Compare commits
66 Commits
2024.2-121
...
develop
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 8557e110a8 | |
Shadowfacts | c2232a5e14 | |
Shadowfacts | e6d9a33dbf | |
Shadowfacts | d8fccc8f1b | |
Shadowfacts | 6528070f1c | |
Shadowfacts | 09c6a87e19 | |
Shadowfacts | cd0d8fffcb | |
Shadowfacts | 1b6f0c07fd | |
Shadowfacts | 2f31b50a5b | |
Shadowfacts | cee4e15b06 | |
Shadowfacts | 888f44366c | |
Shadowfacts | c88076eec0 | |
Shadowfacts | afe47437e4 | |
Shadowfacts | 4dc484c3c2 | |
Shadowfacts | 0f2a85b108 | |
Shadowfacts | 5e55ce75c2 | |
Shadowfacts | eec2adbfd9 | |
Shadowfacts | a848f6e425 | |
Shadowfacts | 44896d305e | |
Shadowfacts | 6c70ed4b4e | |
Shadowfacts | e3c480131a | |
Shadowfacts | 575166f5b4 | |
Shadowfacts | c60aa3e3f3 | |
Shadowfacts | 75f0d12c82 | |
Shadowfacts | 5cf2bc4fbf | |
Shadowfacts | 908b499f8f | |
Shadowfacts | 67c7905acf | |
Shadowfacts | eacafe87b3 | |
Shadowfacts | 2a53b24487 | |
Shadowfacts | 9df3c33c6c | |
Shadowfacts | d4e82d6e7a | |
Shadowfacts | 06ba758309 | |
Shadowfacts | 2c56902389 | |
Shadowfacts | cb3fd43dbd | |
Shadowfacts | 3d15759fb9 | |
Shadowfacts | 5620b6ab78 | |
Shadowfacts | 09999175f7 | |
Shadowfacts | f2a9f890ff | |
Shadowfacts | 093994b474 | |
Shadowfacts | 3d0de5af04 | |
Shadowfacts | 966a906436 | |
Shadowfacts | 844d4056e3 | |
Shadowfacts | 00ef131bb6 | |
Shadowfacts | d6be6f14dc | |
Shadowfacts | 2ccf028bc2 | |
Shadowfacts | 3eeffada1f | |
Shadowfacts | 0499255be7 | |
Shadowfacts | f909c1da10 | |
Shadowfacts | 81543965ae | |
Shadowfacts | 96d42756d5 | |
Shadowfacts | f6e57d664f | |
Shadowfacts | c33be1cbf3 | |
Shadowfacts | 6d99156bd9 | |
Shadowfacts | ca764811ed | |
Shadowfacts | a589bb2863 | |
Shadowfacts | 6f35fd2676 | |
Shadowfacts | e83cef1c8c | |
Shadowfacts | b89df3f27b | |
Shadowfacts | 4ecc16a93b | |
Shadowfacts | 8960873ff3 | |
Shadowfacts | 043a708515 | |
Shadowfacts | c6b230414e | |
Shadowfacts | f5e9f66f76 | |
Shadowfacts | ee5f9a62ff | |
Shadowfacts | a92cf8c812 | |
Shadowfacts | 756874949a |
|
@ -1,3 +1,20 @@
|
|||
## 2024.2
|
||||
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!
|
||||
|
||||
Features/Improvements:
|
||||
- Push notifications
|
||||
- Add post preview to Appearance preferences
|
||||
- Show instance announcements in Notifications tab
|
||||
- Add subscription option to Tip Jar
|
||||
- iPadOS: Multi-column navigation
|
||||
- Pleroma/Akkoma: Emoji reaction notifications
|
||||
|
||||
Bugfixes:
|
||||
- Fix fetching server info on some instances
|
||||
- Fix attachment captions not displaying while loading in gallery
|
||||
- macOS: Remove in-app Safari preferences
|
||||
- Pleroma: Handle posts with missing creation date
|
||||
|
||||
## 2024.1
|
||||
This update includes a significant improvements for the attachment gallery and displaying rich text posts. See below for a full list of improvements and fixes.
|
||||
|
||||
|
|
52
CHANGELOG.md
52
CHANGELOG.md
|
@ -1,5 +1,57 @@
|
|||
# Changelog
|
||||
|
||||
## 2024.3 (127)
|
||||
Bugfixes:
|
||||
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen
|
||||
- Fix profile header images being blurry
|
||||
- Fix dismissing gallery when presented from sheet
|
||||
- Fix potential crash in multi-column interface
|
||||
- Fix crash when opening push notification while sheet presented
|
||||
- Fix being able to block your own domain
|
||||
- Fix links in profile fields with other text not being interactable
|
||||
- Fix excessive CPU use immediately after app launch
|
||||
- Fix timeline failing to load when one status is malformed
|
||||
- iPadOS: Fix pointer interactions on conversation main status action buttons
|
||||
- iPadOS: Fix multiple close buttons being added in multi-column interface
|
||||
- iPadOS: Fix Cmd+1/etc. resetting navigation state when returning to previous column
|
||||
- iPadOS: Fix previous sidebar selection losing navigation state in some circumstances
|
||||
- iPadOS: Fix profile followers/following buttons not having pointer effect
|
||||
- iPadOS: Fix search token suggestions not having pointer effect
|
||||
- iPadOS: Fix conversation thread links appearing above avatar during pointer effect
|
||||
- iPadOS: Fix multi-column interface not animating scroll when replacing subsequent columns
|
||||
- iPadOS: Fix not being able to select text on conversation main status by double-clicking with cursor
|
||||
- iPadOS: Fix selecting search result always pushing new column rather than replacing
|
||||
- Pixelfed/Firefish: Fix error loading accounts in some circumstances
|
||||
- Pixelfed: Fix loading relationships and follow/block/etc. actions not working
|
||||
|
||||
## 2024.3 (126)
|
||||
Bugfixes:
|
||||
- Fix an issue displaying post HTML in certain edge cases
|
||||
- Fix crash when video attachment playback ends
|
||||
- Fix excessive CPU usage when scrubbing video attachment
|
||||
- Fix video attachment thubmnails being flipped on Compose screen
|
||||
- Pleroma: Fix editing attachment descriptions not working
|
||||
|
||||
## 2024.2 (124)
|
||||
Features/Improvements:
|
||||
- Add subscription option to Tip Jar
|
||||
|
||||
Bugfixes:
|
||||
- Fix attachment captions not displaying while loading in gallery
|
||||
- Fix tapping follow request push notification not working
|
||||
- Pleroma: Handle posts with missing creation dates
|
||||
|
||||
## 2024.2 (122)
|
||||
Features/Improvements:
|
||||
- Show instance announcements in Notifications
|
||||
- Pleroma/Akkoma: Display emoji reactions in Notifications
|
||||
- Pleroma/Akkoma: Add push notifications for emoji reactions
|
||||
|
||||
Bugfixes:
|
||||
- Fix issue fetching server info on some instances
|
||||
- Fix Preferences background color not updating after changing Pure Black Dark Mode
|
||||
- Fix push subscription settings background using incorrect color with Pure Black Dark Mode off
|
||||
|
||||
## 2024.2 (121)
|
||||
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.
|
||||
|
||||
|
|
|
@ -63,6 +63,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||
mutableContent.body = notification.body
|
||||
mutableContent.userInfo["notificationID"] = notification.notificationID
|
||||
mutableContent.userInfo["accountID"] = accountID
|
||||
mutableContent.targetContentIdentifier = accountID
|
||||
|
||||
let task = Task {
|
||||
await updateNotificationContent(mutableContent, account: account, push: notification)
|
||||
|
@ -109,6 +110,12 @@ class NotificationService: UNNotificationServiceExtension {
|
|||
kindStr = "📊 Poll finished"
|
||||
case .update:
|
||||
kindStr = "✏️ Edited"
|
||||
case .emojiReaction:
|
||||
if let emoji = notification.emoji {
|
||||
kindStr = "\(emoji) Reacted"
|
||||
} else {
|
||||
kindStr = nil
|
||||
}
|
||||
default:
|
||||
kindStr = nil
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ class AttachmentThumbnailController: ViewController {
|
|||
case .video, .gifv:
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
|
@ -91,6 +92,7 @@ class AttachmentThumbnailController: ViewController {
|
|||
if type.conforms(to: .movie) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import SwiftUI
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteEmojisController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteHashtagsController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
|
|
|
@ -52,15 +52,22 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
|||
appliedSourceToDestTransform = false
|
||||
}
|
||||
|
||||
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||
// is in the window's root presentation.
|
||||
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||
// container causees it to be removed when the transition completes.
|
||||
if to.view.superview == nil {
|
||||
to.view.frame = container.bounds
|
||||
container.addSubview(to.view)
|
||||
}
|
||||
|
||||
from.view.frame = container.bounds
|
||||
container.addSubview(from.view)
|
||||
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content.view.layer.masksToBounds = true
|
||||
|
||||
container.addSubview(to.view)
|
||||
container.addSubview(from.view)
|
||||
container.addSubview(content.view)
|
||||
|
||||
content.view.frame = destFrameInContainer
|
||||
|
|
|
@ -157,7 +157,7 @@ public final class InstanceFeatures: ObservableObject {
|
|||
}
|
||||
|
||||
public var needsEditAttachmentsInSeparateRequest: Bool {
|
||||
instanceType.isPleroma(.akkoma(nil))
|
||||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
public var composeDirectStatuses: Bool {
|
||||
|
@ -209,6 +209,26 @@ public final class InstanceFeatures: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public var instanceAnnouncements: Bool {
|
||||
hasMastodonVersion(3, 1, 0)
|
||||
}
|
||||
|
||||
public var emojiReactionNotifications: Bool {
|
||||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
public var muteNotifications: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public var blockDomains: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public var hideReblogs: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public init() {
|
||||
}
|
||||
|
||||
|
@ -330,6 +350,14 @@ extension InstanceFeatures {
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isPixelfed: Bool {
|
||||
if case .pixelfed = self {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(InstanceType) public enum MastodonType {
|
||||
|
|
|
@ -42,7 +42,7 @@ public struct Client: Sendable {
|
|||
} else if let date = iso8601.date(from: str) {
|
||||
return date
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -204,8 +204,8 @@ public struct Client: Sendable {
|
|||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||
}
|
||||
|
||||
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
@ -456,14 +456,13 @@ public struct Client: Sendable {
|
|||
}
|
||||
|
||||
// MARK: - Timelines
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
return timeline.request(range: range)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Bookmarks
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
@ -491,7 +490,7 @@ public struct Client: Sendable {
|
|||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
||||
}
|
||||
|
||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
|
||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
|
|
|
@ -40,8 +40,9 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
|||
self.displayName = try container.decode(String.self, forKey: .displayName)
|
||||
self.locked = try container.decode(Bool.self, forKey: .locked)
|
||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
|
||||
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
|
||||
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
|
||||
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
|
||||
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
||||
self.note = try container.decode(String.self, forKey: .note)
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
|
@ -94,8 +95,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
|||
return request
|
||||
}
|
||||
|
||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||
"only_media" => onlyMedia,
|
||||
"pinned" => pinned,
|
||||
"exclude_replies" => excludeReplies,
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
//
|
||||
// Announcement.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 4/16/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Announcement: Decodable, Sendable, Hashable, Identifiable {
|
||||
public let id: String
|
||||
public let content: String
|
||||
public let startsAt: Date?
|
||||
public let endsAt: Date?
|
||||
public let allDay: Bool
|
||||
public let publishedAt: Date
|
||||
public let updatedAt: Date
|
||||
public let read: Bool?
|
||||
public let mentions: [Account]
|
||||
public let statuses: [Status]
|
||||
public let tags: [Hashtag]
|
||||
public let emojis: [Emoji]
|
||||
public var reactions: [Reaction]
|
||||
|
||||
public static func all() -> Request<[Announcement]> {
|
||||
return Request(method: .get, path: "/api/v1/announcements")
|
||||
}
|
||||
|
||||
public static func dismiss(id: String) -> Request<Empty> {
|
||||
return Request(method: .post, path: "/api/v1/announcements/\(id)/dismiss")
|
||||
}
|
||||
|
||||
public static func react(id: String, name: String) -> Request<Empty> {
|
||||
return Request(method: .put, path: "/api/v1/announcements/\(id)/reactions/\(name)")
|
||||
}
|
||||
|
||||
public static func unreact(id: String, name: String) -> Request<Empty> {
|
||||
return Request(method: .delete, path: "/api/v1/announcements/\(id)/reactions/\(name)")
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case content
|
||||
case startsAt = "starts_at"
|
||||
case endsAt = "ends_at"
|
||||
case allDay = "all_day"
|
||||
case publishedAt = "published_at"
|
||||
case updatedAt = "updated_at"
|
||||
case read
|
||||
case mentions
|
||||
case statuses
|
||||
case tags
|
||||
case emojis
|
||||
case reactions
|
||||
}
|
||||
}
|
||||
|
||||
extension Announcement {
|
||||
public struct Account: Decodable, Sendable, Hashable {
|
||||
public let id: String
|
||||
public let username: String
|
||||
public let url: WebURL
|
||||
public let acct: String
|
||||
}
|
||||
}
|
||||
|
||||
extension Announcement {
|
||||
public struct Status: Decodable, Sendable, Hashable {
|
||||
public let id: String
|
||||
public let url: WebURL
|
||||
}
|
||||
}
|
||||
|
||||
extension Announcement {
|
||||
public struct Reaction: Decodable, Sendable, Hashable {
|
||||
public let name: String
|
||||
public var count: Int
|
||||
public var me: Bool?
|
||||
public let url: URL?
|
||||
public let staticURL: URL?
|
||||
|
||||
public init(name: String, count: Int, me: Bool?, url: URL?, staticURL: URL?) {
|
||||
self.name = name
|
||||
self.count = count
|
||||
self.me = me
|
||||
self.url = url
|
||||
self.staticURL = staticURL
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case count
|
||||
case me
|
||||
case url
|
||||
case staticURL = "static_url"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -43,8 +43,13 @@ extension Emoji: CustomDebugStringConvertible {
|
|||
}
|
||||
}
|
||||
|
||||
extension Emoji: Equatable {
|
||||
extension Emoji: Equatable, Hashable {
|
||||
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
|
||||
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(shortcode)
|
||||
hasher.combine(url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ extension InstanceV2 {
|
|||
public struct Thumbnail: Decodable, Sendable {
|
||||
public let url: String
|
||||
public let blurhash: String?
|
||||
public let versions: ThumbnailVersions
|
||||
public let versions: ThumbnailVersions?
|
||||
}
|
||||
|
||||
public struct ThumbnailVersions: Decodable, Sendable {
|
||||
|
@ -120,6 +120,6 @@ extension InstanceV2 {
|
|||
extension InstanceV2 {
|
||||
public struct Contact: Decodable, Sendable {
|
||||
public let email: String
|
||||
public let account: Account
|
||||
public let account: Account?
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Notification: Decodable, Sendable {
|
||||
public let id: String
|
||||
|
@ -14,6 +15,10 @@ public struct Notification: Decodable, Sendable {
|
|||
public let createdAt: Date
|
||||
public let account: Account
|
||||
public let status: Status?
|
||||
// Only present for pleroma emoji reactions
|
||||
// Either an emoji or :shortcode: (for akkoma custom emoji reactions)
|
||||
public let emoji: String?
|
||||
public let emojiURL: WebURL?
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
@ -27,6 +32,8 @@ public struct Notification: Decodable, Sendable {
|
|||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.account = try container.decode(Account.self, forKey: .account)
|
||||
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
|
||||
self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
|
||||
}
|
||||
|
||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||
|
@ -39,6 +46,8 @@ public struct Notification: Decodable, Sendable {
|
|||
case createdAt = "created_at"
|
||||
case account
|
||||
case status
|
||||
case emoji
|
||||
case emojiURL = "emoji_url"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,6 +61,7 @@ extension Notification {
|
|||
case poll
|
||||
case update
|
||||
case status
|
||||
case emojiReaction = "pleroma:emoji_reaction"
|
||||
case unknown
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ public struct PushSubscription: Decodable, Sendable {
|
|||
"data[alerts][favourite]" => alerts.favourite,
|
||||
"data[alerts][poll]" => alerts.poll,
|
||||
"data[alerts][update]" => alerts.update,
|
||||
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
|
||||
"data[policy]" => policy.rawValue,
|
||||
]))
|
||||
}
|
||||
|
@ -58,6 +59,7 @@ public struct PushSubscription: Decodable, Sendable {
|
|||
"data[alerts][favourite]" => alerts.favourite,
|
||||
"data[alerts][poll]" => alerts.poll,
|
||||
"data[alerts][update]" => alerts.update,
|
||||
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
|
||||
"data[policy]" => policy.rawValue,
|
||||
]))
|
||||
}
|
||||
|
@ -85,8 +87,19 @@ extension PushSubscription {
|
|||
public let favourite: Bool
|
||||
public let poll: Bool
|
||||
public let update: Bool
|
||||
public let emojiReaction: Bool
|
||||
|
||||
public init(mention: Bool, status: Bool, reblog: Bool, follow: Bool, followRequest: Bool, favourite: Bool, poll: Bool, update: Bool) {
|
||||
public init(
|
||||
mention: Bool,
|
||||
status: Bool,
|
||||
reblog: Bool,
|
||||
follow: Bool,
|
||||
followRequest: Bool,
|
||||
favourite: Bool,
|
||||
poll: Bool,
|
||||
update: Bool,
|
||||
emojiReaction: Bool
|
||||
) {
|
||||
self.mention = mention
|
||||
self.status = status
|
||||
self.reblog = reblog
|
||||
|
@ -95,6 +108,7 @@ extension PushSubscription {
|
|||
self.favourite = favourite
|
||||
self.poll = poll
|
||||
self.update = update
|
||||
self.emojiReaction = emojiReaction
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
|
@ -110,6 +124,8 @@ extension PushSubscription {
|
|||
self.poll = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.poll)
|
||||
// update added in mastodon 3.5.0
|
||||
self.update = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.update) ?? false
|
||||
// pleroma/akkoma only
|
||||
self.emojiReaction = try container.decodeIfPresent(Bool.self, forKey: .emojiReaction) ?? false
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
@ -121,6 +137,7 @@ extension PushSubscription {
|
|||
case favourite
|
||||
case poll
|
||||
case update
|
||||
case emojiReaction = "pleroma:emoji_reaction"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,10 +27,13 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
|
|||
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
||||
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
||||
self.muting = try container.decode(Bool.self, forKey: .muting)
|
||||
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
|
||||
// not supported on pixelfed
|
||||
self.mutingNotifications = try container.decodeIfPresent(Bool.self, forKey: .mutingNotifications) ?? false
|
||||
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
||||
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
|
||||
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
|
||||
// not supported on pixelfed
|
||||
self.domainBlocking = try container.decodeIfPresent(Bool.self, forKey: .domainBlocking) ?? false
|
||||
// not supported on pixelfed
|
||||
self.showingReblogs = try container.decodeIfPresent(Bool.self, forKey: .showingReblogs) ?? true
|
||||
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import Foundation
|
|||
|
||||
public struct SearchResults: Decodable, Sendable {
|
||||
public let accounts: [Account]
|
||||
public let statuses: [Status]
|
||||
public let statuses: [TryDecode<Status>]
|
||||
public let hashtags: [Hashtag]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
|
|
@ -124,6 +124,12 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
return request
|
||||
}
|
||||
|
||||
public static func getReactions(_ statusID: String, emoji: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reactions/\(emoji)")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func delete(_ statusID: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
|
||||
}
|
||||
|
|
|
@ -32,8 +32,8 @@ extension Timeline {
|
|||
}
|
||||
}
|
||||
|
||||
func request(range: RequestRange) -> Request<[Status]> {
|
||||
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
|
||||
func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
|
||||
if case .public(true) = self {
|
||||
request.queryParameters.append("local" => true)
|
||||
}
|
||||
|
|
|
@ -7,17 +7,18 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||
public private(set) var notifications: [Notification]
|
||||
public let id: String
|
||||
public let kind: Notification.Kind
|
||||
public let kind: Kind
|
||||
|
||||
public init?(notifications: [Notification]) {
|
||||
public init?(notifications: [Notification], kind: Kind) {
|
||||
guard !notifications.isEmpty else { return nil }
|
||||
self.notifications = notifications
|
||||
self.id = notifications.first!.id
|
||||
self.kind = notifications.first!.kind
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
||||
|
@ -44,30 +45,61 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
|||
notifications.append(contentsOf: group.notifications)
|
||||
}
|
||||
|
||||
private static func groupKind(for notification: Notification) -> Kind {
|
||||
switch notification.kind {
|
||||
case .mention:
|
||||
return .mention
|
||||
case .reblog:
|
||||
return .reblog
|
||||
case .favourite:
|
||||
return .favourite
|
||||
case .follow:
|
||||
return .follow
|
||||
case .followRequest:
|
||||
return .followRequest
|
||||
case .poll:
|
||||
return .poll
|
||||
case .update:
|
||||
return .update
|
||||
case .status:
|
||||
return .status
|
||||
case .emojiReaction:
|
||||
if let emoji = notification.emoji {
|
||||
return .emojiReaction(emoji, notification.emojiURL)
|
||||
} else {
|
||||
return .unknown
|
||||
}
|
||||
case .unknown:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||
var groups = [NotificationGroup]()
|
||||
for notification in notifications {
|
||||
let groupKind = groupKind(for: notification)
|
||||
|
||||
if allowedTypes.contains(notification.kind) {
|
||||
if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) {
|
||||
if let lastGroup = groups.last, canMerge(notification: notification, kind: groupKind, into: lastGroup) {
|
||||
groups[groups.count - 1].append(notification)
|
||||
continue
|
||||
} else if groups.count >= 2 {
|
||||
let secondToLastGroup = groups[groups.count - 2]
|
||||
if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) {
|
||||
if allowedTypes.contains(notification.kind), canMerge(notification: notification, kind: groupKind, into: secondToLastGroup) {
|
||||
groups[groups.count - 2].append(notification)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups.append(NotificationGroup(notifications: [notification])!)
|
||||
groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool {
|
||||
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
|
||||
private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool {
|
||||
return kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
|
||||
}
|
||||
|
||||
public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||
|
@ -82,21 +114,21 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
|||
var second = second
|
||||
merged.reserveCapacity(second.count)
|
||||
while let firstGroupFromSecond = second.first,
|
||||
allowedTypes.contains(firstGroupFromSecond.kind) {
|
||||
allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) {
|
||||
|
||||
second.removeFirst()
|
||||
|
||||
guard let lastGroup = merged.last,
|
||||
allowedTypes.contains(lastGroup.kind) else {
|
||||
allowedTypes.contains(lastGroup.kind.notificationKind) else {
|
||||
merged.append(firstGroupFromSecond)
|
||||
break
|
||||
}
|
||||
|
||||
if canMerge(notification: firstGroupFromSecond.notifications.first!, into: lastGroup) {
|
||||
if canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, 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) {
|
||||
if allowedTypes.contains(secondToLastGroup.kind.notificationKind), canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: secondToLastGroup) {
|
||||
merged[merged.count - 2].append(group: firstGroupFromSecond)
|
||||
} else {
|
||||
merged.append(firstGroupFromSecond)
|
||||
|
@ -109,4 +141,42 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
|||
return merged
|
||||
}
|
||||
|
||||
public enum Kind: Sendable, Equatable {
|
||||
case mention
|
||||
case reblog
|
||||
case favourite
|
||||
case follow
|
||||
case followRequest
|
||||
case poll
|
||||
case update
|
||||
case status
|
||||
case emojiReaction(String, WebURL?)
|
||||
case unknown
|
||||
|
||||
var notificationKind: Notification.Kind {
|
||||
switch self {
|
||||
case .mention:
|
||||
.mention
|
||||
case .reblog:
|
||||
.reblog
|
||||
case .favourite:
|
||||
.favourite
|
||||
case .follow:
|
||||
.follow
|
||||
case .followRequest:
|
||||
.followRequest
|
||||
case .poll:
|
||||
.poll
|
||||
case .update:
|
||||
.update
|
||||
case .status:
|
||||
.status
|
||||
case .emojiReaction(_, _):
|
||||
.emojiReaction
|
||||
case .unknown:
|
||||
.unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// TryDecode.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 6/8/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TryDecode<T: Decodable>: Decodable {
|
||||
case error(String)
|
||||
case value(T)
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
do {
|
||||
self = .value(try T(from: decoder))
|
||||
} catch {
|
||||
self = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public var value: T? {
|
||||
if case .value(let value) = self {
|
||||
value
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TryDecode: Sendable where T: Sendable {
|
||||
}
|
|
@ -104,6 +104,7 @@ class PushManagerImpl: _PushManager {
|
|||
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
|
||||
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
|
||||
guard newEndpoint != $0.endpoint else {
|
||||
PushManager.logger.debug("Skipping update of push subscription with endpoint \($0.endpoint, privacy: .public)")
|
||||
return $0
|
||||
}
|
||||
var copy = $0
|
||||
|
|
|
@ -70,6 +70,7 @@ public struct PushSubscription {
|
|||
public static let favorite = Alerts(rawValue: 1 << 5)
|
||||
public static let poll = Alerts(rawValue: 1 << 6)
|
||||
public static let update = Alerts(rawValue: 1 << 7)
|
||||
public static let emojiReaction = Alerts(rawValue: 1 << 8)
|
||||
|
||||
public let rawValue: Int
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
//
|
||||
// FuzzyMatcher.swift
|
||||
// ComposeUI
|
||||
// TuskerComponents
|
||||
//
|
||||
// Created by Shadowfacts on 10/10/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
struct FuzzyMatcher {
|
||||
public struct FuzzyMatcher {
|
||||
|
||||
private init() {}
|
||||
|
||||
|
@ -21,7 +21,7 @@ struct FuzzyMatcher {
|
|||
/// +2 points for every char in `pattern` that occurs in `str` sequentially
|
||||
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
|
||||
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
|
||||
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
||||
public static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
||||
let pattern = pattern.lowercased()
|
||||
let str = str.lowercased()
|
||||
|
|
@ -74,4 +74,9 @@ extension PreferenceStore {
|
|||
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
|
||||
enabledFeatureFlags.contains(flag)
|
||||
}
|
||||
|
||||
|
||||
public func getValue<Key: PreferenceKey>(preferenceKeyPath: KeyPath<PreferenceStore, PreferencePublisher<Key>>) -> Key.Value {
|
||||
self[keyPath: preferenceKeyPath].preference.wrappedValue
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@
|
|||
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 */; };
|
||||
D6187BED2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.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 */; };
|
||||
|
@ -74,6 +75,7 @@
|
|||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
||||
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
||||
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */; };
|
||||
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; };
|
||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
|
||||
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; };
|
||||
|
@ -95,7 +97,7 @@
|
|||
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3C92BC59FF500208903 /* MastodonController+Push.swift */; };
|
||||
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */; };
|
||||
D630C3D42BC61B6100208903 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3D32BC61B6100208903 /* NotificationService.swift */; };
|
||||
D630C3D82BC61B6100208903 /* NotificationExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D630C3D12BC61B6000208903 /* NotificationExtension.appex */; platformFilters = (ios, maccatalyst, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
D630C3D82BC61B6100208903 /* NotificationExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D630C3D12BC61B6000208903 /* NotificationExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3DE2BC61C4900208903 /* PushNotifications */; };
|
||||
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; };
|
||||
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; };
|
||||
|
@ -227,6 +229,12 @@
|
|||
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */; };
|
||||
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */; };
|
||||
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; };
|
||||
D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */; };
|
||||
D698F4692BD0799F0054DB14 /* AnnouncementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */; };
|
||||
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */; };
|
||||
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */; };
|
||||
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */; };
|
||||
D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */; };
|
||||
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 */; };
|
||||
|
@ -251,6 +259,7 @@
|
|||
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
|
||||
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
|
||||
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
|
||||
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */; };
|
||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
|
||||
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; };
|
||||
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; };
|
||||
|
@ -291,6 +300,7 @@
|
|||
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */; };
|
||||
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */; };
|
||||
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532E2BCB873400E26A0E /* MockStatusView.swift */; };
|
||||
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */; };
|
||||
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
|
||||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
|
||||
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
|
||||
|
@ -460,6 +470,7 @@
|
|||
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>"; };
|
||||
D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationViewController.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>"; };
|
||||
|
@ -497,6 +508,7 @@
|
|||
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
||||
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
||||
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
||||
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveProfileSuggestionService.swift; sourceTree = "<group>"; };
|
||||
D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = "<group>"; };
|
||||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
|
||||
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
|
||||
|
@ -649,6 +661,12 @@
|
|||
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeSceneDelegate.swift; sourceTree = "<group>"; };
|
||||
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextAttachment+Emoji.swift"; sourceTree = "<group>"; };
|
||||
D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = "<group>"; };
|
||||
D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsHostingController.swift; sourceTree = "<group>"; };
|
||||
D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsView.swift; sourceTree = "<group>"; };
|
||||
D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementListRow.swift; sourceTree = "<group>"; };
|
||||
D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsCollection.swift; sourceTree = "<group>"; };
|
||||
D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddReactionView.swift; sourceTree = "<group>"; };
|
||||
D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnouncementContentTextView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
|
@ -670,6 +688,7 @@
|
|||
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
|
||||
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
|
||||
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
||||
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentHistoryTokenStore.swift; sourceTree = "<group>"; };
|
||||
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = "<group>"; };
|
||||
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
|
||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -1034,6 +1053,7 @@
|
|||
D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
|
||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */,
|
||||
);
|
||||
path = CoreData;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1054,6 +1074,7 @@
|
|||
children = (
|
||||
D65B4B89297879DE00DABDFB /* Account Follows */,
|
||||
D6A3BC822321F69400FD64D5 /* Account List */,
|
||||
D698F4472BCEE2320054DB14 /* Announcements */,
|
||||
D641C787213DD862004B4513 /* Compose */,
|
||||
D641C785213DD83B004B4513 /* Conversation */,
|
||||
D6F2E960249E772F005846BB /* Crash Reporter */,
|
||||
|
@ -1150,6 +1171,7 @@
|
|||
D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */,
|
||||
D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */,
|
||||
D68245112BCA1F4000AFB38B /* NotificationLoadingViewController.swift */,
|
||||
D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift */,
|
||||
);
|
||||
path = Notifications;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1349,6 +1371,19 @@
|
|||
path = About;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D698F4472BCEE2320054DB14 /* Announcements */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */,
|
||||
D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */,
|
||||
D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */,
|
||||
D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */,
|
||||
D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */,
|
||||
D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */,
|
||||
);
|
||||
path = Announcements;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6A3BC822321F69400FD64D5 /* Account List */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1727,6 +1762,7 @@
|
|||
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
|
||||
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
|
||||
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
|
||||
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */,
|
||||
);
|
||||
path = API;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1954,6 +1990,7 @@
|
|||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -2129,6 +2166,7 @@
|
|||
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
|
||||
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
|
||||
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
||||
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
|
||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
||||
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
|
||||
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */,
|
||||
|
@ -2159,8 +2197,10 @@
|
|||
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
|
||||
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
|
||||
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
|
||||
D698F4692BD0799F0054DB14 /* AnnouncementsView.swift in Sources */,
|
||||
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
|
||||
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */,
|
||||
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */,
|
||||
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
|
||||
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
|
||||
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */,
|
||||
|
@ -2188,6 +2228,7 @@
|
|||
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
|
||||
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
|
||||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
|
||||
D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */,
|
||||
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
|
||||
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */,
|
||||
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
|
||||
|
@ -2202,6 +2243,7 @@
|
|||
D65A261D2BC39399005EB5D8 /* PushInstanceSettingsView.swift in Sources */,
|
||||
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
|
||||
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
||||
D6187BED2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift in Sources */,
|
||||
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */,
|
||||
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
|
||||
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
|
||||
|
@ -2323,8 +2365,10 @@
|
|||
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */,
|
||||
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
|
||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
||||
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */,
|
||||
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
||||
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
|
||||
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */,
|
||||
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
|
||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
||||
|
@ -2367,9 +2411,11 @@
|
|||
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */,
|
||||
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
|
||||
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */,
|
||||
D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */,
|
||||
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
|
||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
||||
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
|
||||
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */,
|
||||
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
||||
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */,
|
||||
);
|
||||
|
@ -3203,7 +3249,7 @@
|
|||
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 0.2.3;
|
||||
version = 0.2.4;
|
||||
};
|
||||
};
|
||||
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
|
||||
|
|
|
@ -58,7 +58,8 @@ private extension Pachyderm.PushSubscription.Alerts {
|
|||
followRequest: alerts.contains(.followRequest),
|
||||
favourite: alerts.contains(.favorite),
|
||||
poll: alerts.contains(.poll),
|
||||
update: alerts.contains(.update)
|
||||
update: alerts.contains(.update),
|
||||
emojiReaction: alerts.contains(.emojiReaction)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// RemoveProfileSuggestionService.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/1/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
@MainActor
|
||||
class RemoveProfileSuggestionService {
|
||||
private let accountID: String
|
||||
private let mastodonController: MastodonController
|
||||
private let presenter: any TuskerNavigationDelegate
|
||||
private let completionHandler: @MainActor () -> Void
|
||||
|
||||
init(accountID: String, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate, completionHandler: @MainActor @escaping () -> Void) {
|
||||
self.accountID = accountID
|
||||
self.mastodonController = mastodonController
|
||||
self.presenter = presenter
|
||||
self.completionHandler = completionHandler
|
||||
}
|
||||
|
||||
func run() async {
|
||||
let req = Suggestion.remove(accountID: accountID)
|
||||
do {
|
||||
_ = try await mastodonController.run(req)
|
||||
completionHandler()
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: presenter) { toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.run()
|
||||
}
|
||||
self.presenter.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -185,7 +185,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
let mastodonController = MastodonController.getForAccount(account)
|
||||
do {
|
||||
let result = try await mastodonController.updatePushSubscription(subscription: $0)
|
||||
PushManager.logger.debug("Updated push subscription \(result.id) on \(mastodonController.instanceURL)")
|
||||
PushManager.logger.info("Updated push subscription \(result.id, privacy: .public) on \(mastodonController.instanceURL) with endpoint \($0.endpoint, privacy: .public)")
|
||||
PushManager.logger.debug("New push subscription: \(String(describing: result))")
|
||||
return true
|
||||
} catch {
|
||||
PushManager.logger.error("Error updating push subscription: \(String(describing: error))")
|
||||
|
@ -289,13 +290,19 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
|
|||
// if the scene is already active, then we animate the account switching if necessary
|
||||
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
|
||||
|
||||
rootViewController.select(route: .notifications, animated: false)
|
||||
rootViewController.select(route: .notifications, animated: false) {
|
||||
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
|
||||
rootViewController.getNavigationController().pushViewController(vc, animated: false)
|
||||
}
|
||||
} else {
|
||||
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
|
||||
if #available(iOS 17.0, *) {
|
||||
let request = UISceneSessionActivationRequest(userActivity: activity)
|
||||
UIApplication.shared.activateSceneSession(for: request)
|
||||
} else {
|
||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
|
||||
}
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"symbols" : [
|
||||
{
|
||||
"filename" : "face.smiling.badge.plus.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--Generator: Apple Native CoreSVG 232.5-->
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="3300" height="2200">
|
||||
<!--glyph: "", point size: 100.0, font version: "19.2d2e1", template writer version: "128"-->
|
||||
<style>.monochrome-0 {-sfsymbols-motion-group:1}
|
||||
.monochrome-1 {-sfsymbols-motion-group:1}
|
||||
.monochrome-2 {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
.monochrome-3 {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
.monochrome-4 {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
|
||||
.multicolor-0:tintColor {-sfsymbols-motion-group:1}
|
||||
.multicolor-1:tintColor {-sfsymbols-motion-group:1}
|
||||
.multicolor-2:tintColor {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
.multicolor-3:systemGreenColor {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
.multicolor-4:white {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
|
||||
.hierarchical-0:secondary {-sfsymbols-motion-group:1}
|
||||
.hierarchical-1:secondary {-sfsymbols-motion-group:1}
|
||||
.hierarchical-2:primary {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
.hierarchical-3:primary {-sfsymbols-motion-group:0}
|
||||
.hierarchical-4:primary {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
|
||||
.SFSymbolsPreviewWireframe {fill:none;opacity:1.0;stroke:black;stroke-width:0.5}
|
||||
</style>
|
||||
<g id="Notes">
|
||||
<rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
|
||||
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 856.422 322)">Thin</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1153.13 322)">Light</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1449.84 322)">Regular</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1746.56 322)">Medium</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2043.27 322)">Semibold</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2339.98 322)">Bold</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2636.69 322)">Heavy</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2933.4 322)">Black</text>
|
||||
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1903" y2="1903"/>
|
||||
<g transform="matrix(0.2 0 0 0.2 263 1933)">
|
||||
<path d="m46.2402 4.15039c21.5332 0 39.4531-17.8711 39.4531-39.4043s-17.9688-39.4043-39.502-39.4043c-21.4844 0-39.3555 17.8711-39.3555 39.4043s17.9199 39.4043 39.4043 39.4043Zm0-7.42188c-17.7246 0-31.8848-14.209-31.8848-31.9824s14.1113-31.9824 31.8359-31.9824c17.7734 0 32.0312 14.209 32.0312 31.9824s-14.209 31.9824-31.9824 31.9824Zm-17.9688-31.9824c0 2.14844 1.51367 3.61328 3.75977 3.61328h10.498v10.5957c0 2.19727 1.46484 3.71094 3.61328 3.71094 2.24609 0 3.71094-1.51367 3.71094-3.71094v-10.5957h10.5957c2.19727 0 3.71094-1.46484 3.71094-3.61328 0-2.19727-1.51367-3.71094-3.71094-3.71094h-10.5957v-10.5469c0-2.24609-1.46484-3.75977-3.71094-3.75977-2.14844 0-3.61328 1.51367-3.61328 3.75977v10.5469h-10.498c-2.24609 0-3.75977 1.51367-3.75977 3.71094Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.2 0 0 0.2 281.506 1933)">
|
||||
<path d="m58.5449 14.5508c27.2461 0 49.8047-22.6074 49.8047-49.8047 0-27.2461-22.6074-49.8047-49.8535-49.8047-27.1973 0-49.7559 22.5586-49.7559 49.8047 0 27.1973 22.6074 49.8047 49.8047 49.8047Zm0-8.30078c-23.0469 0-41.4551-18.457-41.4551-41.5039s18.3594-41.5039 41.4062-41.5039 41.5527 18.457 41.5527 41.5039-18.457 41.5039-41.5039 41.5039Zm-22.6562-41.5039c0 2.39258 1.66016 4.00391 4.15039 4.00391h14.3555v14.4043c0 2.44141 1.66016 4.15039 4.05273 4.15039 2.44141 0 4.15039-1.66016 4.15039-4.15039v-14.4043h14.4043c2.44141 0 4.15039-1.61133 4.15039-4.00391 0-2.44141-1.70898-4.15039-4.15039-4.15039h-14.4043v-14.3555c0-2.49023-1.70898-4.19922-4.15039-4.19922-2.39258 0-4.05273 1.70898-4.05273 4.19922v14.3555h-14.3555c-2.49023 0-4.15039 1.70898-4.15039 4.15039Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.2 0 0 0.2 304.924 1933)">
|
||||
<path d="m74.8535 28.3203c34.8145 0 63.623-28.8086 63.623-63.5742 0-34.8145-28.8574-63.623-63.6719-63.623-34.7656 0-63.5254 28.8086-63.5254 63.623 0 34.7656 28.8086 63.5742 63.5742 63.5742Zm0-9.08203c-30.1758 0-54.4434-24.3164-54.4434-54.4922 0-30.2246 24.2188-54.4922 54.3945-54.4922 30.2246 0 54.541 24.2676 54.541 54.4922 0 30.1758-24.2676 54.4922-54.4922 54.4922Zm-28.8574-54.4922c0 2.58789 1.85547 4.39453 4.58984 4.39453h19.7266v19.7754c0 2.68555 1.85547 4.58984 4.44336 4.58984 2.68555 0 4.54102-1.85547 4.54102-4.58984v-19.7754h19.7754c2.68555 0 4.58984-1.80664 4.58984-4.39453 0-2.73438-1.85547-4.58984-4.58984-4.58984h-19.7754v-19.7266c0-2.73438-1.85547-4.63867-4.54102-4.63867-2.58789 0-4.44336 1.9043-4.44336 4.63867v19.7266h-19.7266c-2.73438 0-4.58984 1.85547-4.58984 4.58984Z"/>
|
||||
</g>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 1953)">Design Variations</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1971)">Symbols are supported in up to nine weights and three scales.</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1989)">For optimal layout with text and other symbols, vertically align</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 2007)">symbols with the adjacent text.</text>
|
||||
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="776" x2="776" y1="1919" y2="1933"/>
|
||||
<g transform="matrix(0.2 0 0 0.2 776 1933)">
|
||||
<path d="m16.5527 0.78125c2.58789 0 3.85742-0.976562 4.78516-3.71094l6.29883-17.2363h28.8086l6.29883 17.2363c0.927734 2.73438 2.19727 3.71094 4.73633 3.71094 2.58789 0 4.24805-1.5625 4.24805-4.00391 0-0.830078-0.146484-1.61133-0.537109-2.63672l-22.9004-60.9863c-1.12305-2.97852-3.125-4.49219-6.25-4.49219-3.02734 0-5.07812 1.46484-6.15234 4.44336l-22.9004 61.084c-0.390625 1.02539-0.537109 1.80664-0.537109 2.63672 0 2.44141 1.5625 3.95508 4.10156 3.95508Zm13.4766-28.3691 11.8652-32.8613h0.244141l11.8652 32.8613Z"/>
|
||||
</g>
|
||||
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="792.836" x2="792.836" y1="1919" y2="1933"/>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 776 1953)">Margins</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1971)">Leading and trailing margins on the left and right side of each symbol</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1989)">can be adjusted by modifying the x-location of the margin guidelines.</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2007)">Modifications are automatically applied proportionally to all</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2025)">scales and weights.</text>
|
||||
<g transform="matrix(0.2 0 0 0.2 1289 1933)">
|
||||
<path d="m14.209 9.32617 8.49609 8.54492c4.29688 4.3457 9.22852 4.05273 13.8672-1.07422l53.4668-58.9355-4.83398-4.88281-53.0762 58.3984c-1.75781 2.00195-3.41797 2.49023-5.76172 0.146484l-5.85938-5.81055c-2.34375-2.29492-1.80664-4.00391 0.195312-5.81055l57.373-54.0039-4.88281-4.83398-57.959 54.4434c-4.93164 4.58984-5.32227 9.47266-1.02539 13.8184Zm32.0801-90.9668c-2.09961 2.05078-2.24609 4.93164-1.07422 6.88477 1.17188 1.80664 3.4668 2.97852 6.68945 2.14844 7.32422-1.70898 14.9414-2.00195 22.0703 2.68555l-2.92969 7.27539c-1.70898 4.15039-0.830078 7.08008 1.85547 9.81445l11.4746 11.5723c2.44141 2.44141 4.49219 2.53906 7.32422 2.05078l5.32227-0.976562 3.32031 3.36914-0.195312 2.7832c-0.195312 2.49023 0.439453 4.39453 2.88086 6.78711l3.80859 3.71094c2.39258 2.39258 5.46875 2.53906 7.8125 0.195312l14.5508-14.5996c2.34375-2.34375 2.24609-5.32227-0.146484-7.71484l-3.85742-3.80859c-2.39258-2.39258-4.24805-3.17383-6.64062-2.97852l-2.88086 0.244141-3.22266-3.17383 1.2207-5.61523c0.634766-2.83203-0.146484-5.0293-3.07617-7.95898l-10.9863-10.9375c-16.6992-16.6016-38.8672-16.2109-53.3203-1.75781Zm7.4707 1.85547c12.1582-8.88672 28.6133-7.37305 39.7461 3.75977l12.1582 12.0605c1.17188 1.17188 1.36719 2.09961 1.02539 3.80859l-1.61133 7.42188 7.51953 7.42188 4.93164-0.292969c1.26953-0.0488281 1.66016 0.0488281 2.63672 1.02539l2.88086 2.88086-12.207 12.207-2.88086-2.88086c-0.976562-0.976562-1.12305-1.36719-1.07422-2.68555l0.341797-4.88281-7.4707-7.42188-7.61719 1.26953c-1.61133 0.341797-2.34375 0.195312-3.56445-0.976562l-10.0098-10.0098c-1.26953-1.17188-1.41602-2.00195-0.634766-3.85742l4.39453-10.4492c-7.8125-7.27539-17.9688-10.4004-28.125-7.42188-0.78125 0.195312-1.07422-0.439453-0.439453-0.976562Z"/>
|
||||
</g>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 1289 1953)">Exporting</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1971)">Symbols should be outlined when exporting to ensure the</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1989)">design is preserved when submitting to Xcode.</text>
|
||||
<text id="template-version" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.5.0</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1951)">Requires Xcode 15 or greater</text>
|
||||
<text id="descriptive-name" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1969)">Generated from </text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1987)">Typeset at 100.0 points</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 726)">Small</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1156)">Medium</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1586)">Large</text>
|
||||
</g>
|
||||
<g id="Guides">
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
|
||||
<line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1126)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
|
||||
<line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1556)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1556" y2="1556"/>
|
||||
<line id="Capline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1485.54" y2="1485.54"/>
|
||||
<line id="left-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="515.649" x2="515.649" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Ultralight-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="603.773" x2="603.773" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1403.58" x2="1403.58" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Regular-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="1496.11" x2="1496.11" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2884.57" x2="2884.57" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Black-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="2982.23" x2="2982.23" y1="600.785" y2="720.121"/>
|
||||
</g>
|
||||
<g id="Symbols">
|
||||
<g id="Black-S" transform="matrix(1 0 0 1 2884.57 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M48.8281 6.78711C71.9727 6.78711 90.8203-12.0605 90.8203-35.2051C90.8203-58.3496 71.9727-77.1973 48.8281-77.1973C25.6836-77.1973 6.83594-58.3496 6.83594-35.2051C6.83594-12.0605 25.6836 6.78711 48.8281 6.78711ZM48.8281-7.37305C33.4473-7.37305 20.9961-19.8242 20.9961-35.2051C20.9961-50.5859 33.4473-63.0371 48.8281-63.0371C64.209-63.0371 76.6602-50.5859 76.6602-35.2051C76.6602-19.8242 64.209-7.37305 48.8281-7.37305Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M48.8281-18.1641C56.9824-18.1641 62.4512-23.5352 62.4512-26.1719C62.4512-27.1973 61.4258-27.6855 60.4004-27.2461C57.4707-25.9766 54.3945-24.2676 48.8281-24.2676C43.2617-24.2676 40.0879-25.8789 37.2559-27.2461C36.2305-27.7344 35.2051-27.1973 35.2051-26.1719C35.2051-23.5352 40.625-18.1641 48.8281-18.1641ZM37.793-38.916C40.0879-38.916 42.0898-40.9668 42.0898-43.7988C42.0898-46.6797 40.0879-48.7305 37.793-48.7305C35.498-48.7305 33.5938-46.6797 33.5938-43.7988C33.5938-40.9668 35.498-38.916 37.793-38.916ZM59.8145-38.916C62.0605-38.916 64.1113-40.9668 64.1113-43.7988C64.1113-46.6797 62.0605-48.7305 59.8145-48.7305C57.4707-48.7305 55.5664-46.6797 55.5664-43.7988C55.5664-40.9668 57.4707-38.916 59.8145-38.916Z"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M87.8941 20.2949C102.836 20.2949 115.189 7.89255 115.189-7.04885C115.189-21.9903 102.836-34.2949 87.8941-34.2949C72.9527-34.2949 60.5992-21.9903 60.5992-7.04885C60.5992 7.89255 72.9527 20.2949 87.8941 20.2949Z"/>
|
||||
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M87.8941 13.9473C99.3688 13.9473 108.842 4.37695 108.842-7.04885C108.842-18.4746 99.3688-27.9472 87.8941-27.9472C76.4195-27.9472 66.9468-18.4746 66.9468-7.04885C66.9468 4.37695 76.4195 13.9473 87.8941 13.9473Z"/>
|
||||
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M87.8941 7.01365C85.5503 7.01365 83.9878 5.45115 83.9878 3.15625L83.9878-3.09375L77.8355-3.09375C75.5406-3.09375 73.9292-4.65625 73.9292-7.00005C73.9292-9.34375 75.4429-10.9062 77.8355-10.9062L83.9878-10.9062L83.9878-17.0097C83.9878-19.3047 85.5503-20.9161 87.8941-20.9161C90.2378-20.9161 91.8003-19.4023 91.8003-17.0097L91.8003-10.9062L98.0018-10.9062C100.297-10.9062 101.859-9.34375 101.859-7.00005C101.859-4.65625 100.297-3.09375 98.0018-3.09375L91.8003-3.09375L91.8003 3.15625C91.8003 5.45115 90.2378 7.01365 87.8941 7.01365Z"/>
|
||||
</g>
|
||||
<g id="Regular-S" transform="matrix(1 0 0 1 1403.58 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M46.2402 4.15039C67.7734 4.15039 85.6934-13.7207 85.6934-35.2539C85.6934-56.7871 67.7246-74.6582 46.1914-74.6582C24.707-74.6582 6.83594-56.7871 6.83594-35.2539C6.83594-13.7207 24.7559 4.15039 46.2402 4.15039ZM46.2402-3.27148C28.5156-3.27148 14.3555-17.4805 14.3555-35.2539C14.3555-53.0273 28.4668-67.2363 46.1914-67.2363C63.9648-67.2363 78.2227-53.0273 78.2227-35.2539C78.2227-17.4805 64.0137-3.27148 46.2402-3.27148Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M46.1914-15.8691C54.3457-15.8691 59.8145-21.2402 59.8145-23.877C59.8145-24.8535 58.8379-25.3418 57.8613-24.9512C54.9805-23.584 51.8066-21.875 46.1914-21.875C40.625-21.875 37.4512-23.584 34.5703-24.9512C33.5938-25.3418 32.6172-24.8535 32.6172-23.877C32.6172-21.2402 38.0859-15.8691 46.1914-15.8691ZM34.9121-38.5742C37.4512-38.5742 39.6973-40.7715 39.6973-43.9453C39.6973-47.2168 37.4512-49.4141 34.9121-49.4141C32.4219-49.4141 30.2246-47.2168 30.2246-43.9453C30.2246-40.7715 32.4219-38.5742 34.9121-38.5742ZM57.5195-38.5742C60.0586-38.5742 62.2559-40.7715 62.2559-43.9453C62.2559-47.2168 60.0586-49.4141 57.5195-49.4141C54.9805-49.4141 52.832-47.2168 52.832-43.9453C52.832-40.7715 54.9805-38.5742 57.5195-38.5742Z"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M83.277 18.3906C97.0956 18.3906 108.668 6.8184 108.668-7C108.668-20.916 97.1926-32.3906 83.277-32.3906C69.3121-32.3906 57.8864-20.916 57.8864-7C57.8864 6.9649 69.3121 18.3906 83.277 18.3906Z"/>
|
||||
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M83.277 12.6777C93.9216 12.6777 102.955 3.7422 102.955-7C102.955-17.791 94.0676-26.6777 83.277-26.6777C72.486-26.6777 63.5504-17.791 63.5504-7C63.5504 3.8399 72.486 12.6777 83.277 12.6777Z"/>
|
||||
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M83.277 5.2559C81.7145 5.2559 80.7379 4.2305 80.7379 2.7168L80.7379-4.4609L73.5602-4.4609C72.0465-4.4609 71.0211-5.4863 71.0211-7C71.0211-8.5137 72.0465-9.5391 73.5602-9.5391L80.7379-9.5391L80.7379-16.7168C80.7379-18.2305 81.7145-19.2559 83.277-19.2559C84.7906-19.2559 85.816-18.2305 85.816-16.7168L85.816-9.5391L92.9936-9.5391C94.5076-9.5391 95.4836-8.5137 95.4836-7C95.4836-5.4863 94.5076-4.4609 92.9936-4.4609L85.816-4.4609L85.816 2.7168C85.816 4.2305 84.7906 5.2559 83.277 5.2559Z"/>
|
||||
</g>
|
||||
<g id="Ultralight-S" transform="matrix(1 0 0 1 515.649 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M44.0606 1.97072C64.5039 1.97072 81.2886-14.8105 81.2886-35.2539C81.2886-55.6973 64.5005-72.4785 44.0571-72.4785C23.5718-72.4785 6.83594-55.6973 6.83594-35.2539C6.83594-14.8105 23.5752 1.97072 44.0606 1.97072ZM44.0606-0.274438C24.7466-0.274438 9.04252-15.9365 9.04252-35.2539C9.04252-54.5713 24.7432-70.2334 44.0571-70.2334C63.3745-70.2334 79.04-54.5713 79.04-35.2539C79.04-15.9365 63.3779-0.274438 44.0606-0.274438Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M44.0571-17.0044C51.3032-17.0044 56.3633-22.103 56.3633-24.2856C56.3633-24.7627 55.9317-24.8423 55.6362-24.5879C53.4365-22.4487 49.4453-20.6489 44.0571-20.6489C38.6724-20.6489 34.7266-22.4941 32.4815-24.5879C32.186-24.8423 31.7544-24.7627 31.7544-24.2856C31.7544-22.103 36.8145-17.0044 44.0571-17.0044ZM32.5054-39.0283C34.4541-39.0283 36.2007-41.0439 36.2007-43.5366C36.2007-45.9907 34.4995-48.0064 32.5054-48.0064C30.5147-48.0064 28.8169-45.9907 28.8169-43.5366C28.8169-41.0439 30.5601-39.0283 32.5054-39.0283ZM55.6123-39.0283C57.5611-39.0283 59.3042-41.0439 59.3042-43.5366C59.3042-45.9907 57.6065-48.0064 55.6123-48.0064C53.6182-48.0064 51.9238-45.9907 51.9238-43.5366C51.9238-41.0439 53.6636-39.0283 55.6123-39.0283Z"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M79.3341 14.3492C90.9727 14.3492 100.684 4.72955 100.684-6.99995C100.684-18.7362 91.0247-28.3492 79.3341-28.3492C67.6397-28.3492 57.9395-18.6908 57.9395-6.99995C57.9395 4.73985 67.6397 14.3492 79.3341 14.3492Z"/>
|
||||
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M79.3341 11.2701C89.2977 11.2701 97.6037 3.06105 97.6037-6.99995C97.6037-17.0644 89.3527-25.2699 79.3341-25.2699C69.315-25.2699 61.0152-17.019 61.0152-6.99995C61.0152 3.06795 69.315 11.2701 79.3341 11.2701Z"/>
|
||||
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M79.3341 4.89265C78.3619 4.89265 77.794 4.23055 77.794 3.35255L77.794-5.50535L68.9361-5.50535C68.149-5.50535 67.4415-6.03115 67.4415-6.99995C67.4415-7.96875 68.149-8.54005 68.9361-8.54005L77.794-8.54005L77.794-17.3071C77.794-18.1396 78.3619-18.8471 79.3341-18.8471C80.2574-18.8471 80.8287-18.1396 80.8287-17.3071L80.8287-8.54005L89.6407-8.54005C90.4737-8.54005 91.1327-7.96875 91.1327-6.99995C91.1327-6.03115 90.4737-5.50535 89.6407-5.50535L80.8287-5.50535L80.8287 3.35255C80.8287 4.23055 80.2574 4.89265 79.3341 4.89265Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 22 KiB |
|
@ -96,7 +96,7 @@ final class ImageCache: @unchecked Sendable {
|
|||
}
|
||||
|
||||
private func fetch(url: URL) async -> FetchResult {
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: url) else {
|
||||
guard let (data, _) = try? await URLSession.appDefault.data(from: url) else {
|
||||
return .none
|
||||
}
|
||||
guard let image = UIImage(data: data) else {
|
||||
|
|
|
@ -48,8 +48,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
return context
|
||||
}()
|
||||
|
||||
private var lastRemoteChangeToken: NSPersistentHistoryToken?
|
||||
|
||||
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
|
||||
// would need to audit existing uses to make sure everything happens on the main thread
|
||||
// and when updating things on the background context would need to switch to main, refetch, and then publish
|
||||
|
@ -124,12 +122,13 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
|
||||
// migrate saved data from local store to cloud store
|
||||
// this can be removed pre-app store release
|
||||
if !FileManager.default.fileExists(atPath: cloudStoreLocation.path) {
|
||||
group.enter()
|
||||
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
|
||||
if FileManager.default.fileExists(atPath: defaultPath.path) {
|
||||
group.enter()
|
||||
let defaultDesc = NSPersistentStoreDescription(url: defaultPath)
|
||||
defaultDesc.configuration = "Default"
|
||||
defaultDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||
let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
||||
defaultPSC.persistentStoreDescriptions = [defaultDesc]
|
||||
defaultPSC.loadPersistentStores { _, error in
|
||||
|
@ -189,9 +188,11 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||
viewContext.name = "View"
|
||||
|
||||
if accountInfo != nil {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
|
||||
}
|
||||
}
|
||||
|
||||
func save(context: NSManagedObjectContext) {
|
||||
guard context.hasChanges else {
|
||||
|
@ -520,22 +521,48 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
}
|
||||
|
||||
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
||||
guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||
guard let accountInfo,
|
||||
let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||
return
|
||||
}
|
||||
remoteChangesBackgroundContext.perform {
|
||||
PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in
|
||||
self.remoteChangesBackgroundContext.perform {
|
||||
defer {
|
||||
self.lastRemoteChangeToken = token
|
||||
PersistentHistoryTokenStore.setToken(token, for: accountInfo)
|
||||
}
|
||||
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken)
|
||||
if let result = try? self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult,
|
||||
let transactions = result.result as? [NSPersistentHistoryTransaction],
|
||||
!transactions.isEmpty {
|
||||
let transactions: [NSPersistentHistoryTransaction]
|
||||
do {
|
||||
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
|
||||
if let result = try self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult {
|
||||
transactions = result.result as? [NSPersistentHistoryTransaction] ?? []
|
||||
} else {
|
||||
logger.error("Unexpectedly non-NSPersistentHistoryResult")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
logger.error("Unable to fetch persistent history results: \(String(describing: error), privacy: .public)")
|
||||
return
|
||||
}
|
||||
if !transactions.isEmpty {
|
||||
self.processPersistentHistoryTransactions(transactions)
|
||||
}
|
||||
|
||||
// NB: We deliberately do not purge old persistent history.
|
||||
// Doing so causes the CoreData+CloudKit integration to replay all of
|
||||
// the server's changes on initialization, which takes a long time
|
||||
// and produces a bunch of intermediate UI updates we don't want.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
|
||||
logger.info("Processing \(transactions.count) persistent history transactions")
|
||||
var changedHashtags = false
|
||||
var changedInstances = false
|
||||
var changedTimelinePositions = Set<NSManagedObjectID>()
|
||||
var changedAccountPrefs = false
|
||||
outer: for transaction in transactions {
|
||||
logger.info("Processing \(transaction.changes?.count ?? 0) changes in transaction")
|
||||
for change in transaction.changes ?? [] {
|
||||
if change.changedObjectID.entity.name == "SavedHashtag" {
|
||||
changedHashtags = true
|
||||
|
@ -573,8 +600,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// PersistentHistoryTokenStore.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/8/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import UserAccounts
|
||||
|
||||
struct PersistentHistoryTokenStore {
|
||||
private static let queue = DispatchQueue(label: "PersistentHistoryTokenStore")
|
||||
|
||||
private static var tokens: [String: NSPersistentHistoryToken] = (try? load()) ?? [:]
|
||||
|
||||
private static let applicationSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
private static let storeURL = applicationSupportDirectory.appendingPathComponent("PersistentHistoryTokenStore.plist")
|
||||
|
||||
private static func load() throws -> [String: NSPersistentHistoryToken]? {
|
||||
let data = try Data(contentsOf: storeURL)
|
||||
let unarchived = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSPersistentHistoryToken.self], from: data)
|
||||
return unarchived as? [String: NSPersistentHistoryToken]
|
||||
}
|
||||
|
||||
private static func save() throws {
|
||||
let data = try NSKeyedArchiver.archivedData(withRootObject: tokens as [NSString: NSPersistentHistoryToken], requiringSecureCoding: true)
|
||||
try data.write(to: PersistentHistoryTokenStore.storeURL)
|
||||
}
|
||||
|
||||
static func token(for account: UserAccountInfo, completion: @escaping (NSPersistentHistoryToken?) -> Void) {
|
||||
queue.async {
|
||||
completion(tokens[account.id])
|
||||
}
|
||||
}
|
||||
|
||||
static func setToken(_ token: NSPersistentHistoryToken, for account: UserAccountInfo) {
|
||||
queue.async {
|
||||
tokens[account.id] = token
|
||||
try? save()
|
||||
}
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
}
|
|
@ -8,26 +8,11 @@
|
|||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import TuskerPreferences
|
||||
|
||||
extension View {
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
func appGroupedListBackground(container: UIAppearanceContainer.Type, applyBackground: Bool = true) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
if applyBackground {
|
||||
self
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
||||
} else {
|
||||
self
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
} else {
|
||||
self
|
||||
.onAppear {
|
||||
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
|
||||
}
|
||||
}
|
||||
func appGroupedListBackground(container: UIAppearanceContainer.Type) -> some View {
|
||||
self.modifier(AppGroupedListBackground(container: container))
|
||||
}
|
||||
|
||||
func appGroupedListRowBackground() -> some View {
|
||||
|
@ -35,11 +20,45 @@ extension View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct AppGroupedListRowBackground: ViewModifier {
|
||||
private struct AppGroupedListBackground: ViewModifier {
|
||||
let container: any UIAppearanceContainer.Type
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.pureBlackDarkMode) private var environmentPureBlackDarkMode
|
||||
|
||||
private var pureBlackDarkMode: Bool {
|
||||
// using @PreferenceObserving just does not work for this, so try the environment key when available
|
||||
// if it's not available, the color won't update automatically, but it will be correct when the view is created
|
||||
if #available(iOS 17.0, *) {
|
||||
environmentPureBlackDarkMode
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if colorScheme == .dark, !Preferences.shared.pureBlackDarkMode {
|
||||
if #available(iOS 16.0, *) {
|
||||
if colorScheme == .dark, !pureBlackDarkMode {
|
||||
content
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.onAppear {
|
||||
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppGroupedListRowBackground: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@PreferenceObserving(\.$pureBlackDarkMode) private var pureBlackDarkMode
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if colorScheme == .dark, !pureBlackDarkMode {
|
||||
content
|
||||
.listRowBackground(Color.appGroupedCellBackground)
|
||||
} else {
|
||||
|
@ -47,3 +66,31 @@ private struct AppGroupedListRowBackground: ViewModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
private struct PreferenceObserving<Key: TuskerPreferences.PreferenceKey>: DynamicProperty {
|
||||
typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
|
||||
|
||||
let keyPath: PrefKeyPath
|
||||
@StateObject private var observer: Observer
|
||||
|
||||
init(_ keyPath: PrefKeyPath) {
|
||||
self.keyPath = keyPath
|
||||
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
|
||||
}
|
||||
|
||||
var wrappedValue: Key.Value {
|
||||
Preferences.shared.getValue(preferenceKeyPath: keyPath)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private class Observer: ObservableObject {
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(keyPath: PrefKeyPath) {
|
||||
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -143,3 +143,33 @@ extension UIMutableTraits {
|
|||
set { self[PureBlackDarkModeTrait.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private struct PureBlackDarkModeKey: UITraitBridgedEnvironmentKey {
|
||||
static let defaultValue: Bool = false
|
||||
|
||||
static func read(from traitCollection: UITraitCollection) -> Bool {
|
||||
traitCollection[PureBlackDarkModeTrait.self]
|
||||
}
|
||||
|
||||
static func write(to mutableTraits: inout any UIMutableTraits, value: Bool) {
|
||||
mutableTraits[PureBlackDarkModeTrait.self] = value
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var pureBlackDarkMode: Bool {
|
||||
get {
|
||||
if #available(iOS 17.0, *) {
|
||||
self[PureBlackDarkModeKey.self]
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
set {
|
||||
if #available(iOS 17.0, *) {
|
||||
self[PureBlackDarkModeKey.self] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,6 @@ extension StatusSwipeAction {
|
|||
protocol StatusSwipeActionContainer: UIView {
|
||||
var mastodonController: MastodonController! { get }
|
||||
var navigationDelegate: any TuskerNavigationDelegate { get }
|
||||
var toastableViewController: ToastableViewController? { get }
|
||||
|
||||
var canReblog: Bool { get }
|
||||
|
||||
|
|
|
@ -32,8 +32,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
|
|||
}
|
||||
launchActivity = activity
|
||||
|
||||
let account: UserAccountInfo
|
||||
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
|
||||
|
||||
let account: UserAccountInfo
|
||||
if let activityAccount = UserActivityManager.getAccount(from: activity) {
|
||||
account = activityAccount
|
||||
} else if let mostRecent = UserAccountsManager.shared.getMostRecentAccount() {
|
||||
|
|
|
@ -29,6 +29,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
|||
return
|
||||
}
|
||||
|
||||
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
|
||||
|
||||
let account: UserAccountInfo
|
||||
let controller: MastodonController
|
||||
let draft: Draft?
|
||||
|
|
|
@ -83,7 +83,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
} else {
|
||||
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
|
||||
}
|
||||
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
|
||||
Task(priority: .userInitiated) {
|
||||
_ = await userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
|
||||
}
|
||||
}
|
||||
|
||||
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
||||
|
@ -191,9 +193,11 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
|
||||
if let activity = launchActivity {
|
||||
func doRestoreActivity(context: UserActivityHandlingContext) {
|
||||
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
|
||||
Task(priority: .userInitiated) {
|
||||
_ = await activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
}
|
||||
}
|
||||
if activity.isStateRestorationActivity {
|
||||
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
|
||||
} else if activity.activityType != UserActivityType.mainScene.rawValue {
|
||||
|
@ -225,7 +229,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
window!.windowScene!.title = account.instanceURL.host!
|
||||
}
|
||||
|
||||
let newRoot = createAppUI()
|
||||
window!.windowScene!.activationConditions.prefersToActivateForTargetContentIdentifierPredicate = NSPredicate(format: "self == %@", account.id)
|
||||
|
||||
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
|
||||
let direction: AccountSwitchingContainerViewController.AnimationDirection
|
||||
if animated,
|
||||
|
@ -235,9 +240,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
} else {
|
||||
direction = .none
|
||||
}
|
||||
container.setRoot(newRoot, for: account, animating: direction)
|
||||
container.setRoot(createAppUI, for: account, animating: direction)
|
||||
} else {
|
||||
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot, for: account)
|
||||
window!.rootViewController = AccountSwitchingContainerViewController(root: createAppUI(), for: account)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -248,6 +253,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
LogoutService(accountInfo: account).run()
|
||||
if UserAccountsManager.shared.onboardingComplete {
|
||||
activateAccount(UserAccountsManager.shared.accounts.first!, animated: false)
|
||||
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
|
||||
container.removeAccount(account)
|
||||
}
|
||||
} else {
|
||||
window!.rootViewController = createOnboardingUI()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,191 @@
|
|||
//
|
||||
// AddReactionView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
struct AddReactionView: View {
|
||||
let mastodonController: MastodonController
|
||||
let addReaction: (Reaction) async throws -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ScaledMetric private var emojiSize = 30
|
||||
@State private var allEmojis: [Emoji] = []
|
||||
@State private var emojisBySection: [String: [Emoji]] = [:]
|
||||
@State private var query = ""
|
||||
@State private var error: (any Error)?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView(.vertical) {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
|
||||
if query.count == 1 {
|
||||
Section {
|
||||
AddReactionButton {
|
||||
await doAddReaction(.emoji(query))
|
||||
} label: {
|
||||
Text(query)
|
||||
.font(.system(size: 25))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(emojisBySection.keys.sorted(), id: \.self) { section in
|
||||
Section {
|
||||
ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in
|
||||
AddReactionButton {
|
||||
await doAddReaction(.custom(emoji))
|
||||
} label: {
|
||||
CustomEmojiImageView(emoji: emoji)
|
||||
.frame(height: emojiSize)
|
||||
.accessibilityLabel(emoji.shortcode)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
if !section.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(section)
|
||||
.font(.caption)
|
||||
|
||||
Divider()
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.searchPresentationToolbarBehaviorIfAvailable()
|
||||
.onChange(of: query) { _ in
|
||||
updateFilteredEmojis()
|
||||
}
|
||||
.navigationTitle("Add Reaction")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(role: .cancel) {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.mediumPresentationDetentIfAvailable()
|
||||
.alertWithData("Error Adding Reaction", data: $error, actions: { _ in
|
||||
Button("OK") {}
|
||||
}, message: { error in
|
||||
Text(error.localizedDescription)
|
||||
})
|
||||
.task {
|
||||
allEmojis = await mastodonController.getCustomEmojis()
|
||||
updateFilteredEmojis()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFilteredEmojis() {
|
||||
let filteredEmojis = if !query.isEmpty {
|
||||
allEmojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in
|
||||
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
||||
}
|
||||
.filter(\.1.matched)
|
||||
.sorted { $0.1.score > $1.1.score }
|
||||
.map(\.0)
|
||||
} else {
|
||||
allEmojis
|
||||
}
|
||||
|
||||
var shortcodes = Set<String>()
|
||||
var newEmojis = [Emoji]()
|
||||
var newEmojisBySection = [String: [Emoji]]()
|
||||
for emoji in filteredEmojis where !shortcodes.contains(emoji.shortcode) {
|
||||
newEmojis.append(emoji)
|
||||
shortcodes.insert(emoji.shortcode)
|
||||
|
||||
let category = emoji.category ?? ""
|
||||
if newEmojisBySection.keys.contains(category) {
|
||||
newEmojisBySection[category]!.append(emoji)
|
||||
} else {
|
||||
newEmojisBySection[category] = [emoji]
|
||||
}
|
||||
}
|
||||
emojisBySection = newEmojisBySection
|
||||
}
|
||||
|
||||
private func doAddReaction(_ reaction: Reaction) async {
|
||||
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||
do {
|
||||
try await addReaction(reaction)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
enum Reaction {
|
||||
case emoji(String)
|
||||
case custom(Emoji)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddReactionButton<Label: View>: View {
|
||||
let addReaction: () async -> Void
|
||||
@ViewBuilder let label: Label
|
||||
@State private var isLoading = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
isLoading = true
|
||||
Task {
|
||||
await addReaction()
|
||||
isLoading = false
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
label
|
||||
.opacity(isLoading ? 0 : 1)
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
.hoverEffect()
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
@ViewBuilder
|
||||
func mediumPresentationDetentIfAvailable() -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
self.presentationDetents([.medium, .large])
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS, obsoleted: 17.1)
|
||||
@ViewBuilder
|
||||
func searchPresentationToolbarBehaviorIfAvailable() -> some View {
|
||||
if #available(iOS 17.1, *) {
|
||||
self.searchPresentationToolbarBehavior(.avoidHidingContent)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// AddReactionView()
|
||||
//}
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// AnnouncementContentTextView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/16/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURL
|
||||
|
||||
class AnnouncementContentTextView: ContentTextView {
|
||||
|
||||
var heightChanged: ((CGFloat) -> Void)?
|
||||
|
||||
private var announcement: Announcement?
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
heightChanged?(contentSize.height)
|
||||
}
|
||||
|
||||
func setTextFrom(announcement: Announcement, content: NSAttributedString) {
|
||||
self.announcement = announcement
|
||||
self.attributedText = content
|
||||
setEmojis(announcement.emojis, identifier: announcement.id)
|
||||
}
|
||||
|
||||
override func getMention(for url: URL, text: String) -> Mention? {
|
||||
announcement?.mentions.first {
|
||||
URL($0.url) == url
|
||||
}.map {
|
||||
Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id)
|
||||
}
|
||||
}
|
||||
|
||||
override func getHashtag(for url: URL, text: String) -> Hashtag? {
|
||||
announcement?.tags.first {
|
||||
URL($0.url) == url
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,246 @@
|
|||
//
|
||||
// AnnouncementListRow.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
import WebURLFoundationExtras
|
||||
|
||||
struct AnnouncementListRow: View {
|
||||
@Binding var announcement: Announcement
|
||||
let mastodonController: MastodonController
|
||||
let navigationDelegate: TuskerNavigationDelegate?
|
||||
let removeAnnouncement: @MainActor () -> Void
|
||||
@State private var contentTextViewHeight: CGFloat?
|
||||
@State private var isShowingAddReactionSheet = false
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
mostOfTheBody
|
||||
.alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in
|
||||
dimension[.leading]
|
||||
})
|
||||
} else {
|
||||
mostOfTheBody
|
||||
}
|
||||
}
|
||||
|
||||
private var mostOfTheBody: some View {
|
||||
VStack {
|
||||
HStack(alignment: .top) {
|
||||
AnnouncementContentTextViewRepresentable(announcement: announcement, navigationDelegate: navigationDelegate) { newHeight in
|
||||
DispatchQueue.main.async {
|
||||
contentTextViewHeight = newHeight
|
||||
}
|
||||
}
|
||||
.frame(height: contentTextViewHeight)
|
||||
|
||||
Text(announcement.publishedAt, format: .abbreviatedTimeAgo)
|
||||
.fontWeight(.light)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack {
|
||||
Button {
|
||||
isShowingAddReactionSheet = true
|
||||
} label: {
|
||||
Label {
|
||||
Text("Add Reaction")
|
||||
} icon: {
|
||||
if #available(iOS 16.0, *) {
|
||||
Image("face.smiling.badge.plus")
|
||||
} else {
|
||||
Image(systemName: "face.smiling")
|
||||
}
|
||||
}
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(4)
|
||||
.hoverEffect()
|
||||
|
||||
ForEach($announcement.reactions, id: \.name) { $reaction in
|
||||
ReactionButton(announcement: announcement, reaction: $reaction, mastodonController: mastodonController)
|
||||
}
|
||||
}
|
||||
.frame(height: 32)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await dismissAnnouncement()
|
||||
}
|
||||
} label: {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await dismissAnnouncement()
|
||||
await removeAnnouncement()
|
||||
}
|
||||
} label: {
|
||||
Label("Dismiss", systemImage: "xmark")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingAddReactionSheet) {
|
||||
AddReactionView(mastodonController: mastodonController, addReaction: self.addReaction)
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissAnnouncement() async {
|
||||
do {
|
||||
_ = try await mastodonController.run(Announcement.dismiss(id: announcement.id))
|
||||
} catch {
|
||||
Logging.general.error("Error dismissing attachment: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func addReaction(_ reaction: AddReactionView.Reaction) async throws {
|
||||
let name = switch reaction {
|
||||
case .emoji(let s): s
|
||||
case .custom(let emoji): emoji.shortcode
|
||||
}
|
||||
_ = try await mastodonController.run(Announcement.react(id: announcement.id, name: name))
|
||||
for (idx, reaction) in announcement.reactions.enumerated() {
|
||||
if reaction.name == name {
|
||||
announcement.reactions[idx].me = true
|
||||
announcement.reactions[idx].count += 1
|
||||
return
|
||||
}
|
||||
}
|
||||
let url: URL?
|
||||
let staticURL: URL?
|
||||
if case .custom(let emoji) = reaction {
|
||||
url = URL(emoji.url)
|
||||
staticURL = URL(emoji.staticURL)
|
||||
} else {
|
||||
url = nil
|
||||
staticURL = nil
|
||||
}
|
||||
announcement.reactions.append(.init(name: name, count: 1, me: true, url: url, staticURL: staticURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnnouncementContentTextViewRepresentable: UIViewRepresentable {
|
||||
let announcement: Announcement
|
||||
let navigationDelegate: TuskerNavigationDelegate?
|
||||
let heightChanged: (CGFloat) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> AnnouncementContentTextView {
|
||||
let view = AnnouncementContentTextView()
|
||||
view.isScrollEnabled = true
|
||||
view.backgroundColor = .clear
|
||||
view.isEditable = false
|
||||
view.isSelectable = false
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
view.adjustsFontForContentSizeCategory = true
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: AnnouncementContentTextView, context: Context) {
|
||||
uiView.navigationDelegate = navigationDelegate
|
||||
uiView.setTextFrom(announcement: announcement, content: TimelineStatusCollectionViewCell.htmlConverter.convert(announcement.content))
|
||||
uiView.heightChanged = heightChanged
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReactionButton: View {
|
||||
let announcement: Announcement
|
||||
@Binding var reaction: Announcement.Reaction
|
||||
let mastodonController: MastodonController
|
||||
@State private var customEmojiImage: (Image, CGFloat)?
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.toggleReaction) {
|
||||
let countStr = reaction.count.formatted(.number)
|
||||
let title = if reaction.name.count == 1 {
|
||||
"\(reaction.name) \(countStr)"
|
||||
} else {
|
||||
countStr
|
||||
}
|
||||
if reaction.url != nil {
|
||||
Label {
|
||||
Text(title)
|
||||
} icon: {
|
||||
if let (image, aspectRatio) = customEmojiImage {
|
||||
image.aspectRatio(aspectRatio, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
.buttonStyle(TintedButtonStyle(highlighted: reaction.me == true))
|
||||
.font(.body.monospacedDigit())
|
||||
.hoverEffect()
|
||||
.task {
|
||||
if let url = reaction.url,
|
||||
let image = await ImageCache.emojis.get(url).1 {
|
||||
let aspectRatio = image.size.width / image.size.height
|
||||
customEmojiImage = (
|
||||
Image(uiImage: image).resizable(),
|
||||
aspectRatio
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleReaction() {
|
||||
if reaction.me == true {
|
||||
let oldCount = reaction.count
|
||||
reaction.me = false
|
||||
reaction.count -= 1
|
||||
Task {
|
||||
do {
|
||||
_ = try await mastodonController.run(Announcement.unreact(id: announcement.id, name: reaction.name))
|
||||
} catch {
|
||||
reaction.me = true
|
||||
reaction.count = oldCount
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let oldCount = reaction.count
|
||||
reaction.me = true
|
||||
reaction.count += 1
|
||||
Task {
|
||||
do {
|
||||
_ = try await mastodonController.run(Announcement.react(id: announcement.id, name: reaction.name))
|
||||
} catch {
|
||||
reaction.me = false
|
||||
reaction.count = oldCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TintedButtonStyle: ButtonStyle {
|
||||
let highlighted: Bool
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.foregroundStyle(highlighted ? AnyShapeStyle(.white) : AnyShapeStyle(.tint))
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 32)
|
||||
.background(.tint.opacity(highlighted ? 1 : 0.2), in: RoundedRectangle(cornerRadius: 4))
|
||||
.opacity(configuration.isPressed ? 0.8 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// AnnouncementListRow()
|
||||
//}
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// AnnouncementsCollection.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
class AnnouncementsCollection: ObservableObject {
|
||||
@Published var announcements: [Announcement]
|
||||
|
||||
init(announcements: [Announcement]) {
|
||||
self.announcements = announcements
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// AnnouncementsHostingController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
|
||||
class AnnouncementsHostingController: UIHostingController<AnnouncementsView> {
|
||||
private let mastodonController: MastodonController
|
||||
|
||||
init(announcements: AnnouncementsCollection, mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
@Box var boxedSelf: TuskerNavigationDelegate?
|
||||
super.init(rootView: AnnouncementsView(announcements: announcements, mastodonController: mastodonController, navigationDelegate: _boxedSelf))
|
||||
boxedSelf = self
|
||||
|
||||
navigationItem.title = "Announcements"
|
||||
}
|
||||
|
||||
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
extension AnnouncementsHostingController: TuskerNavigationDelegate {
|
||||
nonisolated var apiController: MastodonController! { mastodonController }
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// AnnouncementsView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
|
||||
struct AnnouncementsView: View {
|
||||
@ObservedObject var state: AnnouncementsCollection
|
||||
let mastodonController: MastodonController
|
||||
@Box var navigationDelegate: TuskerNavigationDelegate?
|
||||
|
||||
init(announcements: AnnouncementsCollection, mastodonController: MastodonController, navigationDelegate: Box<TuskerNavigationDelegate?>) {
|
||||
self.state = announcements
|
||||
self.mastodonController = mastodonController
|
||||
self._navigationDelegate = navigationDelegate
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach($state.announcements) { $announcement in
|
||||
AnnouncementListRow(announcement: $announcement, mastodonController: mastodonController, navigationDelegate: navigationDelegate) {
|
||||
withAnimation {
|
||||
state.announcements.removeAll(where: { $0.id == announcement.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// AnnouncementsView()
|
||||
//}
|
|
@ -222,7 +222,7 @@ class ConversationViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
if isLikelyMastodonRemoteStatus(url: url),
|
||||
let (_, response) = try? await URLSession.shared.data(from: url, delegate: RedirectBlocker()),
|
||||
let (_, response) = try? await URLSession.appDefault.data(from: url, delegate: RedirectBlocker()),
|
||||
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
|
||||
effectiveURL = location
|
||||
} else {
|
||||
|
@ -232,7 +232,7 @@ class ConversationViewController: UIViewController {
|
|||
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
|
||||
do {
|
||||
let (results, _) = try await mastodonController.run(request)
|
||||
guard let status = results.statuses.first(where: { $0.url?.serialized() == effectiveURL }) else {
|
||||
guard let status = results.statuses.compactMap(\.value).first(where: { $0.url?.serialized() == effectiveURL }) else {
|
||||
throw UnableToResolveError()
|
||||
}
|
||||
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
|
||||
|
|
|
@ -59,7 +59,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
|
|||
|
||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||
resultsController.exploreNavigationController = self.navigationController!
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController)
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
|
||||
definesPresentationContext = true
|
||||
|
||||
navigationItem.searchController = searchController
|
||||
|
|
|
@ -34,7 +34,7 @@ class InlineTrendsViewController: UIViewController {
|
|||
|
||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||
resultsController.exploreNavigationController = self.navigationController
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController)
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
|
||||
searchController.obscuresBackgroundDuringPresentation = true
|
||||
searchController.hidesNavigationBarDuringPresentation = false
|
||||
definesPresentationContext = true
|
||||
|
|
|
@ -184,7 +184,19 @@ extension SuggestedProfilesViewController: UICollectionViewDelegate {
|
|||
return UIContextMenuConfiguration {
|
||||
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
||||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
|
||||
let dismiss = UIAction(title: "Remove Suggestion", image: UIImage(systemName: "trash"), attributes: .destructive) { [unowned self] _ in
|
||||
let service = RemoveProfileSuggestionService(accountID: id, mastodonController: self.mastodonController, presenter: self) { [weak self] in
|
||||
guard let self else { return }
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
|
||||
snapshot.deleteItems([.account(id, .global)])
|
||||
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
Task {
|
||||
await service.run()
|
||||
}
|
||||
}
|
||||
return UIMenu(children: [UIMenu(options: .displayInline, children: [dismiss])] + self.actionsForProfile(accountID: id, source: .view(cell)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -123,7 +123,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
|
|||
private func loadTrendingStatuses() async {
|
||||
let statuses: [Status]
|
||||
do {
|
||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0.compactMap(\.value)
|
||||
} catch {
|
||||
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
await MainActor.run {
|
||||
|
|
|
@ -277,7 +277,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
|||
let linksReq = Client.getTrendingLinks(limit: 10)
|
||||
async let links = try? mastodonController.run(linksReq).0
|
||||
let statusesReq = Client.getTrendingStatuses(limit: 10)
|
||||
async let statuses = try? mastodonController.run(statusesReq).0
|
||||
async let statuses = try? mastodonController.run(statusesReq).0.compactMap(\.value)
|
||||
|
||||
if let links = await links {
|
||||
if snapshot.sectionIdentifiers.contains(.profileSuggestions) {
|
||||
|
@ -332,7 +332,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
|||
|
||||
do {
|
||||
let request = Client.getTrendingStatuses(offset: origSnapshot.itemIdentifiers(inSection: .trendingStatuses).count)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
|
||||
|
@ -368,20 +368,14 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
|||
|
||||
@MainActor
|
||||
private func removeProfileSuggestion(accountID: String) async {
|
||||
let req = Suggestion.remove(accountID: accountID)
|
||||
do {
|
||||
_ = try await mastodonController.run(req)
|
||||
var snapshot = dataSource.snapshot()
|
||||
let service = RemoveProfileSuggestionService(accountID: accountID, mastodonController: mastodonController, presenter: self) { [weak self] in
|
||||
guard let self else { return }
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
|
||||
snapshot.deleteItems([.account(accountID, .global)])
|
||||
await apply(snapshot: snapshot)
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: self) { [unowned self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
_ = await self.removeProfileSuggestion(accountID: accountID)
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
await service.run()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ class ImageGalleryDataSource: GalleryDataSource {
|
|||
gifController: gifController
|
||||
)
|
||||
} else {
|
||||
return LoadingGalleryContentViewController {
|
||||
return LoadingGalleryContentViewController(caption: nil) {
|
||||
let (data, image) = await self.cache.get(self.url, loadOriginal: true)
|
||||
if let image {
|
||||
let gifController: GIFController? =
|
||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
|||
import GalleryVC
|
||||
|
||||
class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
private let fallbackCaption: String?
|
||||
private let provider: () async -> (any GalleryContentViewController)?
|
||||
private var wrapped: (any GalleryContentViewController)!
|
||||
|
||||
|
@ -24,14 +25,15 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
|
|||
}
|
||||
|
||||
var caption: String? {
|
||||
wrapped?.caption
|
||||
wrapped?.caption ?? fallbackCaption
|
||||
}
|
||||
|
||||
var canAnimateFromSourceView: Bool {
|
||||
wrapped?.canAnimateFromSourceView ?? true
|
||||
}
|
||||
|
||||
init(provider: @escaping () async -> (any GalleryContentViewController)?) {
|
||||
init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
|
||||
self.fallbackCaption = caption
|
||||
self.provider = provider
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
|
|
@ -57,7 +57,8 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
|
|||
gifController: gifController
|
||||
)
|
||||
} else {
|
||||
return LoadingGalleryContentViewController {
|
||||
return LoadingGalleryContentViewController(caption: attachment.description) {
|
||||
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||
let (data, image) = await ImageCache.attachments.get(attachment.url, loadOriginal: true)
|
||||
if let image {
|
||||
let gifController: GIFController? =
|
||||
|
@ -95,9 +96,9 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
|
|||
// TODO: use separate content VC with audio visualization?
|
||||
return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
|
||||
case .unknown:
|
||||
return LoadingGalleryContentViewController {
|
||||
return LoadingGalleryContentViewController(caption: nil) {
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: attachment.url)
|
||||
let (data, _) = try await URLSession.appDefault.data(from: attachment.url)
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(attachment.url.lastPathComponent)
|
||||
try data.write(to: url)
|
||||
return FallbackGalleryNavigationController(url: url)
|
||||
|
|
|
@ -120,6 +120,15 @@ class VideoControlsViewController: UIViewController {
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let timestampObserverToken {
|
||||
player.removeTimeObserver(timestampObserverToken)
|
||||
}
|
||||
if let scrubberObserverToken {
|
||||
player.removeTimeObserver(scrubberObserverToken)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
|
@ -256,10 +265,8 @@ private class VideoScrubbingControl: UIControl {
|
|||
|
||||
private func updateFillLayerMask() {
|
||||
// I don't know where this animation is coming from
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
fillMaskLayer.frame = CGRect(x: 0, y: 0, width: fractionComplete * bounds.width, height: 8)
|
||||
CATransaction.commit()
|
||||
fillMaskLayer.removeAllAnimations()
|
||||
}
|
||||
|
||||
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
||||
|
|
|
@ -17,7 +17,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
let mastodonController: MastodonController
|
||||
private let predicate: (StatusMO) -> Bool
|
||||
private let predicateTitle: String
|
||||
private let request: (RequestRange) -> Request<[Status]>
|
||||
private let request: (RequestRange) -> Request<[TryDecode<Status>]>
|
||||
|
||||
var collectionView: UICollectionView! {
|
||||
view as? UICollectionView
|
||||
|
@ -28,7 +28,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
private var newer: RequestRange?
|
||||
private var older: RequestRange?
|
||||
|
||||
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[Status]>, mastodonController: MastodonController) {
|
||||
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[TryDecode<Status>]>, mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
self.predicate = predicate
|
||||
self.predicateTitle = predicateTitle
|
||||
|
@ -140,7 +140,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
|
||||
do {
|
||||
let req = request(.count(Self.pageSize))
|
||||
let (statuses, pagination) = try await mastodonController.run(req)
|
||||
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||
let statuses = tryStatuses.compactMap(\.value)
|
||||
newer = pagination?.newer
|
||||
older = pagination?.older
|
||||
|
||||
|
@ -180,7 +181,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
|
||||
do {
|
||||
let req = request(older.withCount(Self.pageSize))
|
||||
let (statuses, pagination) = try await mastodonController.run(req)
|
||||
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||
let statuses = tryStatuses.compactMap(\.value)
|
||||
self.older = pagination?.older
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
|
@ -278,7 +280,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
Task {
|
||||
do {
|
||||
let req = request(newer.withCount(Self.pageSize))
|
||||
let (statuses, pagination) = try await mastodonController.run(req)
|
||||
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||
let statuses = tryStatuses.compactMap(\.value)
|
||||
self.newer = pagination?.newer
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
|
|
|
@ -23,7 +23,7 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
private(set) var currentAccountID: String
|
||||
private(set) var root: AccountSwitchableViewController
|
||||
|
||||
private var userActivities: [String: NSUserActivity] = [:]
|
||||
private var viewControllers: [String: (AccountSwitchableViewController?, NSUserActivity)] = [:]
|
||||
|
||||
init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
|
||||
self.currentAccountID = account.id
|
||||
|
@ -42,27 +42,49 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
embedChild(root)
|
||||
}
|
||||
|
||||
func setRoot(_ newRoot: AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
|
||||
override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
viewControllers = viewControllers.mapValues { (_, activity) in
|
||||
(nil, activity)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAccount(_ account: UserAccountInfo) {
|
||||
viewControllers.removeValue(forKey: account.id)
|
||||
}
|
||||
|
||||
func setRoot(_ newRootProvider: () -> AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
|
||||
let oldRoot = self.root
|
||||
if direction == .none {
|
||||
oldRoot.removeViewAndController()
|
||||
}
|
||||
if let activity = oldRoot.stateRestorationActivity() {
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: saving \(activity.activityType, privacy: .public) for \(self.currentAccountID, privacy: .public)")
|
||||
userActivities[currentAccountID] = activity
|
||||
viewControllers[currentAccountID] = (oldRoot, activity)
|
||||
}
|
||||
|
||||
let newRoot: AccountSwitchableViewController
|
||||
if let (existingRoot, activity) = viewControllers.removeValue(forKey: account.id) {
|
||||
if let existingRoot {
|
||||
newRoot = existingRoot
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: reusing existing VC for \(account.id, privacy: .public)")
|
||||
} else {
|
||||
newRoot = newRootProvider()
|
||||
Task(priority: .userInitiated) {
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
|
||||
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
|
||||
_ = await activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newRoot = newRootProvider()
|
||||
}
|
||||
|
||||
self.currentAccountID = account.id
|
||||
self.root = newRoot
|
||||
embedChild(newRoot)
|
||||
|
||||
if let activity = userActivities.removeValue(forKey: account.id) {
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
|
||||
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
|
||||
_ = activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
}
|
||||
|
||||
if direction != .none {
|
||||
if UIAccessibility.prefersCrossFadeTransitions {
|
||||
newRoot.view.alpha = 0
|
||||
|
@ -92,6 +114,7 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
#endif
|
||||
|
||||
// only one edge is affected in each direction, i have no idea why
|
||||
let origAdditionalSafeAreaInsets = oldRoot.additionalSafeAreaInsets
|
||||
if direction == .upwards {
|
||||
oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom
|
||||
} else {
|
||||
|
@ -102,6 +125,8 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale)
|
||||
newRoot.view.transform = .identity
|
||||
} completion: { (_) in
|
||||
oldRoot.view.transform = .identity
|
||||
oldRoot.additionalSafeAreaInsets = origAdditionalSafeAreaInsets
|
||||
oldRoot.removeViewAndController()
|
||||
newRoot.view.layer.masksToBounds = false
|
||||
}
|
||||
|
@ -127,9 +152,9 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
|||
root.compose(editing: draft, animated: animated, isDucked: isDucked)
|
||||
}
|
||||
|
||||
func select(route: TuskerRoute, animated: Bool) {
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||
loadViewIfNeeded()
|
||||
root.select(route: route, animated: animated)
|
||||
root.select(route: route, animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||
|
|
|
@ -35,8 +35,8 @@ extension DuckableContainerViewController: AccountSwitchableViewController {
|
|||
(child as! TuskerRootViewController).getNavigationController()
|
||||
}
|
||||
|
||||
func select(route: TuskerRoute, animated: Bool) {
|
||||
(child as? TuskerRootViewController)?.select(route: route, animated: animated)
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||
(child as? TuskerRootViewController)?.select(route: route, animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||
|
|
|
@ -13,8 +13,8 @@ import Combine
|
|||
@MainActor
|
||||
protocol MainSidebarViewControllerDelegate: AnyObject {
|
||||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item)
|
||||
}
|
||||
|
||||
|
@ -57,7 +57,7 @@ class MainSidebarViewController: UIViewController {
|
|||
return items
|
||||
}
|
||||
|
||||
private(set) var previouslySelectedItem: Item?
|
||||
private var previouslySelectedItem: Item?
|
||||
var selectedItem: Item? {
|
||||
guard let indexPath = collectionView?.indexPathsForSelectedItems?.first else {
|
||||
return nil
|
||||
|
@ -261,19 +261,21 @@ class MainSidebarViewController: UIViewController {
|
|||
}
|
||||
|
||||
private func returnToPreviousItem() {
|
||||
let item = previouslySelectedItem ?? .tab(.timelines)
|
||||
let oldItem = selectedItem
|
||||
let newItem = previouslySelectedItem ?? .tab(.timelines)
|
||||
previouslySelectedItem = nil
|
||||
select(item: item, animated: true)
|
||||
sidebarDelegate?.sidebar(self, didSelectItem: item)
|
||||
select(item: newItem, animated: true)
|
||||
sidebarDelegate?.sidebar(self, didSelectItem: newItem, previousItem: oldItem)
|
||||
}
|
||||
|
||||
private func showAddList() {
|
||||
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
|
||||
) }) { list in
|
||||
let oldItem = self.selectedItem
|
||||
self.select(item: .list(list), animated: false)
|
||||
let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
|
||||
list.presentEditOnAppear = true
|
||||
self.sidebarDelegate?.sidebar(self, showViewController: list)
|
||||
self.sidebarDelegate?.sidebar(self, showViewController: list, previousItem: oldItem)
|
||||
}
|
||||
service.run()
|
||||
}
|
||||
|
@ -471,7 +473,7 @@ extension MainSidebarViewController: UICollectionViewDelegate {
|
|||
fatalError("unreachable")
|
||||
}
|
||||
} else {
|
||||
sidebarDelegate?.sidebar(self, didSelectItem: item)
|
||||
sidebarDelegate?.sidebar(self, didSelectItem: item, previousItem: previouslySelectedItem)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -540,8 +542,9 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
|
|||
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
|
||||
func didSaveInstance(url: URL) {
|
||||
dismiss(animated: true) {
|
||||
let oldItem = self.selectedItem
|
||||
self.select(item: .savedInstance(url), animated: true)
|
||||
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url))
|
||||
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url), previousItem: oldItem)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -86,7 +86,7 @@ class MainSplitViewController: UISplitViewController {
|
|||
// don't unnecesarily construct a content VC unless the we're in actually split mode
|
||||
// when we change from compact -> split for the first time, the VC will be transferred anyways
|
||||
if traitCollection.horizontalSizeClass != .compact {
|
||||
select(item: .tab(.timelines))
|
||||
doSelect(item: .tab(.timelines))
|
||||
}
|
||||
|
||||
if UIDevice.current.userInterfaceIdiom != .mac {
|
||||
|
@ -149,7 +149,15 @@ class MainSplitViewController: UISplitViewController {
|
|||
self.setViewController(newNav, for: .secondary)
|
||||
}
|
||||
|
||||
func select(item: MainSidebarViewController.Item) {
|
||||
private func select(newItem: MainSidebarViewController.Item, oldItem: MainSidebarViewController.Item?) {
|
||||
if let oldItem,
|
||||
newItem != oldItem {
|
||||
navigationStacks[oldItem] = secondaryNavController.viewControllers
|
||||
}
|
||||
doSelect(item: newItem)
|
||||
}
|
||||
|
||||
private func doSelect(item: MainSidebarViewController.Item) {
|
||||
secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
|
||||
}
|
||||
|
||||
|
@ -180,28 +188,28 @@ class MainSplitViewController: UISplitViewController {
|
|||
}
|
||||
|
||||
@objc func handleSidebarCommandTimelines() {
|
||||
select(newItem: .tab(.timelines), oldItem: sidebar.selectedItem)
|
||||
sidebar.select(item: .tab(.timelines), animated: false)
|
||||
select(item: .tab(.timelines))
|
||||
}
|
||||
|
||||
@objc func handleSidebarCommandNotifications() {
|
||||
select(newItem: .tab(.notifications), oldItem: sidebar.selectedItem)
|
||||
sidebar.select(item: .tab(.notifications), animated: false)
|
||||
select(item: .tab(.notifications))
|
||||
}
|
||||
|
||||
@objc func handleSidebarCommandExplore() {
|
||||
select(newItem: .tab(.explore), oldItem: sidebar.selectedItem)
|
||||
sidebar.select(item: .tab(.explore), animated: false)
|
||||
select(item: .tab(.explore))
|
||||
}
|
||||
|
||||
@objc func handleSidebarCommandBookmarks() {
|
||||
select(newItem: .bookmarks, oldItem: sidebar.selectedItem)
|
||||
sidebar.select(item: .bookmarks, animated: false)
|
||||
select(item: .bookmarks)
|
||||
}
|
||||
|
||||
@objc func handleSidebarCommandMyProfile() {
|
||||
select(newItem: .tab(.myProfile), oldItem: sidebar.selectedItem)
|
||||
sidebar.select(item: .tab(.myProfile), animated: false)
|
||||
select(item: .tab(.myProfile))
|
||||
}
|
||||
|
||||
@objc private func sidebarTapped() {
|
||||
|
@ -444,12 +452,12 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
|||
// These tabs map 1 <-> 1 with sidebar items
|
||||
let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab)
|
||||
sidebar.select(item: item, animated: false)
|
||||
select(item: item)
|
||||
doSelect(item: item)
|
||||
|
||||
case .explore:
|
||||
// If the explore tab is active, the sidebar item is determined above when transferring the explore VC's nav stack
|
||||
sidebar.select(item: exploreItem!, animated: false)
|
||||
select(item: exploreItem!)
|
||||
doSelect(item: exploreItem!)
|
||||
|
||||
default:
|
||||
return
|
||||
|
@ -474,16 +482,13 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
|
|||
compose(editing: nil)
|
||||
}
|
||||
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
|
||||
if let previous = sidebar.previouslySelectedItem {
|
||||
navigationStacks[previous] = secondaryNavController.viewControllers
|
||||
}
|
||||
select(item: item)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?) {
|
||||
select(newItem: item, oldItem: previousItem)
|
||||
}
|
||||
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) {
|
||||
if let previous = sidebar.previouslySelectedItem {
|
||||
navigationStacks[previous] = secondaryNavController.viewControllers
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?) {
|
||||
if let previousItem {
|
||||
navigationStacks[previousItem] = secondaryNavController.viewControllers
|
||||
}
|
||||
secondaryNavController.viewControllers = [viewController]
|
||||
}
|
||||
|
@ -537,14 +542,14 @@ extension MainSplitViewController: StateRestorableViewController {
|
|||
}
|
||||
|
||||
extension MainSplitViewController: TuskerRootViewController {
|
||||
func select(route: TuskerRoute, animated: Bool) {
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||
guard traitCollection.horizontalSizeClass != .compact else {
|
||||
tabBarViewController?.select(route: route, animated: animated)
|
||||
tabBarViewController?.select(route: route, animated: animated, completion: completion)
|
||||
return
|
||||
}
|
||||
guard presentedViewController == nil else {
|
||||
dismiss(animated: animated) {
|
||||
self.select(route: route, animated: animated)
|
||||
self.select(route: route, animated: animated, completion: completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -567,8 +572,10 @@ extension MainSplitViewController: TuskerRootViewController {
|
|||
return
|
||||
}
|
||||
}
|
||||
let oldItem = sidebar.selectedItem
|
||||
sidebar.select(item: item, animated: false)
|
||||
select(item: item)
|
||||
select(newItem: item, oldItem: oldItem)
|
||||
completion?()
|
||||
}
|
||||
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||
|
@ -610,7 +617,7 @@ extension MainSplitViewController: TuskerRootViewController {
|
|||
}
|
||||
|
||||
if sidebar.selectedItem != .explore {
|
||||
select(item: .explore)
|
||||
select(newItem: .explore, oldItem: sidebar.selectedItem)
|
||||
}
|
||||
|
||||
guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else {
|
||||
|
|
|
@ -289,7 +289,7 @@ extension MainTabBarViewController: StateRestorableViewController {
|
|||
}
|
||||
|
||||
extension MainTabBarViewController: TuskerRootViewController {
|
||||
func select(route: TuskerRoute, animated: Bool) {
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||
switch route {
|
||||
case .timelines:
|
||||
select(tab: .timelines, dismissPresented: true)
|
||||
|
@ -310,6 +310,7 @@ extension MainTabBarViewController: TuskerRootViewController {
|
|||
nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
|
||||
}
|
||||
}
|
||||
completion?()
|
||||
}
|
||||
|
||||
func getNavigationDelegate() -> TuskerNavigationDelegate? {
|
||||
|
|
|
@ -12,7 +12,7 @@ import ComposeUI
|
|||
@MainActor
|
||||
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
|
||||
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool)
|
||||
func select(route: TuskerRoute, animated: Bool)
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?)
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
|
||||
func getNavigationDelegate() -> TuskerNavigationDelegate?
|
||||
func getNavigationController() -> NavigationControllerProtocol
|
||||
|
@ -21,33 +21,6 @@ protocol TuskerRootViewController: UIViewController, StateRestorableViewControll
|
|||
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController?
|
||||
}
|
||||
|
||||
//extension TuskerRootViewController {
|
||||
// func select(route: NewRoute, animated: Bool) {
|
||||
// doApply(components: route.components, animated: animated)
|
||||
// }
|
||||
//
|
||||
// private func doApply(components: ArraySlice<RouteComponent>, animated: Bool) {
|
||||
// guard let first = components.first else {
|
||||
// return
|
||||
// }
|
||||
// doApply(component: first, animated: animated) {
|
||||
// self.doApply(components: components.dropFirst(), animated: animated)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func doApply(component: RouteComponent, animated: Bool, completion: @escaping () -> Void) {
|
||||
// switch component {
|
||||
// case .topLevelItem(let rootRoute):
|
||||
// select(route: rootRoute)
|
||||
// completion()
|
||||
// case .popToRoot:
|
||||
// _ = getNavigationController().popToRootViewController(animated: animated)
|
||||
// completion()
|
||||
// case .push(<#T##(MastodonController) -> UIViewController#>)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
enum TuskerRoute {
|
||||
case timelines
|
||||
case notifications
|
||||
|
@ -57,33 +30,6 @@ enum TuskerRoute {
|
|||
case list(id: String)
|
||||
}
|
||||
|
||||
//struct NewRoute: ExpressibleByArrayLiteral {
|
||||
// let components: [RouteComponent]
|
||||
//
|
||||
// init(arrayLiteral elements: RouteComponent...) {
|
||||
// self.components = elements
|
||||
// }
|
||||
//
|
||||
// static var timelines: Self { [.topLevelItem(.timelines)] }
|
||||
// static var explore: Self { [.topLevelItem(.explore)] }
|
||||
// static var myProfile: Self { [.topLevelItem(.myProfile)] }
|
||||
// static var bookmarks: Self { [.topLevelItem(.explore), .push({ BookmarksViewController(mastodonController: $0) })] }
|
||||
// static func profile(accountID: String) -> Self { [.topLevelItem(.timelines), .push({ ProfileViewController(accountID: accountID, mastodonController: $0) })] }
|
||||
//}
|
||||
//
|
||||
//enum RouteComponent {
|
||||
// case topLevelItem(RootRoute)
|
||||
// case popToRoot
|
||||
// case push((MastodonController) -> UIViewController)
|
||||
// case present(UIViewController)
|
||||
//}
|
||||
//
|
||||
//enum RootRoute {
|
||||
// case timelines
|
||||
// case explore
|
||||
// case myProfile
|
||||
//}
|
||||
//
|
||||
@MainActor
|
||||
protocol NavigationControllerProtocol: UIViewController {
|
||||
var viewControllers: [UIViewController] { get set }
|
||||
|
|
|
@ -78,6 +78,7 @@ struct MuteAccountView: View {
|
|||
}
|
||||
.accessibilityHidden(true)
|
||||
|
||||
if mastodonController.instanceFeatures.muteNotifications {
|
||||
Section {
|
||||
Toggle(isOn: $muteNotifications) {
|
||||
Text("Hide notifications from this person")
|
||||
|
@ -90,6 +91,7 @@ struct MuteAccountView: View {
|
|||
}
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker(selection: $duration) {
|
||||
|
|
|
@ -12,7 +12,16 @@ import HTMLStreamer
|
|||
|
||||
class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
||||
|
||||
private let iconView = UIImageView().configure {
|
||||
private static func canDisplay(_ kind: NotificationGroup.Kind) -> Bool {
|
||||
switch kind {
|
||||
case .favourite, .reblog, .emojiReaction:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private let iconImageView = UIImageView().configure {
|
||||
$0.tintColor = UIColor(red: 1, green: 204/255, blue: 0, alpha: 1)
|
||||
$0.contentMode = .scaleAspectFit
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -21,6 +30,10 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
])
|
||||
}
|
||||
|
||||
private let iconLabel = UILabel().configure {
|
||||
$0.font = .systemFont(ofSize: 30)
|
||||
}
|
||||
|
||||
private let avatarStack = UIStackView().configure {
|
||||
$0.axis = .horizontal
|
||||
$0.alignment = .fill
|
||||
|
@ -81,6 +94,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
private var group: NotificationGroup!
|
||||
private var statusID: String!
|
||||
|
||||
private var fetchCustomEmojiImage: (URL, Task<Void, Never>)?
|
||||
private var updateTimestampWorkItem: DispatchWorkItem?
|
||||
|
||||
deinit {
|
||||
|
@ -90,15 +104,21 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(iconView)
|
||||
iconImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(iconImageView)
|
||||
iconLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(iconLabel)
|
||||
vStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(vStack)
|
||||
NSLayoutConstraint.activate([
|
||||
iconView.topAnchor.constraint(equalTo: vStack.topAnchor),
|
||||
iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
|
||||
iconImageView.topAnchor.constraint(equalTo: vStack.topAnchor),
|
||||
iconImageView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
|
||||
iconLabel.topAnchor.constraint(equalTo: iconImageView.topAnchor),
|
||||
iconLabel.bottomAnchor.constraint(equalTo: iconImageView.bottomAnchor),
|
||||
iconLabel.leadingAnchor.constraint(equalTo: iconImageView.leadingAnchor),
|
||||
iconLabel.trailingAnchor.constraint(equalTo: iconImageView.trailingAnchor),
|
||||
|
||||
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
|
||||
vStack.leadingAnchor.constraint(equalTo: iconImageView.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),
|
||||
|
@ -116,7 +136,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
}
|
||||
|
||||
func updateUI(group: NotificationGroup) {
|
||||
guard group.kind == .favourite || group.kind == .reblog,
|
||||
guard ActionNotificationGroupCollectionViewCell.canDisplay(group.kind),
|
||||
let firstNotification = group.notifications.first,
|
||||
let status = firstNotification.status else {
|
||||
fatalError()
|
||||
|
@ -126,9 +146,29 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
|
||||
switch group.kind {
|
||||
case .favourite:
|
||||
iconView.image = UIImage(systemName: "star.fill")
|
||||
iconImageView.image = UIImage(systemName: "star.fill")
|
||||
iconLabel.text = ""
|
||||
fetchCustomEmojiImage?.1.cancel()
|
||||
case .reblog:
|
||||
iconView.image = UIImage(systemName: "repeat")
|
||||
iconImageView.image = UIImage(systemName: "repeat")
|
||||
iconLabel.text = ""
|
||||
fetchCustomEmojiImage?.1.cancel()
|
||||
case .emojiReaction(let emojiOrShortcode, let url):
|
||||
iconImageView.image = nil
|
||||
if let url = url.flatMap({ URL($0) }),
|
||||
fetchCustomEmojiImage?.0 != url {
|
||||
fetchCustomEmojiImage?.1.cancel()
|
||||
let task = Task {
|
||||
let (_, image) = await ImageCache.emojis.get(url)
|
||||
if !Task.isCancelled {
|
||||
self.iconImageView.image = image
|
||||
}
|
||||
}
|
||||
fetchCustomEmojiImage = (url, task)
|
||||
} else {
|
||||
iconLabel.text = emojiOrShortcode
|
||||
fetchCustomEmojiImage?.1.cancel()
|
||||
}
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
|
@ -207,6 +247,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
verb = "Favorited"
|
||||
case .reblog:
|
||||
verb = "Reblogged"
|
||||
case .emojiReaction(_, _):
|
||||
verb = "Reacted to"
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
|
@ -252,6 +294,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
str += "Favorited by "
|
||||
case .reblog:
|
||||
str += "Reblogged by "
|
||||
case .emojiReaction(let emoji, _):
|
||||
str += "Reacted \(emoji) by "
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
//
|
||||
// FollowRequestNotificationViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 5/19/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class FollowRequestNotificationViewController: UIViewController, CollectionViewController {
|
||||
|
||||
private let mastodonController: MastodonController
|
||||
private let notification: Pachyderm.Notification
|
||||
|
||||
var collectionView: UICollectionView! {
|
||||
view as? UICollectionView
|
||||
}
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
init(notification: Pachyderm.Notification, mastodonController: MastodonController) {
|
||||
precondition(notification.kind == .followRequest)
|
||||
self.mastodonController = mastodonController
|
||||
self.notification = notification
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
title = "Follow Request"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
|
||||
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
config.backgroundColor = .appGroupedBackground
|
||||
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
|
||||
section.readableContentInset(in: environment)
|
||||
return section
|
||||
}
|
||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView.delegate = self
|
||||
collectionView.dragDelegate = self
|
||||
collectionView.allowsFocus = true
|
||||
dataSource = createDataSource()
|
||||
}
|
||||
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||
let followRequestCell = UICollectionView.CellRegistration<FollowRequestNotificationCollectionViewCell, Void> { [unowned self] cell, indexPath, itemIdentifier in
|
||||
cell.delegate = self
|
||||
cell.updateUI(notification: self.notification)
|
||||
}
|
||||
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||
return collectionView.dequeueConfiguredReusableCell(using: followRequestCell, for: indexPath, item: ())
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.notifications])
|
||||
snapshot.appendItems([.notification])
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
clearSelectionOnAppear(animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
extension FollowRequestNotificationViewController {
|
||||
enum Section {
|
||||
case notifications
|
||||
}
|
||||
enum Item {
|
||||
case notification
|
||||
}
|
||||
}
|
||||
|
||||
extension FollowRequestNotificationViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
selected(account: notification.account.id)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let cell = collectionView.cellForItem(at: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
let accountID = notification.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))),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: any UIContextMenuInteractionCommitAnimating) {
|
||||
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension FollowRequestNotificationViewController: UICollectionViewDragDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: any UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
|
||||
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)]
|
||||
}
|
||||
}
|
||||
|
||||
extension FollowRequestNotificationViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
||||
extension FollowRequestNotificationViewController: MenuActionProvider {
|
||||
}
|
||||
|
||||
extension FollowRequestNotificationViewController: StatusBarTappableViewController {
|
||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||
collectionView.scrollToTop()
|
||||
return .stop
|
||||
}
|
||||
}
|
|
@ -75,11 +75,21 @@ class NotificationLoadingViewController: UIViewController {
|
|||
}
|
||||
let actionType = notification.kind == .reblog ? StatusActionAccountListViewController.ActionType.reblog : .favorite
|
||||
vc = StatusActionAccountListViewController(actionType: actionType, statusID: statusID, statusState: .unknown, accountIDs: [notification.account.id], mastodonController: mastodonController)
|
||||
case .emojiReaction:
|
||||
guard let statusID = notification.status?.id else {
|
||||
showLoadingError(Error.missingStatus)
|
||||
return
|
||||
}
|
||||
guard let emoji = notification.emoji else {
|
||||
showLoadingError(Error.unknownType)
|
||||
return
|
||||
}
|
||||
let actionType = StatusActionAccountListViewController.ActionType.emojiReaction(emoji, notification.emojiURL)
|
||||
vc = StatusActionAccountListViewController(actionType: actionType, statusID: statusID, statusState: .unknown, accountIDs: [notification.account.id], mastodonController: mastodonController)
|
||||
case .follow:
|
||||
vc = ProfileViewController(accountID: notification.account.id, mastodonController: mastodonController)
|
||||
case .followRequest:
|
||||
// todo
|
||||
return
|
||||
vc = FollowRequestNotificationViewController(notification: notification, mastodonController: mastodonController)
|
||||
case .unknown:
|
||||
showLoadingError(Error.unknownType)
|
||||
return
|
||||
|
|
|
@ -178,7 +178,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
|||
case .hide:
|
||||
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
|
||||
}
|
||||
case .favourite, .reblog:
|
||||
case .favourite, .reblog, .emojiReaction:
|
||||
return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group)
|
||||
case .follow:
|
||||
return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group)
|
||||
|
@ -317,7 +317,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
|||
snapshot.deleteItems([.group(group, collapseState, filterState)])
|
||||
} else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count {
|
||||
let dismissFailed = dismissFailedIndices.sorted().map { notifications[$0] }
|
||||
snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed)!, collapseState, filterState)], afterItem: .group(group, collapseState, filterState))
|
||||
snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed, kind: group.kind)!, collapseState, filterState)], afterItem: .group(group, collapseState, filterState))
|
||||
snapshot.deleteItems([.group(group, collapseState, filterState)])
|
||||
}
|
||||
await apply(snapshot, animatingDifferences: true)
|
||||
|
@ -624,8 +624,8 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
|
|||
let state = collapseState?.copy() ?? .unknown
|
||||
selected(status: statusID, state: state)
|
||||
}
|
||||
case .favourite, .reblog:
|
||||
let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog
|
||||
case .favourite, .reblog, .emojiReaction(_, _):
|
||||
let type = StatusActionAccountListViewController.ActionType(group.kind)!
|
||||
let statusID = group.notifications.first!.status!.id
|
||||
let accountIDs = group.notifications.map(\.account.id).uniques()
|
||||
let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController)
|
||||
|
@ -666,9 +666,9 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
|
|||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForStatus(status, source: .view(cell), includeStatusButtonActions: group.kind == .poll || group.kind == .update))
|
||||
}
|
||||
case .favourite, .reblog:
|
||||
case .favourite, .reblog, .emojiReaction(_, _):
|
||||
return UIContextMenuConfiguration(previewProvider: {
|
||||
let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog
|
||||
let type = StatusActionAccountListViewController.ActionType(group.kind)!
|
||||
let statusID = group.notifications.first!.status!.id
|
||||
let accountIDs = group.notifications.map(\.account.id).uniques()
|
||||
return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController)
|
||||
|
@ -751,7 +751,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate {
|
|||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
case .favourite, .reblog:
|
||||
case .favourite, .reblog, .emojiReaction(_, _):
|
||||
return []
|
||||
case .follow, .followRequest:
|
||||
guard group.notifications.count == 1 else {
|
||||
|
|
|
@ -16,6 +16,22 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
|
|||
|
||||
var initialMode: NotificationsMode?
|
||||
|
||||
private lazy var announcementsButton: UIButton = {
|
||||
#if os(visionOS)
|
||||
var config = UIButton.Configuration.borderedProminent()
|
||||
#else
|
||||
var config = UIButton.Configuration.plain()
|
||||
// We don't want a background for this button, even when accessibility button shapes are enabled, because it's in the navbar.
|
||||
config.background.backgroundColor = .clear
|
||||
#endif
|
||||
config.image = UIImage(systemName: "megaphone.fill")
|
||||
config.contentInsets = .zero
|
||||
let button = UIButton(configuration: config)
|
||||
button.addTarget(self, action: #selector(announcementsButtonPresesd), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
private var unreadAnnouncements: AnnouncementsCollection?
|
||||
|
||||
init(initialMode: NotificationsMode? = nil, mastodonController: MastodonController) {
|
||||
self.initialMode = initialMode
|
||||
self.mastodonController = mastodonController
|
||||
|
@ -30,6 +46,8 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
|
|||
|
||||
title = Page.all.title
|
||||
tabBarItem.image = UIImage(systemName: "bell.fill")
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: announcementsButton)
|
||||
announcementsButton.isHidden = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
@ -42,6 +60,14 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
|
|||
selectMode(initialMode ?? Preferences.shared.defaultNotificationsMode)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
Task {
|
||||
await checkForAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
func selectMode(_ mode: NotificationsMode) {
|
||||
let page: Page
|
||||
switch mode {
|
||||
|
@ -53,6 +79,61 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
|
|||
selectPage(page, animated: false)
|
||||
}
|
||||
|
||||
private func checkForAnnouncements() async {
|
||||
guard mastodonController.instanceFeatures.instanceAnnouncements else {
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
return
|
||||
}
|
||||
let announcements: [Announcement]
|
||||
do {
|
||||
(announcements, _) = try await mastodonController.run(Announcement.all())
|
||||
} catch {
|
||||
Logging.general.error("Error fetching announcements: \(String(describing: error))")
|
||||
return
|
||||
}
|
||||
let unread = announcements.filter { $0.read == false }
|
||||
if unread.isEmpty {
|
||||
unreadAnnouncements = nil
|
||||
if #available(iOS 17.0, *) {
|
||||
announcementsButton.imageView!.addSymbolEffect(.disappear)
|
||||
} else {
|
||||
UIView.animate(withDuration: 0.2, delay: 0.1, options: .curveEaseInOut) {
|
||||
self.announcementsButton.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
|
||||
self.announcementsButton.layer.opacity = 0
|
||||
} completion: { _ in
|
||||
self.announcementsButton.transform = .identity
|
||||
self.announcementsButton.layer.opacity = 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
announcementsButton.isHidden = false
|
||||
announcementsButton.layer.opacity = 1
|
||||
unreadAnnouncements = AnnouncementsCollection(announcements: unread)
|
||||
if #available(iOS 17.0, *) {
|
||||
// make sure to remove the .disappear effect, which stays around indefinitely
|
||||
announcementsButton.imageView!.removeAllSymbolEffects()
|
||||
announcementsButton.imageView!.addSymbolEffect(.bounce)
|
||||
} else {
|
||||
UIView.animate(withDuration: 0.2, delay: 0.1, options: .curveEaseInOut) {
|
||||
self.announcementsButton.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
||||
} completion: { _ in
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
|
||||
self.announcementsButton.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func announcementsButtonPresesd() {
|
||||
guard let unreadAnnouncements else {
|
||||
return
|
||||
}
|
||||
show(AnnouncementsHostingController(announcements: unreadAnnouncements, mastodonController: mastodonController), sender: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationsPageViewController {
|
||||
enum Page: SegmentedPageViewControllerPage {
|
||||
case all
|
||||
case mentions
|
||||
|
|
|
@ -116,10 +116,13 @@ class OnboardingViewController: UINavigationController {
|
|||
}
|
||||
|
||||
private func tryLogin(to instanceURL: URL, updateStatus: (String) -> Void) async throws {
|
||||
logger.debug("Attempting to log in to \(instanceURL, privacy: .public)")
|
||||
|
||||
let mastodonController = MastodonController(instanceURL: instanceURL, transient: true)
|
||||
let clientID: String
|
||||
let clientSecret: String
|
||||
if let clientInfo, clientInfo.url == instanceURL {
|
||||
logger.debug("Using client info from previous attempt")
|
||||
clientID = clientInfo.id
|
||||
clientSecret = clientInfo.secret
|
||||
} else {
|
||||
|
@ -127,21 +130,32 @@ class OnboardingViewController: UINavigationController {
|
|||
do {
|
||||
(clientID, clientSecret) = try await mastodonController.registerApp()
|
||||
self.clientInfo = (instanceURL, clientID, clientSecret)
|
||||
logger.debug("Obtained client info")
|
||||
updateStatus("Reticulating Splines")
|
||||
try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC)
|
||||
} catch {
|
||||
logger.error("Failed to register app: \(String(describing: error), privacy: .public)")
|
||||
throw Error.registeringApp(error)
|
||||
}
|
||||
}
|
||||
updateStatus("Logging in")
|
||||
let authCode = try await getAuthorizationCode(instanceURL: instanceURL, clientID: clientID)
|
||||
let authCode: String
|
||||
do {
|
||||
authCode = try await getAuthorizationCode(instanceURL: instanceURL, clientID: clientID)
|
||||
logger.debug("Obtained authorization code")
|
||||
} catch {
|
||||
logger.error("Failed to get auth code: \(String(describing: error), privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
updateStatus("Authorizing")
|
||||
let accessToken: String
|
||||
do {
|
||||
accessToken = try await retrying("Getting access token") {
|
||||
try await mastodonController.authorize(authorizationCode: authCode)
|
||||
}
|
||||
logger.debug("Obtained access token")
|
||||
} catch {
|
||||
logger.error("Failed to get access token: \(String(describing: error), privacy: .public)")
|
||||
throw Error.gettingAccessToken(error)
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,15 @@ struct NotificationsPrefsView: View {
|
|||
|
||||
var body: some View {
|
||||
List {
|
||||
NavigationLink {
|
||||
TipJarView()
|
||||
} label: {
|
||||
Text("Push notifications are available for free to all users, but providing them has an ongoing cost. If you like the app, please consider \(Text("supporting Tusker").foregroundColor(.accentColor)).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.listRowBackground(Color.accentColor.opacity(0.1))
|
||||
|
||||
Section {
|
||||
ForEach(userAccounts.accounts) { account in
|
||||
PushInstanceSettingsView(account: account)
|
||||
|
|
|
@ -90,7 +90,7 @@ struct PushInstanceSettingsView: View {
|
|||
let mastodonController = await MastodonController.getForAccount(account)
|
||||
do {
|
||||
let result = try await mastodonController.createPushSubscription(subscription: subscription)
|
||||
PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)")
|
||||
PushManager.logger.debug("Push subscription \(result.id, privacy: .public) created on \(account.instanceURL) with endpoint \(result.endpoint, privacy: .public)")
|
||||
self.subscription = subscription
|
||||
return true
|
||||
} catch {
|
||||
|
@ -112,7 +112,7 @@ struct PushInstanceSettingsView: View {
|
|||
let mastodonController = await MastodonController.getForAccount(account)
|
||||
do {
|
||||
let result = try await mastodonController.updatePushSubscription(alerts: alerts, policy: policy)
|
||||
PushManager.logger.debug("Push subscription \(result.id) updated on \(account.instanceURL)")
|
||||
PushManager.logger.debug("Push subscription \(result.id, privacy: .public) updated on \(account.instanceURL)")
|
||||
await PushManager.shared.updateSubscription(account: account, alerts: alerts, policy: policy)
|
||||
subscription?.alerts = alerts
|
||||
subscription?.policy = policy
|
||||
|
|
|
@ -74,20 +74,27 @@ private struct PushSubscriptionSettingsView: View {
|
|||
if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
|
||||
toggle("Edits", alert: .update)
|
||||
}
|
||||
if mastodonController.instanceFeatures.emojiReactionNotifications {
|
||||
toggle("Reactions", alert: .emojiReaction)
|
||||
}
|
||||
// status notifications not supported until we can enable/disable them in the app
|
||||
}
|
||||
}
|
||||
.groupBoxStyle(AppBackgroundGroupBoxStyle())
|
||||
}
|
||||
|
||||
private var allSupportedAlertTypes: PushSubscription.Alerts {
|
||||
var alerts: PushSubscription.Alerts = [.mention, .favorite, .reblog, .follow, .poll]
|
||||
var all: PushSubscription.Alerts = [.mention, .favorite, .reblog, .follow, .poll]
|
||||
if mastodonController.instanceFeatures.pushNotificationTypeFollowRequest {
|
||||
alerts.insert(.followRequest)
|
||||
all.insert(.followRequest)
|
||||
}
|
||||
if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
|
||||
alerts.insert(.update)
|
||||
all.insert(.update)
|
||||
}
|
||||
return alerts
|
||||
if mastodonController.instanceFeatures.emojiReactionNotifications {
|
||||
all.insert(.emojiReaction)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
private func toggle(_ titleKey: LocalizedStringKey, alert: PushSubscription.Alerts) -> some View {
|
||||
|
@ -125,6 +132,19 @@ private extension PushSubscription.Policy {
|
|||
}
|
||||
}
|
||||
|
||||
private struct AppBackgroundGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
configuration.label
|
||||
.font(.headline)
|
||||
|
||||
configuration.content
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appGroupedBackground, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// PushSubscriptionView()
|
||||
//}
|
||||
|
|
|
@ -108,7 +108,12 @@ struct PreferencesView: View {
|
|||
PreferenceSectionLabel(title: "Composing", systemImageName: "pencil", backgroundColor: .blue)
|
||||
}
|
||||
NavigationLink(destination: WellnessPrefsView()) {
|
||||
PreferenceSectionLabel(title: "Digital Wellness", systemImageName: "brain.fill", backgroundColor: .purple)
|
||||
let brainImageName = if #available(iOS 17.0, *) {
|
||||
"brain.fill"
|
||||
} else {
|
||||
"brain"
|
||||
}
|
||||
PreferenceSectionLabel(title: "Digital Wellness", systemImageName: brainImageName, backgroundColor: .purple)
|
||||
}
|
||||
NavigationLink(destination: AdvancedPrefsView()) {
|
||||
PreferenceSectionLabel(title: "Advanced", systemImageName: "gearshape.2.fill", backgroundColor: .gray)
|
||||
|
|
|
@ -11,18 +11,24 @@ import StoreKit
|
|||
import Combine
|
||||
|
||||
struct TipJarView: View {
|
||||
private static let productIDs = [
|
||||
private static let tipProductIDs = [
|
||||
"tusker.tip.small",
|
||||
"tusker.tip.medium",
|
||||
"tusker.tip.large",
|
||||
]
|
||||
private static let supporterProductIDs = [
|
||||
"tusker.supporter.regular",
|
||||
]
|
||||
|
||||
@State private var isLoaded = false
|
||||
@State private var products: [(Product, Bool)] = []
|
||||
@State private var tipProducts: [(Product, Bool)] = []
|
||||
@State private var supporterProducts: [(Product, Bool)] = []
|
||||
@State private var error: Error?
|
||||
@State private var showConfetti = false
|
||||
@State private var updatesObserver: Task<Void, Never>?
|
||||
@State private var buttonWidth: CGFloat?
|
||||
@State private var tipButtonWidth: CGFloat?
|
||||
@State private var supporterButtonWidth: CGFloat?
|
||||
@State private var supporterStartDate: Date?
|
||||
@StateObject private var observer = UbiquitousKeyValueStoreObserver()
|
||||
|
||||
var body: some View {
|
||||
|
@ -47,9 +53,20 @@ struct TipJarView: View {
|
|||
updatesObserver = Task.detached { @MainActor in
|
||||
await observeTransactionUpdates()
|
||||
}
|
||||
for await verificationResult in Transaction.currentEntitlements {
|
||||
if case .verified(let transaction) = verificationResult,
|
||||
Self.supporterProductIDs.contains(transaction.productID),
|
||||
await transaction.subscriptionStatus?.state == .subscribed {
|
||||
supporterStartDate = transaction.originalPurchaseDate
|
||||
break
|
||||
}
|
||||
}
|
||||
do {
|
||||
products = try await Product.products(for: Self.productIDs).map { ($0, false) }
|
||||
products.sort(by: { $0.0.price < $1.0.price })
|
||||
let allProducts = try await Product.products(for: Self.tipProductIDs + Self.supporterProductIDs).map { ($0, false) }
|
||||
tipProducts = allProducts.filter { Self.tipProductIDs.contains($0.0.id) }
|
||||
tipProducts.sort(by: { $0.0.price < $1.0.price })
|
||||
supporterProducts = allProducts.filter { Self.supporterProductIDs.contains($0.0.id) }
|
||||
supporterProducts.sort(by: { $0.0.price < $1.0.price })
|
||||
isLoaded = true
|
||||
} catch {
|
||||
self.error = .fetchingProducts(error)
|
||||
|
@ -67,25 +84,17 @@ struct TipJarView: View {
|
|||
private var productsView: some View {
|
||||
if isLoaded {
|
||||
VStack {
|
||||
Text("If you're enjoying using Tusker and want to show your gratitude or help support its development, it is greatly appreciated!")
|
||||
if !supporterProducts.isEmpty {
|
||||
supporterSubscriptions
|
||||
}
|
||||
|
||||
tipPurchases
|
||||
|
||||
if let tipStatus {
|
||||
tipStatus
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(alignment: .myAlignment) {
|
||||
ForEach($products, id: \.0.id) { $productAndPurchasing in
|
||||
TipRow(product: productAndPurchasing.0, buttonWidth: buttonWidth, isPurchasing: $productAndPurchasing.1, showConfetti: $showConfetti)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(ButtonWidthKey.self) { newValue in
|
||||
if let buttonWidth {
|
||||
self.buttonWidth = max(buttonWidth, newValue)
|
||||
} else {
|
||||
self.buttonWidth = newValue
|
||||
}
|
||||
}
|
||||
|
||||
if let total = getTotalTips(), total > 0 {
|
||||
Text("You've tipped a total of \(Text(total, format: products[0].0.priceFormatStyle)) 😍")
|
||||
.padding(.top, 16)
|
||||
Text("Thank you!")
|
||||
}
|
||||
}
|
||||
|
@ -95,20 +104,93 @@ struct TipJarView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var supporterSubscriptions: some View {
|
||||
Text("If you want to contribute Tusker's continued development, you can become a supporter. Supporting Tusker is an auto-renewable monthly subscription.")
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(alignment: .myAlignment) {
|
||||
ForEach($supporterProducts, id: \.0.id) { $productAndPurchasing in
|
||||
TipRow(product: productAndPurchasing.0, buttonWidth: supporterButtonWidth, isPurchasing: $productAndPurchasing.1, showConfetti: $showConfetti)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(ButtonWidthKey.self) { newValue in
|
||||
if let supporterButtonWidth {
|
||||
self.supporterButtonWidth = max(supporterButtonWidth, newValue)
|
||||
} else {
|
||||
self.supporterButtonWidth = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var tipPurchases: some View {
|
||||
Text("Or, you can choose to make a one-time tip to show your gratitutde or help support the app's development. It is greatly appreciated!")
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 16)
|
||||
|
||||
VStack(alignment: .myAlignment) {
|
||||
ForEach($tipProducts, id: \.0.id) { $productAndPurchasing in
|
||||
TipRow(product: productAndPurchasing.0, buttonWidth: tipButtonWidth, isPurchasing: $productAndPurchasing.1, showConfetti: $showConfetti)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(ButtonWidthKey.self) { newValue in
|
||||
if let tipButtonWidth {
|
||||
self.tipButtonWidth = max(tipButtonWidth, newValue)
|
||||
} else {
|
||||
self.tipButtonWidth = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var tipStatus: Text? {
|
||||
var text: Text?
|
||||
if let supporterStartDate {
|
||||
var months = Calendar.current.dateComponents([.month], from: supporterStartDate, to: Date()).month!
|
||||
// the user has already paid for n months before the nth month has finished, so reflect that
|
||||
months += 1
|
||||
text = Text("You've been a supporter for ^[\(months) months](inflect: true)")
|
||||
}
|
||||
if let total = getTotalTips(),
|
||||
total > 0 {
|
||||
if let t = text {
|
||||
text = Text("\(t) and tipped \(total.formatted(tipProducts[0].0.priceFormatStyle))")
|
||||
} else {
|
||||
text = Text("You've tipped \(total.formatted(tipProducts[0].0.priceFormatStyle))")
|
||||
}
|
||||
}
|
||||
if let text {
|
||||
return Text("\(text) 😍")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func observeTransactionUpdates() async {
|
||||
for await verificationResult in StoreKit.Transaction.updates {
|
||||
guard let index = products.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) else {
|
||||
continue
|
||||
}
|
||||
if let index = tipProducts.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) {
|
||||
switch verificationResult {
|
||||
case .verified(let transaction):
|
||||
await transaction.finish()
|
||||
self.products[index].1 = false
|
||||
self.tipProducts[index].1 = false
|
||||
self.showConfetti = true
|
||||
case .unverified(_, let error):
|
||||
self.error = .verifyingTransaction(error)
|
||||
}
|
||||
} else if let index = supporterProducts.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) {
|
||||
switch verificationResult {
|
||||
case .verified(let transaction):
|
||||
await transaction.finish()
|
||||
self.supporterProducts[index].1 = false
|
||||
self.showConfetti = true
|
||||
self.supporterStartDate = transaction.originalPurchaseDate
|
||||
case .unverified(_, let error):
|
||||
self.error = .verifyingTransaction(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -149,6 +231,20 @@ private struct TipRow: View {
|
|||
Text(product.displayName)
|
||||
.alignmentGuide(.myAlignment, computeValue: { context in context[.trailing] })
|
||||
|
||||
if let subscription = product.subscription {
|
||||
SubscriptionButton(product: product, subscriptionInfo: subscription, isPurchasing: $isPurchasing, buttonWidth: buttonWidth, purchase: purchase)
|
||||
} else {
|
||||
tipButton
|
||||
}
|
||||
}
|
||||
.alertWithData("Error", data: $error) { _ in
|
||||
Button("OK") {}
|
||||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private var tipButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await self.purchase()
|
||||
|
@ -169,12 +265,6 @@ private struct TipRow: View {
|
|||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.alertWithData("Error", data: $error) { _ in
|
||||
Button("OK") {}
|
||||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func purchase() async {
|
||||
|
@ -221,6 +311,71 @@ private struct TipRow: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct SubscriptionButton: View {
|
||||
let product: Product
|
||||
let subscriptionInfo: Product.SubscriptionInfo
|
||||
@Binding var isPurchasing: Bool
|
||||
let buttonWidth: CGFloat?
|
||||
let purchase: () async -> Void
|
||||
@State private var hasPurchased = false
|
||||
@State private var showManageSheet = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
if #available(iOS 17.0, *), hasPurchased {
|
||||
showManageSheet = true
|
||||
} else {
|
||||
Task {
|
||||
await purchase()
|
||||
await updateHasPurchased()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if #available(iOS 17.0, *), hasPurchased {
|
||||
Text("Manage")
|
||||
} else if isPurchasing {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: buttonWidth, alignment: .center)
|
||||
} else {
|
||||
let per: String = if subscriptionInfo.subscriptionPeriod.value == 1, subscriptionInfo.subscriptionPeriod.unit == .month {
|
||||
"mo"
|
||||
} else {
|
||||
subscriptionInfo.subscriptionPeriod.formatted(product.subscriptionPeriodFormatStyle)
|
||||
}
|
||||
Text("\(product.displayPrice)/\(per)")
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: ButtonWidthKey.self, value: proxy.size.width)
|
||||
})
|
||||
.frame(width: buttonWidth)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.task {
|
||||
await updateHasPurchased()
|
||||
}
|
||||
.onChange(of: showManageSheet) {
|
||||
if !$0 {
|
||||
Task {
|
||||
await updateHasPurchased()
|
||||
}
|
||||
}
|
||||
}
|
||||
.manageSubscriptionsSheetIfAvailable(isPresented: $showManageSheet, subscriptionGroupID: subscriptionInfo.subscriptionGroupID)
|
||||
}
|
||||
|
||||
private func updateHasPurchased() async {
|
||||
switch await Transaction.currentEntitlement(for: product.id) {
|
||||
case .verified(let transaction):
|
||||
let state = await transaction.subscriptionStatus?.state
|
||||
hasPurchased = state == .subscribed
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension HorizontalAlignment {
|
||||
private enum MyTrailing: AlignmentID {
|
||||
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||
|
@ -263,3 +418,15 @@ private class UbiquitousKeyValueStoreObserver: ObservableObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@available(iOS, obsoleted: 17.0)
|
||||
@ViewBuilder
|
||||
func manageSubscriptionsSheetIfAvailable(isPresented: Binding<Bool>, subscriptionGroupID: String) -> some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
self.manageSubscriptionsSheet(isPresented: isPresented, subscriptionGroupID: subscriptionGroupID)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,12 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import OSLog
|
||||
#if canImport(Sentry)
|
||||
import Sentry
|
||||
#endif
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ProfileStatusesViewController")
|
||||
|
||||
class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
|
||||
|
||||
|
@ -250,10 +256,17 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
state = .setupInitialSnapshot
|
||||
|
||||
Task {
|
||||
if let (all, _) = try? await mastodonController.run(Client.getRelationships(accounts: [accountID])),
|
||||
let relationship = all.first {
|
||||
do {
|
||||
let (all, _) = try await mastodonController.run(Client.getRelationships(accounts: [accountID]))
|
||||
if let relationship = all.first {
|
||||
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||
}
|
||||
} catch {
|
||||
logger.error("Error fetching relationship: \(String(describing: error))")
|
||||
#if canImport(Sentry)
|
||||
SentrySDK.capture(error: error)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
await controller.loadInitial()
|
||||
|
@ -297,7 +310,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
}
|
||||
|
||||
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
|
@ -513,7 +526,7 @@ extension ProfileStatusesViewController {
|
|||
extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
||||
typealias TimelineItem = String // status ID
|
||||
|
||||
private func request(for range: RequestRange = .default) -> Request<[Status]> {
|
||||
private func request(for range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
switch kind {
|
||||
case .statuses:
|
||||
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
|
||||
|
@ -526,7 +539,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
|||
|
||||
func loadInitial() async throws -> [String] {
|
||||
let request = request()
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
if !statuses.isEmpty {
|
||||
newer = .after(id: statuses.first!.id, count: nil)
|
||||
|
@ -546,7 +559,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
|||
}
|
||||
|
||||
let request = request(for: newer)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
throw Error.allCaughtUp
|
||||
|
@ -567,7 +580,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
|||
}
|
||||
|
||||
let request = request(for: older)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
return []
|
||||
|
|
|
@ -53,7 +53,7 @@ struct ReportAddStatusView: View {
|
|||
.task { @MainActor in
|
||||
do {
|
||||
let req = Account.getStatuses(report.accountID, range: .count(40), excludeReplies: false, excludeReblogs: true)
|
||||
let (statuses, _) = try await mastodonController.run(req)
|
||||
let statuses = try await mastodonController.run(req).0.compactMap(\.value)
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
self.statuses = statuses.compactMap { mastodonController.persistentContainer.status(for: $0.id) }
|
||||
} catch {
|
||||
|
|
|
@ -157,7 +157,7 @@ struct ReportView: View {
|
|||
.appGroupedListRowBackground()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.appGroupedListBackground(container: UIHostingController<ReportView>.self, applyBackground: true)
|
||||
.appGroupedListBackground(container: UIHostingController<ReportView>.self)
|
||||
.alertWithData("Error Reporting", data: $error, actions: { error in
|
||||
Button("OK") {}
|
||||
}, message: { error in
|
||||
|
|
|
@ -24,7 +24,11 @@ class MastodonSearchController: UISearchController {
|
|||
super.searchResultsController as! SearchResultsViewController
|
||||
}
|
||||
|
||||
init(searchResultsController: SearchResultsViewController) {
|
||||
private weak var owner: UIViewController?
|
||||
|
||||
init(searchResultsController: SearchResultsViewController, owner: UIViewController) {
|
||||
self.owner = owner
|
||||
|
||||
super.init(searchResultsController: searchResultsController)
|
||||
|
||||
searchResultsController.tokenHandler = { [unowned self] token, op in
|
||||
|
@ -152,6 +156,12 @@ extension MastodonSearchController: UISearchBarDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
extension MastodonSearchController: MultiColumnNavigationCustomTargetProviding {
|
||||
var multiColumnNavigationTargetViewController: UIViewController? {
|
||||
owner
|
||||
}
|
||||
}
|
||||
|
||||
extension UISearchBar {
|
||||
var searchQueryWithOperators: String {
|
||||
var parts = searchTextField.tokens.compactMap { $0.representedObject as? String }
|
||||
|
|
|
@ -266,7 +266,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
|||
guard self.currentQuery == query else { return }
|
||||
self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in
|
||||
addAccounts(results.accounts)
|
||||
addStatuses(results.statuses)
|
||||
addStatuses(results.statuses.compactMap(\.value))
|
||||
} completion: {
|
||||
DispatchQueue.main.async {
|
||||
self.showSearchResults(results)
|
||||
|
@ -299,7 +299,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
|||
}
|
||||
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
|
||||
snapshot.appendSections([.statuses])
|
||||
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
|
||||
snapshot.appendItems(results.statuses.compactMap(\.value).map { .status($0.id, .unknown) }, toSection: .statuses)
|
||||
}
|
||||
|
||||
dataSource.apply(snapshot)
|
||||
|
|
|
@ -30,6 +30,8 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
|
|||
label.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
|
||||
])
|
||||
|
||||
addInteraction(UIPointerInteraction(delegate: self))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
@ -40,3 +42,10 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
|
|||
label.text = text
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchTokenSuggestionCollectionViewCell: UIPointerInteractionDelegate {
|
||||
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
|
||||
let preview = UITargetedPreview(view: self)
|
||||
return UIPointerStyle(effect: .lift(preview))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -172,6 +172,8 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
|
|||
return Status.getFavourites(statusID, range: range.withCount(Self.pageSize))
|
||||
case .reblog:
|
||||
return Status.getReblogs(statusID, range: range.withCount(Self.pageSize))
|
||||
case .emojiReaction(let name, _):
|
||||
return Status.getReactions(statusID, emoji: name, range: range.withCount(Self.pageSize))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURL
|
||||
|
||||
class StatusActionAccountListViewController: UIViewController {
|
||||
|
||||
|
@ -80,6 +81,8 @@ class StatusActionAccountListViewController: UIViewController {
|
|||
title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title")
|
||||
case .reblog:
|
||||
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
|
||||
case .emojiReaction(_, _):
|
||||
title = "Reacted To By"
|
||||
}
|
||||
|
||||
view.backgroundColor = .appBackground
|
||||
|
@ -178,7 +181,22 @@ extension StatusActionAccountListViewController {
|
|||
|
||||
extension StatusActionAccountListViewController {
|
||||
enum ActionType {
|
||||
case favorite, reblog
|
||||
case favorite
|
||||
case reblog
|
||||
case emojiReaction(String, WebURL?)
|
||||
|
||||
init?(_ groupKind: NotificationGroup.Kind) {
|
||||
switch groupKind {
|
||||
case .reblog:
|
||||
self = .reblog
|
||||
case .favourite:
|
||||
self = .favorite
|
||||
case .emojiReaction(let emoji, let url):
|
||||
self = .emojiReaction(emoji, url)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -565,7 +565,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
do {
|
||||
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home))
|
||||
async let status = try await mastodonController.run(Client.getStatus(id: marker.lastReadID)).0
|
||||
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0
|
||||
// TODO: consider replacing undecodable statuses here with items to indicate that to the user
|
||||
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0.compactMap(\.value)
|
||||
|
||||
let allStatuses = try await [status] + olderStatuses
|
||||
await mastodonController.persistentContainer.addAll(statuses: allStatuses)
|
||||
|
@ -1100,7 +1101,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
|||
|
||||
func loadInitial() async throws -> [TimelineItem] {
|
||||
let request = Client.getStatuses(timeline: timeline, range: .count(TimelineViewController.pageSize))
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
|
@ -1119,7 +1120,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
|||
let newer = RequestRange.after(id: id, count: TimelineViewController.pageSize)
|
||||
|
||||
let request = Client.getStatuses(timeline: timeline, range: newer)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
throw TimelineViewController.Error.allCaughtUp
|
||||
|
@ -1143,7 +1144,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
|||
let older = RequestRange.before(id: id, count: TimelineViewController.pageSize)
|
||||
|
||||
let request = Client.getStatuses(timeline: timeline, range: older)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
return []
|
||||
|
@ -1181,7 +1182,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
|||
}
|
||||
|
||||
let request = Client.getStatuses(timeline: timeline, range: range)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
return []
|
||||
|
|
|
@ -8,19 +8,30 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
/// View controllers, such as `UISearchController`, that live outside the normal VC hierarchy
|
||||
/// can adopt this protocol to indicate to `MultiColumnNavigationController` the context for
|
||||
/// navigation operations.
|
||||
protocol MultiColumnNavigationCustomTargetProviding {
|
||||
var multiColumnNavigationTargetViewController: UIViewController? { get }
|
||||
}
|
||||
|
||||
class MultiColumnNavigationController: UIViewController {
|
||||
|
||||
private var isManuallyUpdating = false
|
||||
var viewControllers: [UIViewController] = [] {
|
||||
didSet {
|
||||
guard isViewLoaded,
|
||||
!isManuallyUpdating else {
|
||||
return
|
||||
private var _viewControllers: [UIViewController] = []
|
||||
var viewControllers: [UIViewController] {
|
||||
get {
|
||||
_viewControllers
|
||||
}
|
||||
set {
|
||||
_viewControllers = newValue
|
||||
if isViewLoaded,
|
||||
!isManuallyUpdating {
|
||||
updateViews()
|
||||
scrollToEnd(animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var scrollView = UIScrollView()
|
||||
private var stackView = UIStackView()
|
||||
|
@ -68,13 +79,13 @@ class MultiColumnNavigationController: UIViewController {
|
|||
|
||||
private func updateViews() {
|
||||
var i = 0
|
||||
while i < viewControllers.count {
|
||||
while i < _viewControllers.count {
|
||||
let needsCloseButton = i > 0
|
||||
if i <= stackView.arrangedSubviews.count - 1 {
|
||||
let existing = stackView.arrangedSubviews[i] as! ColumnView
|
||||
existing.setContent(viewControllers[i], needsCloseButton: needsCloseButton)
|
||||
existing.setContent(_viewControllers[i], needsCloseButton: needsCloseButton)
|
||||
} else {
|
||||
let new = ColumnView(owner: self, contentViewController: viewControllers[i], needsCloseButton: needsCloseButton)
|
||||
let new = ColumnView(owner: self, contentViewController: _viewControllers[i], needsCloseButton: needsCloseButton)
|
||||
stackView.addArrangedSubview(new)
|
||||
}
|
||||
i += 1
|
||||
|
@ -92,9 +103,11 @@ class MultiColumnNavigationController: UIViewController {
|
|||
var index: Int? = nil
|
||||
var current: UIViewController? = sender
|
||||
while let c = current {
|
||||
index = viewControllers.firstIndex(of: c)
|
||||
index = _viewControllers.firstIndex(of: c)
|
||||
if index != nil {
|
||||
break
|
||||
} else if let targetProviding = c as? MultiColumnNavigationCustomTargetProviding {
|
||||
current = targetProviding.multiColumnNavigationTargetViewController
|
||||
} else {
|
||||
current = c.parent
|
||||
}
|
||||
|
@ -112,19 +125,20 @@ class MultiColumnNavigationController: UIViewController {
|
|||
}
|
||||
|
||||
func replaceViewControllers(_ vcs: [UIViewController], after afterIndex: Int, animated: Bool) {
|
||||
if afterIndex == viewControllers.count - 1 && vcs.count == 1 {
|
||||
if afterIndex == _viewControllers.count - 1 && vcs.count == 1 {
|
||||
pushViewController(vcs[0], animated: animated)
|
||||
} else {
|
||||
viewControllers = Array(viewControllers[...afterIndex]) + vcs
|
||||
_viewControllers = Array(_viewControllers[...afterIndex]) + vcs
|
||||
updateViews()
|
||||
scrollToEnd(animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToEnd(animated: Bool) {
|
||||
if viewControllers.isEmpty {
|
||||
if _viewControllers.isEmpty {
|
||||
scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false)
|
||||
} else {
|
||||
scrollColumnToEnd(columnIndex: viewControllers.count - 1, animated: animated)
|
||||
scrollColumnToEnd(columnIndex: _viewControllers.count - 1, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -142,14 +156,12 @@ class MultiColumnNavigationController: UIViewController {
|
|||
}
|
||||
|
||||
fileprivate func closeColumn(_ vc: UIViewController) {
|
||||
let index = viewControllers.firstIndex(of: vc)!
|
||||
guard index > 0 else {
|
||||
guard let index = _viewControllers.firstIndex(of: vc),
|
||||
index > 0 else {
|
||||
// Can't close the last column
|
||||
return
|
||||
}
|
||||
isManuallyUpdating = true
|
||||
defer { isManuallyUpdating = false }
|
||||
viewControllers.removeSubrange(index...)
|
||||
_viewControllers.removeSubrange(index...)
|
||||
animateChanges {
|
||||
for column in self.stackView.arrangedSubviews[index...] {
|
||||
column.layer.opacity = 0
|
||||
|
@ -158,7 +170,6 @@ class MultiColumnNavigationController: UIViewController {
|
|||
} completion: {
|
||||
self.updateViews()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) {
|
||||
|
@ -173,19 +184,22 @@ class MultiColumnNavigationController: UIViewController {
|
|||
|
||||
extension MultiColumnNavigationController: NavigationControllerProtocol {
|
||||
var topViewController: UIViewController? {
|
||||
viewControllers.last
|
||||
_viewControllers.last
|
||||
}
|
||||
|
||||
func popToRootViewController(animated: Bool) -> [UIViewController]? {
|
||||
let removed = Array(viewControllers.dropFirst())
|
||||
viewControllers = [viewControllers.first!]
|
||||
guard !_viewControllers.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let removed = Array(_viewControllers.dropFirst())
|
||||
_viewControllers = [_viewControllers.first!]
|
||||
updateViews()
|
||||
scrollToEnd(animated: animated)
|
||||
return removed
|
||||
}
|
||||
|
||||
func pushViewController(_ vc: UIViewController, animated: Bool) {
|
||||
isManuallyUpdating = true
|
||||
defer { isManuallyUpdating = false }
|
||||
viewControllers.append(vc)
|
||||
_viewControllers.append(vc)
|
||||
updateViews()
|
||||
scrollToEnd(animated: animated)
|
||||
if animated {
|
||||
|
@ -273,12 +287,17 @@ private class ColumnView: UIView {
|
|||
}
|
||||
|
||||
private func installCloseBarButton(navigationItem: UINavigationItem) {
|
||||
func makeItem() -> UIBarButtonItem {
|
||||
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
|
||||
item.accessibilityLabel = "Close Column"
|
||||
if navigationItem.leftBarButtonItems != nil {
|
||||
navigationItem.leftBarButtonItems!.insert(item, at: 0)
|
||||
return item
|
||||
}
|
||||
if let leftItems = navigationItem.leftBarButtonItems {
|
||||
if !leftItems.contains(where: { $0.action == #selector(closeNavigationColumn) }) {
|
||||
navigationItem.leftBarButtonItems!.insert(makeItem(), at: 0)
|
||||
}
|
||||
} else {
|
||||
navigationItem.leftBarButtonItems = [item]
|
||||
navigationItem.leftBarButtonItems = [makeItem()]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,6 @@ import SwiftUI
|
|||
@MainActor
|
||||
protocol MenuActionProvider: AnyObject {
|
||||
var navigationDelegate: TuskerNavigationDelegate? { get }
|
||||
var toastableViewController: ToastableViewController? { get }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
@ -34,10 +33,6 @@ extension MenuActionProvider where Self: TuskerNavigationDelegate {
|
|||
var navigationDelegate: TuskerNavigationDelegate? { self }
|
||||
}
|
||||
|
||||
extension MenuActionProvider where Self: ToastableViewController {
|
||||
var toastableViewController: ToastableViewController? { self }
|
||||
}
|
||||
|
||||
extension MenuActionProvider {
|
||||
|
||||
private var mastodonController: MastodonController? { navigationDelegate?.apiController }
|
||||
|
@ -459,7 +454,7 @@ extension MenuActionProvider {
|
|||
}
|
||||
|
||||
private func handleSuccess(title: String) {
|
||||
if let toastable = self.toastableViewController {
|
||||
if let toastable = self.navigationDelegate {
|
||||
var config = ToastConfiguration(title: title)
|
||||
config.systemImageName = "checkmark"
|
||||
config.dismissAutomaticallyAfter = 2
|
||||
|
@ -562,11 +557,15 @@ extension MenuActionProvider {
|
|||
return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false))
|
||||
} else {
|
||||
let image = UIImage(systemName: "circle.slash")
|
||||
return UIMenu(title: "Block", image: image, children: [
|
||||
var children = [
|
||||
UIAction(title: "Cancel", handler: { _ in }),
|
||||
UIAction(title: "Block \(displayName)", image: image, attributes: .destructive, handler: handler(true)),
|
||||
UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true))
|
||||
])
|
||||
]
|
||||
if mastodonController.instanceFeatures.blockDomains,
|
||||
host != mastodonController.account?.url.host {
|
||||
children.append(UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true)))
|
||||
}
|
||||
return UIMenu(title: "Block", image: image, children: children)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -597,7 +596,8 @@ extension MenuActionProvider {
|
|||
@MainActor
|
||||
private func hideReblogsAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
|
||||
// don't show action for people that the user isn't following and isn't already hiding reblogs for
|
||||
guard relationship.following || relationship.showingReblogs else {
|
||||
guard relationship.following || relationship.showingReblogs,
|
||||
mastodonController.instanceFeatures.hideReblogs else {
|
||||
return nil
|
||||
}
|
||||
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"
|
||||
|
|
|
@ -50,8 +50,11 @@ extension UIViewController {
|
|||
}
|
||||
|
||||
func removeViewAndController() {
|
||||
beginAppearanceTransition(false, animated: false)
|
||||
view.removeFromSuperview()
|
||||
willMove(toParent: nil)
|
||||
removeFromParent()
|
||||
endAppearanceTransition()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,7 +71,7 @@ extension UIView {
|
|||
|
||||
if layout {
|
||||
subview.frame = bounds
|
||||
|
||||
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
|
|
|
@ -44,9 +44,9 @@ enum AppShortcutItem: String, CaseIterable {
|
|||
}
|
||||
switch self {
|
||||
case .showHomeTimeline:
|
||||
root.select(route: .timelines, animated: false)
|
||||
root.select(route: .timelines, animated: false, completion: nil)
|
||||
case .showNotifications:
|
||||
root.select(route: .notifications, animated: false)
|
||||
root.select(route: .notifications, animated: false, completion: nil)
|
||||
case .composePost:
|
||||
root.compose(editing: nil, animated: false, isDucked: false)
|
||||
}
|
||||
|
|
|
@ -39,12 +39,13 @@ extension NSUserActivity {
|
|||
self.userInfo = [
|
||||
"accountID": accountID
|
||||
]
|
||||
self.targetContentIdentifier = accountID
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleResume(manager: UserActivityManager) -> Bool {
|
||||
func handleResume(manager: UserActivityManager) async -> Bool {
|
||||
guard let type = UserActivityType(rawValue: activityType) else { return false }
|
||||
type.handle(manager)(self)
|
||||
await type.handle(manager)(self)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,8 @@ import ComposeUI
|
|||
protocol UserActivityHandlingContext {
|
||||
var isHandoff: Bool { get }
|
||||
|
||||
func select(route: TuskerRoute)
|
||||
func select(route: TuskerRoute) async
|
||||
func select(route: TuskerRoute, completion: (() -> Void)?)
|
||||
func present(_ vc: UIViewController)
|
||||
|
||||
var topViewController: UIViewController? { get }
|
||||
|
@ -28,6 +29,16 @@ protocol UserActivityHandlingContext {
|
|||
func finalize(activity: NSUserActivity)
|
||||
}
|
||||
|
||||
extension UserActivityHandlingContext {
|
||||
func select(route: TuskerRoute) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
select(route: route) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
|
||||
let isHandoff: Bool
|
||||
let root: TuskerRootViewController
|
||||
|
@ -35,8 +46,8 @@ struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
|
|||
root.getNavigationDelegate()!
|
||||
}
|
||||
|
||||
func select(route: TuskerRoute) {
|
||||
root.select(route: route, animated: true)
|
||||
func select(route: TuskerRoute, completion: (() -> Void)?) {
|
||||
root.select(route: route, animated: true, completion: completion)
|
||||
}
|
||||
|
||||
func present(_ vc: UIViewController) {
|
||||
|
@ -71,9 +82,11 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
|
|||
|
||||
var isHandoff: Bool { false }
|
||||
|
||||
func select(route: TuskerRoute) {
|
||||
root.select(route: route, animated: false)
|
||||
state = .selectedRoute
|
||||
func select(route: TuskerRoute, completion: (() -> Void)?) {
|
||||
root.select(route: route, animated: false) {
|
||||
self.state = .selectedRoute
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
var topViewController: UIViewController? { root.getNavigationController().topViewController }
|
||||
|
|
|
@ -133,8 +133,8 @@ class UserActivityManager {
|
|||
return activity
|
||||
}
|
||||
|
||||
func handleCheckNotifications(activity: NSUserActivity) {
|
||||
context.select(route: .notifications)
|
||||
func handleCheckNotifications(activity: NSUserActivity) async {
|
||||
await context.select(route: .notifications)
|
||||
context.popToRoot()
|
||||
if let notificationsPageController = context.topViewController as? NotificationsPageViewController {
|
||||
notificationsPageController.loadViewIfNeeded()
|
||||
|
@ -204,22 +204,22 @@ class UserActivityManager {
|
|||
return (timeline, positionInfo)
|
||||
}
|
||||
|
||||
func handleShowTimeline(activity: NSUserActivity) {
|
||||
func handleShowTimeline(activity: NSUserActivity) async {
|
||||
guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return }
|
||||
|
||||
var timelineVC: TimelineViewController?
|
||||
if let pinned = PinnedTimeline(timeline: timeline),
|
||||
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
|
||||
context.select(route: .timelines)
|
||||
await context.select(route: .timelines)
|
||||
context.popToRoot()
|
||||
let pageController = context.topViewController as! TimelinesPageViewController
|
||||
pageController.selectTimeline(pinned, animated: false)
|
||||
timelineVC = pageController.currentViewController as? TimelineViewController
|
||||
} else if case .list(let id) = timeline {
|
||||
context.select(route: .list(id: id))
|
||||
await context.select(route: .list(id: id))
|
||||
timelineVC = context.topViewController as? TimelineViewController
|
||||
} else {
|
||||
context.select(route: .explore)
|
||||
await context.select(route: .explore)
|
||||
context.popToRoot()
|
||||
timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||
context.push(timelineVC!)
|
||||
|
@ -249,11 +249,11 @@ class UserActivityManager {
|
|||
return activity.userInfo?["mainStatusID"] as? String
|
||||
}
|
||||
|
||||
func handleShowConversation(activity: NSUserActivity) {
|
||||
func handleShowConversation(activity: NSUserActivity) async {
|
||||
guard let mainStatusID = Self.getConversationStatus(from: activity) else {
|
||||
return
|
||||
}
|
||||
context.select(route: .timelines)
|
||||
await context.select(route: .timelines)
|
||||
context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController))
|
||||
}
|
||||
|
||||
|
@ -274,8 +274,8 @@ class UserActivityManager {
|
|||
return activity.userInfo?["query"] as? String
|
||||
}
|
||||
|
||||
func handleSearch(activity: NSUserActivity) {
|
||||
context.select(route: .explore)
|
||||
func handleSearch(activity: NSUserActivity) async {
|
||||
await context.select(route: .explore)
|
||||
context.popToRoot()
|
||||
|
||||
let searchController: UISearchController
|
||||
|
@ -311,8 +311,8 @@ class UserActivityManager {
|
|||
return activity
|
||||
}
|
||||
|
||||
func handleBookmarks(activity: NSUserActivity) {
|
||||
context.select(route: .bookmarks)
|
||||
func handleBookmarks(activity: NSUserActivity) async {
|
||||
await context.select(route: .bookmarks)
|
||||
}
|
||||
|
||||
// MARK: - My Profile
|
||||
|
@ -325,8 +325,8 @@ class UserActivityManager {
|
|||
return activity
|
||||
}
|
||||
|
||||
func handleMyProfile(activity: NSUserActivity) {
|
||||
context.select(route: .myProfile)
|
||||
func handleMyProfile(activity: NSUserActivity) async {
|
||||
await context.select(route: .myProfile)
|
||||
}
|
||||
|
||||
// MARK: - Show Profile
|
||||
|
@ -344,11 +344,11 @@ class UserActivityManager {
|
|||
return activity.userInfo?["profileID"] as? String
|
||||
}
|
||||
|
||||
func handleShowProfile(activity: NSUserActivity) {
|
||||
func handleShowProfile(activity: NSUserActivity) async {
|
||||
guard let accountID = Self.getProfile(from: activity) else {
|
||||
return
|
||||
}
|
||||
context.select(route: .timelines)
|
||||
await context.select(route: .timelines)
|
||||
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
|
||||
}
|
||||
|
||||
|
@ -361,11 +361,11 @@ class UserActivityManager {
|
|||
return activity
|
||||
}
|
||||
|
||||
func handleShowNotification(activity: NSUserActivity) {
|
||||
func handleShowNotification(activity: NSUserActivity) async {
|
||||
guard let notificationID = activity.userInfo?["notificationID"] as? String else {
|
||||
return
|
||||
}
|
||||
context.select(route: .notifications)
|
||||
await context.select(route: .notifications)
|
||||
context.push(NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController))
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ enum UserActivityType: String {
|
|||
|
||||
extension UserActivityType {
|
||||
@MainActor
|
||||
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void {
|
||||
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) async -> Void {
|
||||
switch self {
|
||||
case .mainScene:
|
||||
fatalError("cannot handle main scene activity")
|
||||
|
|
|
@ -53,13 +53,94 @@
|
|||
"settings" : {
|
||||
"_applicationInternalID" : "1498334597",
|
||||
"_developerTeamID" : "V4WK9KR9U2",
|
||||
"_lastSynchronizedDate" : 696310076.23998904
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 737914663.21114194,
|
||||
"_storeKitErrors" : [
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Load Products"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Purchase"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Verification"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Store Sync"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Subscription Status"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Transaction"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Manage Subscriptions Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Refund Request Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Offer Code Redeem Sheet"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "21490109",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "Tip Jar",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "1.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6502909920",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Support the continued development of Tusker!",
|
||||
"displayName" : "Tusker Supporter",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "tusker.supporter.regular",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "tusker.supporter.regular",
|
||||
"subscriptionGroupID" : "21490109",
|
||||
"type" : "RecurringSubscription"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 2,
|
||||
"major" : 3,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -511,7 +511,7 @@ extension AttachmentView: UIContextMenuInteractionDelegate {
|
|||
} else if self.attachment.kind == .gifv || self.attachment.kind == .video {
|
||||
itemSource = VideoActivityItemSource(asset: AVAsset(url: self.attachment.url), url: self.attachment.url)
|
||||
itemData = Task {
|
||||
try? await URLSession.shared.data(from: self.attachment.url).0
|
||||
try? await URLSession.appDefault.data(from: self.attachment.url).0
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
|
|
|
@ -33,6 +33,11 @@ class CachedImageView: UIImageView {
|
|||
commonInit()
|
||||
}
|
||||
|
||||
deinit {
|
||||
fetchTask?.cancel()
|
||||
blurHashTask?.cancel()
|
||||
}
|
||||
|
||||
private func commonInit() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
|
|
@ -36,6 +36,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
var emojiFont: UIFont = .preferredFont(forTextStyle: .body)
|
||||
var emojiTextColor: UIColor = .label
|
||||
|
||||
private let tapRecognizer = UITapGestureRecognizer()
|
||||
|
||||
// The link range currently being previewed
|
||||
private var currentPreviewedLinkRange: NSRange?
|
||||
// The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing.
|
||||
|
@ -78,8 +80,9 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
updateLinkUnderlineStyle()
|
||||
|
||||
// the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer
|
||||
let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
|
||||
addGestureRecognizer(recognizer)
|
||||
tapRecognizer.addTarget(self, action: #selector(textTapped(_:)))
|
||||
tapRecognizer.delegate = self
|
||||
addGestureRecognizer(tapRecognizer)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil)
|
||||
underlineTextLinksCancellable =
|
||||
|
@ -132,12 +135,6 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
}
|
||||
|
||||
@objc func textTapped(_ recognizer: UITapGestureRecognizer) {
|
||||
// if there currently is a selection, deselct it on single-tap
|
||||
if selectedRange.length > 0 {
|
||||
// location doesn't matter since we are non-editable and the cursor isn't visible
|
||||
selectedRange = NSRange(location: 0, length: 0)
|
||||
}
|
||||
|
||||
let location = recognizer.location(in: self)
|
||||
if let (link, range) = getLinkAtPoint(location),
|
||||
link.scheme != dataDetectorsScheme {
|
||||
|
@ -267,10 +264,6 @@ extension ContentTextView: UITextViewDelegate {
|
|||
}
|
||||
|
||||
extension ContentTextView: MenuActionProvider {
|
||||
var toastableViewController: ToastableViewController? {
|
||||
// todo: pass this down through the text view
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ContentTextView: UIContextMenuInteractionDelegate {
|
||||
|
@ -388,3 +381,25 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ContentTextView: UIGestureRecognizerDelegate {
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
// NB: This method is both a gesture recognizer delegate method and a UIView method.
|
||||
// We only want to prevent our own tap gesture recognizer from beginning, but don't
|
||||
// want to interfere with any other gestures that may begin over this view.
|
||||
if gestureRecognizer === tapRecognizer {
|
||||
let location = gestureRecognizer.location(in: self)
|
||||
if let (link, _) = getLinkAtPoint(location) {
|
||||
if link.scheme == dataDetectorsScheme {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,11 @@ import SwiftUI
|
|||
import SafariServices
|
||||
|
||||
class ProfileFieldValueView: UIView {
|
||||
weak var navigationDelegate: TuskerNavigationDelegate?
|
||||
weak var navigationDelegate: TuskerNavigationDelegate? {
|
||||
didSet {
|
||||
textView.navigationDelegate = navigationDelegate
|
||||
}
|
||||
}
|
||||
|
||||
private static let converter = HTMLConverter(
|
||||
font: .preferredFont(forTextStyle: .body),
|
||||
|
@ -23,9 +27,8 @@ class ProfileFieldValueView: UIView {
|
|||
|
||||
private let account: AccountMO
|
||||
private let field: Account.Field
|
||||
private var link: (String, URL)?
|
||||
|
||||
private let label = EmojiLabel()
|
||||
private let textView = ContentTextView()
|
||||
private var iconView: UIView?
|
||||
|
||||
private var currentTargetedPreview: UITargetedPreview?
|
||||
|
@ -38,34 +41,28 @@ class ProfileFieldValueView: UIView {
|
|||
|
||||
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 }
|
||||
#if os(visionOS)
|
||||
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
|
||||
textView.linkTextAttributes = [
|
||||
.foregroundColor: UIColor.link
|
||||
]
|
||||
#else
|
||||
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
|
||||
textView.linkTextAttributes = [
|
||||
.foregroundColor: UIColor.tintColor
|
||||
]
|
||||
#endif
|
||||
// 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)
|
||||
textView.backgroundColor = nil
|
||||
textView.isScrollEnabled = false
|
||||
textView.isSelectable = false
|
||||
textView.isEditable = false
|
||||
textView.textContainerInset = .zero
|
||||
textView.font = .preferredFont(forTextStyle: .body)
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
textView.attributedText = converted
|
||||
textView.setEmojis(account.emojis, identifier: account.id)
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(textView)
|
||||
|
||||
let labelTrailingConstraint: NSLayoutConstraint
|
||||
|
||||
|
@ -82,20 +79,20 @@ class ProfileFieldValueView: UIView {
|
|||
icon.isPointerInteractionEnabled = true
|
||||
icon.accessibilityLabel = "Verified link"
|
||||
addSubview(icon)
|
||||
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
||||
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
||||
NSLayoutConstraint.activate([
|
||||
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
|
||||
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
|
||||
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
|
||||
])
|
||||
} else {
|
||||
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
labelTrailingConstraint,
|
||||
label.topAnchor.constraint(equalTo: topAnchor),
|
||||
label.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
textView.topAnchor.constraint(equalTo: topAnchor),
|
||||
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -104,7 +101,7 @@ class ProfileFieldValueView: UIView {
|
|||
}
|
||||
|
||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||
var size = label.sizeThatFits(size)
|
||||
var size = textView.sizeThatFits(size)
|
||||
if let iconView {
|
||||
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
|
||||
}
|
||||
|
@ -112,29 +109,7 @@ class ProfileFieldValueView: UIView {
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
textView.textAlignment = alignment
|
||||
}
|
||||
|
||||
@objc private func verifiedIconTapped() {
|
||||
|
@ -144,7 +119,7 @@ class ProfileFieldValueView: UIView {
|
|||
let view = ProfileFieldVerificationView(
|
||||
acct: account.acct,
|
||||
verifiedAt: field.verifiedAt!,
|
||||
linkText: label.text ?? "",
|
||||
linkText: textView.text ?? "",
|
||||
navigationDelegate: navigationDelegate
|
||||
)
|
||||
let host = UIHostingController(rootView: view)
|
||||
|
@ -168,53 +143,3 @@ class ProfileFieldValueView: UIView {
|
|||
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)
|
||||
#if !os(visionOS)
|
||||
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
||||
#endif
|
||||
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!)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,9 +25,9 @@ class ProfileHeaderView: UIView {
|
|||
weak var delegate: ProfileHeaderViewDelegate?
|
||||
var mastodonController: MastodonController! { delegate?.apiController }
|
||||
|
||||
@IBOutlet weak var headerImageView: UIImageView!
|
||||
@IBOutlet weak var headerImageView: CachedImageView!
|
||||
@IBOutlet weak var avatarContainerView: UIView!
|
||||
@IBOutlet weak var avatarImageView: UIImageView!
|
||||
@IBOutlet weak var avatarImageView: CachedImageView!
|
||||
@IBOutlet weak var moreButton: ProfileHeaderButton!
|
||||
@IBOutlet weak var followButton: ProfileHeaderButton!
|
||||
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
|
||||
|
@ -44,8 +44,6 @@ class ProfileHeaderView: UIView {
|
|||
|
||||
var accountID: String!
|
||||
|
||||
private var imagesTask: Task<Void, Never>?
|
||||
|
||||
private var isGrayscale = false
|
||||
private var followButtonMode = FollowButtonMode.follow {
|
||||
didSet {
|
||||
|
@ -56,10 +54,6 @@ class ProfileHeaderView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
imagesTask?.cancel()
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
|
@ -69,11 +63,13 @@ class ProfileHeaderView: UIView {
|
|||
avatarContainerView.layer.cornerCurve = .continuous
|
||||
// Set zPositions so the gallery presentation/dismissal animation looks correct.
|
||||
avatarContainerView.layer.zPosition = 2
|
||||
avatarImageView.cache = .avatars
|
||||
avatarImageView.layer.masksToBounds = true
|
||||
avatarImageView.layer.cornerCurve = .continuous
|
||||
avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(avatarPressed)))
|
||||
avatarImageView.isUserInteractionEnabled = true
|
||||
|
||||
headerImageView.cache = .headers
|
||||
headerImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(headerPressed)))
|
||||
headerImageView.isUserInteractionEnabled = true
|
||||
headerImageView.layer.zPosition = 1
|
||||
|
@ -138,11 +134,11 @@ class ProfileHeaderView: UIView {
|
|||
usernameLabel.text = "@\(account.acct)"
|
||||
lockImageView.isHidden = !account.locked
|
||||
|
||||
imagesTask?.cancel()
|
||||
let avatar = account.avatar
|
||||
let header = account.header
|
||||
imagesTask = Task {
|
||||
await updateImages(avatar: avatar, header: header)
|
||||
if let avatar = account.avatar {
|
||||
avatarImageView.update(for: avatar)
|
||||
}
|
||||
if let header = account.header {
|
||||
headerImageView.update(for: header)
|
||||
}
|
||||
|
||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? [])
|
||||
|
@ -294,44 +290,6 @@ class ProfileHeaderView: UIView {
|
|||
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
|
||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
|
||||
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||
|
||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
imagesTask?.cancel()
|
||||
let avatar = account.avatar
|
||||
let header = account.header
|
||||
imagesTask = Task {
|
||||
await updateImages(avatar: avatar, header: header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func updateImages(avatar: URL?, header: URL?) async {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
guard let avatar,
|
||||
let image = await ImageCache.avatars.get(avatar, loadOriginal: true).1,
|
||||
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: avatar, image: image),
|
||||
!Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
self.avatarImageView.image = transformedImage
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
guard let header,
|
||||
let image = await ImageCache.avatars.get(header, loadOriginal: true).1,
|
||||
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: header, image: image),
|
||||
!Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
self.headerImageView.image = transformedImage
|
||||
}
|
||||
}
|
||||
await group.waitForAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func formatBigNumber(_ value: Int) -> (String, String) {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
|
||||
<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"/>
|
||||
|
@ -14,7 +14,7 @@
|
|||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="iN0-l3-epB" customClass="ProfileHeaderView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dgG-dR-lSv">
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dgG-dR-lSv" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="48" width="414" height="150"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="150" id="aCE-CA-XWm"/>
|
||||
|
@ -23,7 +23,7 @@
|
|||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wT9-2J-uSY">
|
||||
<rect key="frame" x="16" y="138" width="120" height="120"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4">
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="2" y="2" width="116" height="116"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="116" id="eDg-Vc-o8R"/>
|
||||
|
@ -93,7 +93,7 @@
|
|||
<stackView opaque="NO" contentMode="scaleToFill" distribution="fillProportionally" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="udp-EN-wtc">
|
||||
<rect key="frame" x="0.0" y="575.5" width="218.5" height="20.5"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc">
|
||||
<rect key="frame" x="0.0" y="0.0" width="104" height="20.5"/>
|
||||
<state key="normal" title="Button"/>
|
||||
<buttonConfiguration key="configuration" style="plain" title="123 Following">
|
||||
|
@ -104,7 +104,7 @@
|
|||
<action selector="followingCountButtonPressed:" destination="iN0-l3-epB" eventType="touchUpInside" id="QOO-zK-pfu"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" id="XCX-Y3-cG5">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" id="XCX-Y3-cG5">
|
||||
<rect key="frame" x="112" y="0.0" width="106.5" height="20.5"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<state key="normal" title="Button"/>
|
||||
|
|
|
@ -640,7 +640,7 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
|
|||
return defaultRegion
|
||||
} else if let button = interaction.view as? UIButton,
|
||||
actionButtons.contains(button) {
|
||||
var rect = button.convert(button.imageView!.bounds, to: button.imageView!)
|
||||
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
|
||||
rect = rect.insetBy(dx: -24, dy: -24)
|
||||
return UIPointerRegion(rect: rect)
|
||||
}
|
||||
|
@ -654,8 +654,8 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
|
|||
} else if let button = interaction.view as? UIButton,
|
||||
actionButtons.contains(button) {
|
||||
let preview = UITargetedPreview(view: button.imageView!)
|
||||
var rect = button.convert(button.imageView!.bounds, to: button.imageView!)
|
||||
rect = rect.insetBy(dx: -24, dy: -24)
|
||||
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
|
||||
rect = rect.insetBy(dx: -8, dy: -8)
|
||||
return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect))
|
||||
}
|
||||
return nil
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue