Compare commits

...

13 Commits

Author SHA1 Message Date
Shadowfacts ca764811ed Bump build number and update changelog 2024-04-23 13:19:52 -04:00
Shadowfacts a589bb2863 Support emoji reaction push notifications on pleroma/akkoma 2024-04-18 13:17:55 -04:00
Shadowfacts 6f35fd2676 Show pleroma/akkoma emoji notifications
Closes #159
2024-04-18 12:59:44 -04:00
Shadowfacts e83cef1c8c Fix overzealously attempting to migrate local data to cloud store
Fix error when actually migrating due to not opening the store with
NSPersistentHistoryTrackingKey set to true.
2024-04-18 11:45:32 -04:00
Shadowfacts b89df3f27b Add instance announcements
Closes #356
2024-04-18 00:00:00 -04:00
Shadowfacts 4ecc16a93b Move FuzzyMatcher to TuskerComponents 2024-04-17 22:34:31 -04:00
Shadowfacts 8960873ff3 Remove redundant toastableViewController property 2024-04-17 22:34:31 -04:00
Shadowfacts 043a708515 Add more logging to onboarding VC 2024-04-17 17:04:32 -04:00
Shadowfacts c6b230414e Fix error decoding InstanceV2 response on certain instances 2024-04-17 10:18:01 -04:00
Shadowfacts f5e9f66f76 Fix app background colors not updating when preference changed
This only fully fixes it on iOS 17, but it seems to be the best we can do
2024-04-16 12:03:52 -04:00
Shadowfacts ee5f9a62ff Fix push subscription settings GroupBox background in dark mode
Closes #470
2024-04-16 11:37:36 -04:00
Shadowfacts a92cf8c812 Fix potential crash when hit testing StatusCollapseButton 2024-04-15 22:50:31 -04:00
Shadowfacts 756874949a Actually add notification extension privacy manifest to target 2024-04-15 22:42:18 -04:00
45 changed files with 1329 additions and 88 deletions

View File

@ -1,5 +1,16 @@
# Changelog # Changelog
## 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) ## 2024.2 (121)
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance. This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.

View File

@ -109,6 +109,12 @@ class NotificationService: UNNotificationServiceExtension {
kindStr = "📊 Poll finished" kindStr = "📊 Poll finished"
case .update: case .update:
kindStr = "✏️ Edited" kindStr = "✏️ Edited"
case .emojiReaction:
if let emoji = notification.emoji {
kindStr = "\(emoji) Reacted"
} else {
kindStr = nil
}
default: default:
kindStr = nil kindStr = nil
} }

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import Combine import Combine
import TuskerComponents
class AutocompleteEmojisController: ViewController { class AutocompleteEmojisController: ViewController {
unowned let composeController: ComposeController unowned let composeController: ComposeController

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Combine import Combine
import Pachyderm import Pachyderm
import TuskerComponents
class AutocompleteHashtagsController: ViewController { class AutocompleteHashtagsController: ViewController {
unowned let composeController: ComposeController unowned let composeController: ComposeController

View File

@ -209,6 +209,14 @@ public final class InstanceFeatures: ObservableObject {
} }
} }
public var instanceAnnouncements: Bool {
hasMastodonVersion(3, 1, 0)
}
public var emojiReactionNotifications: Bool {
instanceType.isPleroma
}
public init() { public init() {
} }

View File

@ -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"
}
}
}

View File

@ -43,8 +43,13 @@ extension Emoji: CustomDebugStringConvertible {
} }
} }
extension Emoji: Equatable { extension Emoji: Equatable, Hashable {
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool { public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
} }
public func hash(into hasher: inout Hasher) {
hasher.combine(shortcode)
hasher.combine(url)
}
} }

View File

@ -53,7 +53,7 @@ extension InstanceV2 {
public struct Thumbnail: Decodable, Sendable { public struct Thumbnail: Decodable, Sendable {
public let url: String public let url: String
public let blurhash: String? public let blurhash: String?
public let versions: ThumbnailVersions public let versions: ThumbnailVersions?
} }
public struct ThumbnailVersions: Decodable, Sendable { public struct ThumbnailVersions: Decodable, Sendable {
@ -120,6 +120,6 @@ extension InstanceV2 {
extension InstanceV2 { extension InstanceV2 {
public struct Contact: Decodable, Sendable { public struct Contact: Decodable, Sendable {
public let email: String public let email: String
public let account: Account public let account: Account?
} }
} }

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
public struct Notification: Decodable, Sendable { public struct Notification: Decodable, Sendable {
public let id: String public let id: String
@ -14,6 +15,10 @@ public struct Notification: Decodable, Sendable {
public let createdAt: Date public let createdAt: Date
public let account: Account public let account: Account
public let status: Status? 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 { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) 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.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.account = try container.decode(Account.self, forKey: .account) self.account = try container.decode(Account.self, forKey: .account)
self.status = try container.decodeIfPresent(Status.self, forKey: .status) 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> { public static func dismiss(id notificationID: String) -> Request<Empty> {
@ -39,6 +46,8 @@ public struct Notification: Decodable, Sendable {
case createdAt = "created_at" case createdAt = "created_at"
case account case account
case status case status
case emoji
case emojiURL = "emoji_url"
} }
} }
@ -52,6 +61,7 @@ extension Notification {
case poll case poll
case update case update
case status case status
case emojiReaction = "pleroma:emoji_reaction"
case unknown case unknown
} }
} }

View File

@ -44,6 +44,7 @@ public struct PushSubscription: Decodable, Sendable {
"data[alerts][favourite]" => alerts.favourite, "data[alerts][favourite]" => alerts.favourite,
"data[alerts][poll]" => alerts.poll, "data[alerts][poll]" => alerts.poll,
"data[alerts][update]" => alerts.update, "data[alerts][update]" => alerts.update,
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
"data[policy]" => policy.rawValue, "data[policy]" => policy.rawValue,
])) ]))
} }
@ -58,6 +59,7 @@ public struct PushSubscription: Decodable, Sendable {
"data[alerts][favourite]" => alerts.favourite, "data[alerts][favourite]" => alerts.favourite,
"data[alerts][poll]" => alerts.poll, "data[alerts][poll]" => alerts.poll,
"data[alerts][update]" => alerts.update, "data[alerts][update]" => alerts.update,
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
"data[policy]" => policy.rawValue, "data[policy]" => policy.rawValue,
])) ]))
} }
@ -85,8 +87,19 @@ extension PushSubscription {
public let favourite: Bool public let favourite: Bool
public let poll: Bool public let poll: Bool
public let update: 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.mention = mention
self.status = status self.status = status
self.reblog = reblog self.reblog = reblog
@ -95,6 +108,7 @@ extension PushSubscription {
self.favourite = favourite self.favourite = favourite
self.poll = poll self.poll = poll
self.update = update self.update = update
self.emojiReaction = emojiReaction
} }
public init(from decoder: any Decoder) throws { 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) self.poll = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.poll)
// update added in mastodon 3.5.0 // update added in mastodon 3.5.0
self.update = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.update) ?? false 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 { private enum CodingKeys: String, CodingKey {
@ -121,6 +137,7 @@ extension PushSubscription {
case favourite case favourite
case poll case poll
case update case update
case emojiReaction = "pleroma:emoji_reaction"
} }
} }
} }

View File

@ -124,6 +124,12 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return request 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> { public static func delete(_ statusID: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)") return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
} }

View File

@ -7,17 +7,18 @@
// //
import Foundation import Foundation
import WebURL
public struct NotificationGroup: Identifiable, Hashable, Sendable { public struct NotificationGroup: Identifiable, Hashable, Sendable {
public private(set) var notifications: [Notification] public private(set) var notifications: [Notification]
public let id: String 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 } guard !notifications.isEmpty else { return nil }
self.notifications = notifications self.notifications = notifications
self.id = notifications.first!.id self.id = notifications.first!.id
self.kind = notifications.first!.kind self.kind = kind
} }
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool { public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
@ -43,31 +44,62 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
private mutating func append(group: NotificationGroup) { private mutating func append(group: NotificationGroup) {
notifications.append(contentsOf: group.notifications) 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 @MainActor
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] { public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [NotificationGroup]() var groups = [NotificationGroup]()
for notification in notifications { for notification in notifications {
let groupKind = groupKind(for: notification)
if allowedTypes.contains(notification.kind) { 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) groups[groups.count - 1].append(notification)
continue continue
} else if groups.count >= 2 { } else if groups.count >= 2 {
let secondToLastGroup = groups[groups.count - 2] let secondToLastGroup = groups[groups.count - 2]
if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) { if allowedTypes.contains(notification.kind), canMerge(notification: notification, kind: groupKind, into: secondToLastGroup) {
groups[groups.count - 2].append(notification) groups[groups.count - 2].append(notification)
continue continue
} }
} }
} }
groups.append(NotificationGroup(notifications: [notification])!) groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!)
} }
return groups return groups
} }
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool { private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool {
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id 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] { 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 var second = second
merged.reserveCapacity(second.count) merged.reserveCapacity(second.count)
while let firstGroupFromSecond = second.first, while let firstGroupFromSecond = second.first,
allowedTypes.contains(firstGroupFromSecond.kind) { allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) {
second.removeFirst() second.removeFirst()
guard let lastGroup = merged.last, guard let lastGroup = merged.last,
allowedTypes.contains(lastGroup.kind) else { allowedTypes.contains(lastGroup.kind.notificationKind) else {
merged.append(firstGroupFromSecond) merged.append(firstGroupFromSecond)
break 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) merged[merged.count - 1].append(group: firstGroupFromSecond)
} else if merged.count >= 2 { } else if merged.count >= 2 {
let secondToLastGroup = merged[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) merged[merged.count - 2].append(group: firstGroupFromSecond)
} else { } else {
merged.append(firstGroupFromSecond) merged.append(firstGroupFromSecond)
@ -109,4 +141,42 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
return merged 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
}
}
}
} }

View File

@ -70,6 +70,7 @@ public struct PushSubscription {
public static let favorite = Alerts(rawValue: 1 << 5) public static let favorite = Alerts(rawValue: 1 << 5)
public static let poll = Alerts(rawValue: 1 << 6) public static let poll = Alerts(rawValue: 1 << 6)
public static let update = Alerts(rawValue: 1 << 7) public static let update = Alerts(rawValue: 1 << 7)
public static let emojiReaction = Alerts(rawValue: 1 << 8)
public let rawValue: Int public let rawValue: Int

View File

@ -1,6 +1,6 @@
// //
// FuzzyMatcher.swift // FuzzyMatcher.swift
// ComposeUI // TuskerComponents
// //
// Created by Shadowfacts on 10/10/20. // Created by Shadowfacts on 10/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved. // Copyright © 2020 Shadowfacts. All rights reserved.
@ -8,7 +8,7 @@
import Foundation import Foundation
struct FuzzyMatcher { public struct FuzzyMatcher {
private init() {} 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 occurs in `str` sequentially
/// -2 points for every char in `pattern` that does not occur 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` /// -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 pattern = pattern.lowercased()
let str = str.lowercased() let str = str.lowercased()

View File

@ -74,4 +74,9 @@ extension PreferenceStore {
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool { public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
enabledFeatureFlags.contains(flag) enabledFeatureFlags.contains(flag)
} }
public func getValue<Key: PreferenceKey>(preferenceKeyPath: KeyPath<PreferenceStore, PreferencePublisher<Key>>) -> Key.Value {
self[keyPath: preferenceKeyPath].preference.wrappedValue
}
} }

View File

@ -227,6 +227,12 @@
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */; }; D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */; };
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.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 */; }; 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 */; }; D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; }; D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; };
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; }; D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; };
@ -291,6 +297,7 @@
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */; }; D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */; };
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */; }; D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */; };
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532E2BCB873400E26A0E /* MockStatusView.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 */; }; D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.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 */; }; D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -649,6 +656,12 @@
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeSceneDelegate.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; };
@ -1054,6 +1067,7 @@
children = ( children = (
D65B4B89297879DE00DABDFB /* Account Follows */, D65B4B89297879DE00DABDFB /* Account Follows */,
D6A3BC822321F69400FD64D5 /* Account List */, D6A3BC822321F69400FD64D5 /* Account List */,
D698F4472BCEE2320054DB14 /* Announcements */,
D641C787213DD862004B4513 /* Compose */, D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */, D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */, D6F2E960249E772F005846BB /* Crash Reporter */,
@ -1349,6 +1363,19 @@
path = About; path = About;
sourceTree = "<group>"; 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 */ = { D6A3BC822321F69400FD64D5 /* Account List */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1954,6 +1981,7 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -2129,6 +2157,7 @@
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */, D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */, D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */, D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */, D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */, D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */, D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */,
@ -2159,8 +2188,10 @@
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */, D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */, D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D698F4692BD0799F0054DB14 /* AnnouncementsView.swift in Sources */,
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */, D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */, D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */,
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */,
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */, D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */, D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */, D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */,
@ -2188,6 +2219,7 @@
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */, D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */, D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */,
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */, D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */, D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */,
D61DC84628F498F200B82C6E /* Logging.swift in Sources */, D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
@ -2323,6 +2355,7 @@
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */, D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */,
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */, D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */, D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */,
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */, D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */, D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */, D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
@ -2367,6 +2400,7 @@
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */, D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */,
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */, D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */, D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */,
D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */,
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */, D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */, D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */, D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,

View File

@ -58,7 +58,8 @@ private extension Pachyderm.PushSubscription.Alerts {
followRequest: alerts.contains(.followRequest), followRequest: alerts.contains(.followRequest),
favourite: alerts.contains(.favorite), favourite: alerts.contains(.favorite),
poll: alerts.contains(.poll), poll: alerts.contains(.poll),
update: alerts.contains(.update) update: alerts.contains(.update),
emojiReaction: alerts.contains(.emojiReaction)
) )
} }
} }

View File

@ -0,0 +1,12 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"symbols" : [
{
"filename" : "face.smiling.badge.plus.svg",
"idiom" : "universal"
}
]
}

View File

@ -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

View File

@ -124,12 +124,13 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
// migrate saved data from local store to cloud store // migrate saved data from local store to cloud store
// this can be removed pre-app store release // this can be removed pre-app store release
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! if !FileManager.default.fileExists(atPath: cloudStoreLocation.path) {
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
if FileManager.default.fileExists(atPath: defaultPath.path) {
group.enter() group.enter()
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
let defaultDesc = NSPersistentStoreDescription(url: defaultPath) let defaultDesc = NSPersistentStoreDescription(url: defaultPath)
defaultDesc.configuration = "Default" defaultDesc.configuration = "Default"
defaultDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel) let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
defaultPSC.persistentStoreDescriptions = [defaultDesc] defaultPSC.persistentStoreDescriptions = [defaultDesc]
defaultPSC.loadPersistentStores { _, error in defaultPSC.loadPersistentStores { _, error in

View File

@ -8,26 +8,11 @@
import SwiftUI import SwiftUI
import Combine import Combine
import TuskerPreferences
extension View { extension View {
@MainActor func appGroupedListBackground(container: UIAppearanceContainer.Type) -> some View {
@ViewBuilder self.modifier(AppGroupedListBackground(container: container))
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 appGroupedListRowBackground() -> some View { 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(\.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 { 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 content
.listRowBackground(Color.appGroupedCellBackground) .listRowBackground(Color.appGroupedCellBackground)
} else { } 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()
}
}
}
}

View File

@ -143,3 +143,33 @@ extension UIMutableTraits {
set { self[PureBlackDarkModeTrait.self] = newValue } 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
}
}
}
}

View File

@ -34,7 +34,6 @@ extension StatusSwipeAction {
protocol StatusSwipeActionContainer: UIView { protocol StatusSwipeActionContainer: UIView {
var mastodonController: MastodonController! { get } var mastodonController: MastodonController! { get }
var navigationDelegate: any TuskerNavigationDelegate { get } var navigationDelegate: any TuskerNavigationDelegate { get }
var toastableViewController: ToastableViewController? { get }
var canReblog: Bool { get } var canReblog: Bool { get }

View File

@ -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()
//}

View File

@ -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
}
}
}

View File

@ -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()
//}

View File

@ -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
}
}

View File

@ -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 }
}

View File

@ -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()
//}

View File

@ -12,7 +12,16 @@ import HTMLStreamer
class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { 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.tintColor = UIColor(red: 1, green: 204/255, blue: 0, alpha: 1)
$0.contentMode = .scaleAspectFit $0.contentMode = .scaleAspectFit
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -21,6 +30,10 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
]) ])
} }
private let iconLabel = UILabel().configure {
$0.font = .systemFont(ofSize: 30)
}
private let avatarStack = UIStackView().configure { private let avatarStack = UIStackView().configure {
$0.axis = .horizontal $0.axis = .horizontal
$0.alignment = .fill $0.alignment = .fill
@ -81,6 +94,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
private var group: NotificationGroup! private var group: NotificationGroup!
private var statusID: String! private var statusID: String!
private var fetchCustomEmojiImage: (URL, Task<Void, Never>)?
private var updateTimestampWorkItem: DispatchWorkItem? private var updateTimestampWorkItem: DispatchWorkItem?
deinit { deinit {
@ -90,15 +104,21 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
iconView.translatesAutoresizingMaskIntoConstraints = false iconImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconView) contentView.addSubview(iconImageView)
iconLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconLabel)
vStack.translatesAutoresizingMaskIntoConstraints = false vStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(vStack) contentView.addSubview(vStack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
iconView.topAnchor.constraint(equalTo: vStack.topAnchor), iconImageView.topAnchor.constraint(equalTo: vStack.topAnchor),
iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50), iconImageView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
iconLabel.topAnchor.constraint(equalTo: iconImageView.topAnchor),
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8), iconLabel.bottomAnchor.constraint(equalTo: iconImageView.bottomAnchor),
iconLabel.leadingAnchor.constraint(equalTo: iconImageView.leadingAnchor),
iconLabel.trailingAnchor.constraint(equalTo: iconImageView.trailingAnchor),
vStack.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 8),
vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
@ -116,7 +136,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
} }
func updateUI(group: NotificationGroup) { func updateUI(group: NotificationGroup) {
guard group.kind == .favourite || group.kind == .reblog, guard ActionNotificationGroupCollectionViewCell.canDisplay(group.kind),
let firstNotification = group.notifications.first, let firstNotification = group.notifications.first,
let status = firstNotification.status else { let status = firstNotification.status else {
fatalError() fatalError()
@ -126,9 +146,29 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
switch group.kind { switch group.kind {
case .favourite: case .favourite:
iconView.image = UIImage(systemName: "star.fill") iconImageView.image = UIImage(systemName: "star.fill")
iconLabel.text = ""
fetchCustomEmojiImage?.1.cancel()
case .reblog: 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: default:
fatalError() fatalError()
} }
@ -207,6 +247,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
verb = "Favorited" verb = "Favorited"
case .reblog: case .reblog:
verb = "Reblogged" verb = "Reblogged"
case .emojiReaction(_, _):
verb = "Reacted to"
default: default:
fatalError() fatalError()
} }
@ -252,6 +294,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
str += "Favorited by " str += "Favorited by "
case .reblog: case .reblog:
str += "Reblogged by " str += "Reblogged by "
case .emojiReaction(let emoji, _):
str += "Reacted \(emoji) by "
default: default:
return nil return nil
} }

View File

@ -75,6 +75,17 @@ class NotificationLoadingViewController: UIViewController {
} }
let actionType = notification.kind == .reblog ? StatusActionAccountListViewController.ActionType.reblog : .favorite let actionType = notification.kind == .reblog ? StatusActionAccountListViewController.ActionType.reblog : .favorite
vc = StatusActionAccountListViewController(actionType: actionType, statusID: statusID, statusState: .unknown, accountIDs: [notification.account.id], mastodonController: mastodonController) 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: case .follow:
vc = ProfileViewController(accountID: notification.account.id, mastodonController: mastodonController) vc = ProfileViewController(accountID: notification.account.id, mastodonController: mastodonController)
case .followRequest: case .followRequest:

View File

@ -178,7 +178,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
case .hide: case .hide:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ()) return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
} }
case .favourite, .reblog: case .favourite, .reblog, .emojiReaction:
return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group) return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group)
case .follow: case .follow:
return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group) return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group)
@ -317,7 +317,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
snapshot.deleteItems([.group(group, collapseState, filterState)]) snapshot.deleteItems([.group(group, collapseState, filterState)])
} else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count { } else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count {
let dismissFailed = dismissFailedIndices.sorted().map { notifications[$0] } 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)]) snapshot.deleteItems([.group(group, collapseState, filterState)])
} }
await apply(snapshot, animatingDifferences: true) await apply(snapshot, animatingDifferences: true)
@ -624,8 +624,8 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
let state = collapseState?.copy() ?? .unknown let state = collapseState?.copy() ?? .unknown
selected(status: statusID, state: state) selected(status: statusID, state: state)
} }
case .favourite, .reblog: case .favourite, .reblog, .emojiReaction(_, _):
let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog let type = StatusActionAccountListViewController.ActionType(group.kind)!
let statusID = group.notifications.first!.status!.id let statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id).uniques() let accountIDs = group.notifications.map(\.account.id).uniques()
let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController) let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController)
@ -666,9 +666,9 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
} actionProvider: { _ in } actionProvider: { _ in
UIMenu(children: self.actionsForStatus(status, source: .view(cell), includeStatusButtonActions: group.kind == .poll || group.kind == .update)) 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: { 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 statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id).uniques() let accountIDs = group.notifications.map(\.account.id).uniques()
return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController) return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController)
@ -751,7 +751,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate {
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
case .favourite, .reblog: case .favourite, .reblog, .emojiReaction(_, _):
return [] return []
case .follow, .followRequest: case .follow, .followRequest:
guard group.notifications.count == 1 else { guard group.notifications.count == 1 else {

View File

@ -16,6 +16,22 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
var initialMode: NotificationsMode? 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) { init(initialMode: NotificationsMode? = nil, mastodonController: MastodonController) {
self.initialMode = initialMode self.initialMode = initialMode
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -30,6 +46,8 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
title = Page.all.title title = Page.all.title
tabBarItem.image = UIImage(systemName: "bell.fill") tabBarItem.image = UIImage(systemName: "bell.fill")
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: announcementsButton)
announcementsButton.isHidden = true
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -42,6 +60,14 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
selectMode(initialMode ?? Preferences.shared.defaultNotificationsMode) selectMode(initialMode ?? Preferences.shared.defaultNotificationsMode)
} }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task {
await checkForAnnouncements()
}
}
func selectMode(_ mode: NotificationsMode) { func selectMode(_ mode: NotificationsMode) {
let page: Page let page: Page
switch mode { switch mode {
@ -53,6 +79,61 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
selectPage(page, animated: false) 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 { enum Page: SegmentedPageViewControllerPage {
case all case all
case mentions case mentions

View File

@ -116,10 +116,13 @@ class OnboardingViewController: UINavigationController {
} }
private func tryLogin(to instanceURL: URL, updateStatus: (String) -> Void) async throws { 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 mastodonController = MastodonController(instanceURL: instanceURL, transient: true)
let clientID: String let clientID: String
let clientSecret: String let clientSecret: String
if let clientInfo, clientInfo.url == instanceURL { if let clientInfo, clientInfo.url == instanceURL {
logger.debug("Using client info from previous attempt")
clientID = clientInfo.id clientID = clientInfo.id
clientSecret = clientInfo.secret clientSecret = clientInfo.secret
} else { } else {
@ -127,21 +130,32 @@ class OnboardingViewController: UINavigationController {
do { do {
(clientID, clientSecret) = try await mastodonController.registerApp() (clientID, clientSecret) = try await mastodonController.registerApp()
self.clientInfo = (instanceURL, clientID, clientSecret) self.clientInfo = (instanceURL, clientID, clientSecret)
logger.debug("Obtained client info")
updateStatus("Reticulating Splines") updateStatus("Reticulating Splines")
try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC)
} catch { } catch {
logger.error("Failed to register app: \(String(describing: error), privacy: .public)")
throw Error.registeringApp(error) throw Error.registeringApp(error)
} }
} }
updateStatus("Logging in") 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") updateStatus("Authorizing")
let accessToken: String let accessToken: String
do { do {
accessToken = try await retrying("Getting access token") { accessToken = try await retrying("Getting access token") {
try await mastodonController.authorize(authorizationCode: authCode) try await mastodonController.authorize(authorizationCode: authCode)
} }
logger.debug("Obtained access token")
} catch { } catch {
logger.error("Failed to get access token: \(String(describing: error), privacy: .public)")
throw Error.gettingAccessToken(error) throw Error.gettingAccessToken(error)
} }

View File

@ -74,20 +74,27 @@ private struct PushSubscriptionSettingsView: View {
if mastodonController.instanceFeatures.pushNotificationTypeUpdate { if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
toggle("Edits", alert: .update) 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 // status notifications not supported until we can enable/disable them in the app
} }
} }
.groupBoxStyle(AppBackgroundGroupBoxStyle())
} }
private var allSupportedAlertTypes: PushSubscription.Alerts { 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 { if mastodonController.instanceFeatures.pushNotificationTypeFollowRequest {
alerts.insert(.followRequest) all.insert(.followRequest)
} }
if mastodonController.instanceFeatures.pushNotificationTypeUpdate { 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 { 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 { //#Preview {
// PushSubscriptionView() // PushSubscriptionView()
//} //}

View File

@ -108,7 +108,12 @@ struct PreferencesView: View {
PreferenceSectionLabel(title: "Composing", systemImageName: "pencil", backgroundColor: .blue) PreferenceSectionLabel(title: "Composing", systemImageName: "pencil", backgroundColor: .blue)
} }
NavigationLink(destination: WellnessPrefsView()) { 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()) { NavigationLink(destination: AdvancedPrefsView()) {
PreferenceSectionLabel(title: "Advanced", systemImageName: "gearshape.2.fill", backgroundColor: .gray) PreferenceSectionLabel(title: "Advanced", systemImageName: "gearshape.2.fill", backgroundColor: .gray)

View File

@ -157,7 +157,7 @@ struct ReportView: View {
.appGroupedListRowBackground() .appGroupedListRowBackground()
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.appGroupedListBackground(container: UIHostingController<ReportView>.self, applyBackground: true) .appGroupedListBackground(container: UIHostingController<ReportView>.self)
.alertWithData("Error Reporting", data: $error, actions: { error in .alertWithData("Error Reporting", data: $error, actions: { error in
Button("OK") {} Button("OK") {}
}, message: { error in }, message: { error in

View File

@ -172,6 +172,8 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
return Status.getFavourites(statusID, range: range.withCount(Self.pageSize)) return Status.getFavourites(statusID, range: range.withCount(Self.pageSize))
case .reblog: case .reblog:
return Status.getReblogs(statusID, range: range.withCount(Self.pageSize)) return Status.getReblogs(statusID, range: range.withCount(Self.pageSize))
case .emojiReaction(let name, _):
return Status.getReactions(statusID, emoji: name, range: range.withCount(Self.pageSize))
} }
} }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import WebURL
class StatusActionAccountListViewController: UIViewController { class StatusActionAccountListViewController: UIViewController {
@ -80,6 +81,8 @@ class StatusActionAccountListViewController: UIViewController {
title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title") title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title")
case .reblog: case .reblog:
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title") title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
case .emojiReaction(_, _):
title = "Reacted To By"
} }
view.backgroundColor = .appBackground view.backgroundColor = .appBackground
@ -178,7 +181,22 @@ extension StatusActionAccountListViewController {
extension StatusActionAccountListViewController { extension StatusActionAccountListViewController {
enum ActionType { 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
}
}
} }
} }

View File

@ -15,7 +15,6 @@ import SwiftUI
@MainActor @MainActor
protocol MenuActionProvider: AnyObject { protocol MenuActionProvider: AnyObject {
var navigationDelegate: TuskerNavigationDelegate? { get } var navigationDelegate: TuskerNavigationDelegate? { get }
var toastableViewController: ToastableViewController? { get }
} }
@MainActor @MainActor
@ -34,10 +33,6 @@ extension MenuActionProvider where Self: TuskerNavigationDelegate {
var navigationDelegate: TuskerNavigationDelegate? { self } var navigationDelegate: TuskerNavigationDelegate? { self }
} }
extension MenuActionProvider where Self: ToastableViewController {
var toastableViewController: ToastableViewController? { self }
}
extension MenuActionProvider { extension MenuActionProvider {
private var mastodonController: MastodonController? { navigationDelegate?.apiController } private var mastodonController: MastodonController? { navigationDelegate?.apiController }
@ -459,7 +454,7 @@ extension MenuActionProvider {
} }
private func handleSuccess(title: String) { private func handleSuccess(title: String) {
if let toastable = self.toastableViewController { if let toastable = self.navigationDelegate {
var config = ToastConfiguration(title: title) var config = ToastConfiguration(title: title)
config.systemImageName = "checkmark" config.systemImageName = "checkmark"
config.dismissAutomaticallyAfter = 2 config.dismissAutomaticallyAfter = 2

View File

@ -267,10 +267,6 @@ extension ContentTextView: UITextViewDelegate {
} }
extension ContentTextView: MenuActionProvider { extension ContentTextView: MenuActionProvider {
var toastableViewController: ToastableViewController? {
// todo: pass this down through the text view
nil
}
} }
extension ContentTextView: UIContextMenuInteractionDelegate { extension ContentTextView: UIContextMenuInteractionDelegate {

View File

@ -170,10 +170,6 @@ class ProfileFieldValueView: UIView {
} }
extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider { extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider {
var toastableViewController: ToastableViewController? {
navigationDelegate
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let (hashtag, url) = getHashtagOrURL(), guard let (hashtag, url) = getHashtagOrURL(),
let navigationDelegate else { let navigationDelegate else {

View File

@ -10,7 +10,7 @@ import UIKit
class StatusCollapseButton: UIButton { class StatusCollapseButton: UIButton {
private var interactionBounds: CGRect! private var interactionBounds: CGRect?
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
@ -19,7 +19,7 @@ class StatusCollapseButton: UIButton {
} }
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return interactionBounds.contains(point) return interactionBounds?.contains(point) ?? false
} }
} }

View File

@ -945,7 +945,6 @@ extension TimelineStatusCollectionViewCell: UIPointerInteractionDelegate {
extension TimelineStatusCollectionViewCell: StatusSwipeActionContainer { extension TimelineStatusCollectionViewCell: StatusSwipeActionContainer {
var navigationDelegate: TuskerNavigationDelegate { delegate! } var navigationDelegate: TuskerNavigationDelegate { delegate! }
var toastableViewController: ToastableViewController? { delegate }
var canReblog: Bool { var canReblog: Bool {
reblogButton.isEnabled reblogButton.isEnabled

View File

@ -10,7 +10,7 @@
// https://help.apple.com/xcode/#/dev745c5c974 // https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.2 MARKETING_VERSION = 2024.2
CURRENT_PROJECT_VERSION = 121 CURRENT_PROJECT_VERSION = 122
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev