Compare commits
21 Commits
822c2e0fa2
...
fc391cc18c
Author | SHA1 | Date |
---|---|---|
Shadowfacts | fc391cc18c | |
Shadowfacts | 35b390d3c1 | |
Shadowfacts | b21703f6d9 | |
Shadowfacts | d003098146 | |
Shadowfacts | db7c183d06 | |
Shadowfacts | 7d3c82f4b7 | |
Shadowfacts | 13ec3366d3 | |
Shadowfacts | f9a41fd4f3 | |
Shadowfacts | 2157126332 | |
Shadowfacts | e87dcfe48e | |
Shadowfacts | 566c3d474d | |
Shadowfacts | ca03cf3b08 | |
Shadowfacts | f0e530722f | |
Shadowfacts | dcd1b4ad94 | |
Shadowfacts | 3394c2126c | |
Shadowfacts | 85765928b4 | |
Shadowfacts | f13874ee01 | |
Shadowfacts | bac272a2db | |
Shadowfacts | 48bd957276 | |
Shadowfacts | d4d42e7856 | |
Shadowfacts | 671a8e0cb3 |
19
CHANGELOG.md
19
CHANGELOG.md
|
@ -1,5 +1,24 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2023.5 (85)
|
||||||
|
This build adds support for editing posts and showing edit timestamps and history.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Post editing
|
||||||
|
- Show post edit history
|
||||||
|
- Improve rate limit exceeded error message
|
||||||
|
- Shorten hashtag save/follow action subtitles so they fit in the context menu
|
||||||
|
- Remove Hide/Show Reblogs action for accounts the user isn't following
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix nodeinfo not being fetched on instances with punycode domains
|
||||||
|
- Fix potential crash with interactive push gesture
|
||||||
|
- Fix list timelines opened in new window lacking Edit button
|
||||||
|
- Fix hashtag timelines opened in new window lacking save/follow actions
|
||||||
|
- Fix being able to scroll to top while fast account switcher is active
|
||||||
|
- Fix decoding statuses lacking emojis on Calckey
|
||||||
|
- Fix decoding polls on Calckey
|
||||||
|
|
||||||
## 2023.5 (84)
|
## 2023.5 (84)
|
||||||
Bugfixes:
|
Bugfixes:
|
||||||
- Fix notifications scrolling to top when refreshing
|
- Fix notifications scrolling to top when refreshing
|
||||||
|
|
|
@ -15,20 +15,18 @@ class PostService: ObservableObject {
|
||||||
private let mastodonController: ComposeMastodonContext
|
private let mastodonController: ComposeMastodonContext
|
||||||
private let config: ComposeUIConfig
|
private let config: ComposeUIConfig
|
||||||
private let draft: Draft
|
private let draft: Draft
|
||||||
let totalSteps: Int
|
|
||||||
|
|
||||||
@Published var currentStep = 1
|
@Published var currentStep = 1
|
||||||
|
@Published private(set) var totalSteps = 2
|
||||||
|
|
||||||
init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
|
init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.config = config
|
self.config = config
|
||||||
self.draft = draft
|
self.draft = draft
|
||||||
// 2 steps (request data, then upload) for each attachment
|
|
||||||
self.totalSteps = 2 + (draft.attachments.count * 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func post() async throws {
|
func post() async throws {
|
||||||
guard draft.hasContent else {
|
guard draft.hasContent || draft.editedStatusID != nil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,14 +35,41 @@ class PostService: ObservableObject {
|
||||||
|
|
||||||
let uploadedAttachments = try await uploadAttachments()
|
let uploadedAttachments = try await uploadAttachments()
|
||||||
|
|
||||||
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil
|
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : ""
|
||||||
let sensitive = contentWarning != nil
|
let sensitive = !contentWarning.isEmpty
|
||||||
|
|
||||||
let request = Client.createStatus(
|
let request: Request<Status>
|
||||||
|
|
||||||
|
if let editedStatusID = draft.editedStatusID {
|
||||||
|
if mastodonController.instanceFeatures.needsEditAttachmentsInSeparateRequest {
|
||||||
|
await updateEditedAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
request = Client.editStatus(
|
||||||
|
id: editedStatusID,
|
||||||
|
text: textForPosting(),
|
||||||
|
contentType: config.contentType,
|
||||||
|
spoilerText: contentWarning,
|
||||||
|
sensitive: sensitive,
|
||||||
|
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
|
||||||
|
mediaIDs: uploadedAttachments,
|
||||||
|
mediaAttributes: draft.draftAttachments.compactMap {
|
||||||
|
if let id = $0.editedAttachmentID {
|
||||||
|
return EditStatusMediaAttributes(id: id, description: $0.attachmentDescription, focus: nil)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
poll: draft.poll.map {
|
||||||
|
EditPollParameters(options: $0.pollOptions.map(\.text), expiresIn: Int($0.duration), multiple: $0.multiple)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
request = Client.createStatus(
|
||||||
text: textForPosting(),
|
text: textForPosting(),
|
||||||
contentType: config.contentType,
|
contentType: config.contentType,
|
||||||
inReplyTo: draft.inReplyToID,
|
inReplyTo: draft.inReplyToID,
|
||||||
media: uploadedAttachments,
|
mediaIDs: uploadedAttachments,
|
||||||
sensitive: sensitive,
|
sensitive: sensitive,
|
||||||
spoilerText: contentWarning,
|
spoilerText: contentWarning,
|
||||||
visibility: draft.visibility,
|
visibility: draft.visibility,
|
||||||
|
@ -54,18 +79,32 @@ class PostService: ObservableObject {
|
||||||
pollMultiple: draft.poll?.multiple,
|
pollMultiple: draft.poll?.multiple,
|
||||||
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
|
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (_, _) = try await mastodonController.run(request)
|
let (status, _) = try await mastodonController.run(request)
|
||||||
currentStep += 1
|
currentStep += 1
|
||||||
|
mastodonController.storeCreatedStatus(status)
|
||||||
} catch let error as Client.Error {
|
} catch let error as Client.Error {
|
||||||
throw Error.posting(error)
|
throw Error.posting(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uploadAttachments() async throws -> [Attachment] {
|
private func uploadAttachments() async throws -> [String] {
|
||||||
var attachments: [Attachment] = []
|
// 2 steps (request data, then upload) for each attachment
|
||||||
|
self.totalSteps += 2 * draft.attachments.count
|
||||||
|
|
||||||
|
var attachments: [String] = []
|
||||||
attachments.reserveCapacity(draft.attachments.count)
|
attachments.reserveCapacity(draft.attachments.count)
|
||||||
for (index, attachment) in draft.draftAttachments.enumerated() {
|
for (index, attachment) in draft.draftAttachments.enumerated() {
|
||||||
|
// if this attachment already exists and is being edited, we don't do anything
|
||||||
|
// edits to the description are handled as part of the edit status request
|
||||||
|
if let editedAttachmentID = attachment.editedAttachmentID {
|
||||||
|
attachments.append(editedAttachmentID)
|
||||||
|
currentStep += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
let data: Data
|
let data: Data
|
||||||
let utType: UTType
|
let utType: UTType
|
||||||
do {
|
do {
|
||||||
|
@ -76,7 +115,7 @@ class PostService: ObservableObject {
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
|
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
|
||||||
attachments.append(uploaded)
|
attachments.append(uploaded.id)
|
||||||
currentStep += 1
|
currentStep += 1
|
||||||
} catch let error as Client.Error {
|
} catch let error as Client.Error {
|
||||||
throw Error.attachmentUpload(index: index, cause: error)
|
throw Error.attachmentUpload(index: index, cause: error)
|
||||||
|
@ -117,6 +156,17 @@ class PostService: ObservableObject {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// only needed for akkoma, not used on regular mastodon
|
||||||
|
private func updateEditedAttachments() async {
|
||||||
|
for attachment in draft.draftAttachments {
|
||||||
|
guard let id = attachment.editedAttachmentID else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let req = Client.updateAttachment(id: id, description: attachment.attachmentDescription, focus: nil)
|
||||||
|
_ = try? await mastodonController.run(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum Error: Swift.Error, LocalizedError {
|
enum Error: Swift.Error, LocalizedError {
|
||||||
case attachmentData(index: Int, cause: AttachmentData.Error)
|
case attachmentData(index: Int, cause: AttachmentData.Error)
|
||||||
case attachmentUpload(index: Int, cause: Client.Error)
|
case attachmentUpload(index: Int, cause: Client.Error)
|
||||||
|
|
|
@ -23,4 +23,6 @@ public protocol ComposeMastodonContext {
|
||||||
func cachedRelationship(for accountID: String) -> RelationshipProtocol?
|
func cachedRelationship(for accountID: String) -> RelationshipProtocol?
|
||||||
@MainActor
|
@MainActor
|
||||||
func searchCachedHashtags(query: String) -> [Hashtag]
|
func searchCachedHashtags(query: String) -> [Hashtag]
|
||||||
|
|
||||||
|
func storeCreatedStatus(_ status: Status)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ class AttachmentRowController: ViewController {
|
||||||
init(parent: ComposeController, attachment: DraftAttachment) {
|
init(parent: ComposeController, attachment: DraftAttachment) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
self.thumbnailController = AttachmentThumbnailController(attachment: attachment)
|
self.thumbnailController = AttachmentThumbnailController(attachment: attachment, parent: parent)
|
||||||
|
|
||||||
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
|
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
|
||||||
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
|
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
|
||||||
|
|
|
@ -11,14 +11,16 @@ import Photos
|
||||||
import TuskerComponents
|
import TuskerComponents
|
||||||
|
|
||||||
class AttachmentThumbnailController: ViewController {
|
class AttachmentThumbnailController: ViewController {
|
||||||
|
unowned let parent: ComposeController
|
||||||
let attachment: DraftAttachment
|
let attachment: DraftAttachment
|
||||||
|
|
||||||
@Published private var image: UIImage?
|
@Published private var image: UIImage?
|
||||||
@Published private var gifController: GIFController?
|
@Published private var gifController: GIFController?
|
||||||
@Published private var fullSize: Bool = false
|
@Published private var fullSize: Bool = false
|
||||||
|
|
||||||
init(attachment: DraftAttachment) {
|
init(attachment: DraftAttachment, parent: ComposeController) {
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
|
self.parent = parent
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadImageIfNecessary(fullSize: Bool) {
|
func loadImageIfNecessary(fullSize: Bool) {
|
||||||
|
@ -28,6 +30,24 @@ class AttachmentThumbnailController: ViewController {
|
||||||
self.fullSize = fullSize
|
self.fullSize = fullSize
|
||||||
|
|
||||||
switch attachment.data {
|
switch attachment.data {
|
||||||
|
case .editing(_, let kind, let url):
|
||||||
|
switch kind {
|
||||||
|
case .image:
|
||||||
|
Task { @MainActor in
|
||||||
|
self.image = await parent.fetchAttachment(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .video, .gifv:
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
|
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||||
|
self.image = UIImage(cgImage: cgImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .audio, .unknown:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case .asset(let id):
|
case .asset(let id):
|
||||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
||||||
return
|
return
|
||||||
|
|
|
@ -12,6 +12,7 @@ import TuskerComponents
|
||||||
import MatchedGeometryPresentation
|
import MatchedGeometryPresentation
|
||||||
|
|
||||||
public final class ComposeController: ViewController {
|
public final class ComposeController: ViewController {
|
||||||
|
public typealias FetchAttachment = (URL) async -> UIImage?
|
||||||
public typealias FetchStatus = (String) -> (any StatusProtocol)?
|
public typealias FetchStatus = (String) -> (any StatusProtocol)?
|
||||||
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
|
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
|
||||||
public typealias CurrentAccountContainerView = (AnyView) -> AnyView
|
public typealias CurrentAccountContainerView = (AnyView) -> AnyView
|
||||||
|
@ -22,6 +23,7 @@ public final class ComposeController: ViewController {
|
||||||
@Published public var config: ComposeUIConfig
|
@Published public var config: ComposeUIConfig
|
||||||
@Published public var mastodonController: ComposeMastodonContext
|
@Published public var mastodonController: ComposeMastodonContext
|
||||||
let fetchAvatar: AvatarImageView.FetchAvatar
|
let fetchAvatar: AvatarImageView.FetchAvatar
|
||||||
|
let fetchAttachment: FetchAttachment
|
||||||
let fetchStatus: FetchStatus
|
let fetchStatus: FetchStatus
|
||||||
let displayNameLabel: DisplayNameLabel
|
let displayNameLabel: DisplayNameLabel
|
||||||
let currentAccountContainerView: CurrentAccountContainerView
|
let currentAccountContainerView: CurrentAccountContainerView
|
||||||
|
@ -64,11 +66,12 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
var postButtonEnabled: Bool {
|
var postButtonEnabled: Bool {
|
||||||
draft.hasContent
|
draft.editedStatusID != nil ||
|
||||||
|
(draft.hasContent
|
||||||
&& charactersRemaining >= 0
|
&& charactersRemaining >= 0
|
||||||
&& !isPosting
|
&& !isPosting
|
||||||
&& attachmentsListController.isValid
|
&& attachmentsListController.isValid
|
||||||
&& isPollValid
|
&& isPollValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPollValid: Bool {
|
private var isPollValid: Bool {
|
||||||
|
@ -79,6 +82,8 @@ public final class ComposeController: ViewController {
|
||||||
if let id = draft.inReplyToID,
|
if let id = draft.inReplyToID,
|
||||||
let status = fetchStatus(id) {
|
let status = fetchStatus(id) {
|
||||||
return "Reply to @\(status.account.acct)"
|
return "Reply to @\(status.account.acct)"
|
||||||
|
} else if draft.editedStatusID != nil {
|
||||||
|
return "Edit Post"
|
||||||
} else {
|
} else {
|
||||||
return "New Post"
|
return "New Post"
|
||||||
}
|
}
|
||||||
|
@ -89,6 +94,7 @@ public final class ComposeController: ViewController {
|
||||||
config: ComposeUIConfig,
|
config: ComposeUIConfig,
|
||||||
mastodonController: ComposeMastodonContext,
|
mastodonController: ComposeMastodonContext,
|
||||||
fetchAvatar: @escaping AvatarImageView.FetchAvatar,
|
fetchAvatar: @escaping AvatarImageView.FetchAvatar,
|
||||||
|
fetchAttachment: @escaping FetchAttachment,
|
||||||
fetchStatus: @escaping FetchStatus,
|
fetchStatus: @escaping FetchStatus,
|
||||||
displayNameLabel: @escaping DisplayNameLabel,
|
displayNameLabel: @escaping DisplayNameLabel,
|
||||||
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
|
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
|
||||||
|
@ -99,6 +105,7 @@ public final class ComposeController: ViewController {
|
||||||
self.config = config
|
self.config = config
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.fetchAvatar = fetchAvatar
|
self.fetchAvatar = fetchAvatar
|
||||||
|
self.fetchAttachment = fetchAttachment
|
||||||
self.fetchStatus = fetchStatus
|
self.fetchStatus = fetchStatus
|
||||||
self.displayNameLabel = displayNameLabel
|
self.displayNameLabel = displayNameLabel
|
||||||
self.currentAccountContainerView = currentAccountContainerView
|
self.currentAccountContainerView = currentAccountContainerView
|
||||||
|
@ -170,7 +177,7 @@ public final class ComposeController: ViewController {
|
||||||
|
|
||||||
func postStatus() {
|
func postStatus() {
|
||||||
guard !isPosting,
|
guard !isPosting,
|
||||||
draft.hasContent else {
|
draft.editedStatusID != nil || draft.hasContent else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -396,20 +403,27 @@ public final class ComposeController: ViewController {
|
||||||
.font(.system(size: 17, weight: .regular))
|
.font(.system(size: 17, weight: .regular))
|
||||||
}
|
}
|
||||||
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
||||||
|
// edit drafts can't be saved
|
||||||
|
if draft.editedStatusID == nil {
|
||||||
Button(action: { controller.cancel(deleteDraft: false) }) {
|
Button(action: { controller.cancel(deleteDraft: false) }) {
|
||||||
Text("Save Draft")
|
Text("Save Draft")
|
||||||
}
|
}
|
||||||
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||||
Text("Delete Draft")
|
Text("Delete Draft")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||||
|
Text("Cancel Edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var postButton: some View {
|
private var postButton: some View {
|
||||||
if draft.hasContent || !controller.config.allowSwitchingDrafts {
|
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
||||||
Button(action: controller.postStatus) {
|
Button(action: controller.postStatus) {
|
||||||
Text("Post")
|
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
.disabled(!controller.postButtonEnabled)
|
.disabled(!controller.postButtonEnabled)
|
||||||
|
|
|
@ -119,10 +119,18 @@ class DraftsController: ViewController {
|
||||||
|
|
||||||
private struct DraftRow: View {
|
private struct DraftRow: View {
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: Draft
|
||||||
|
@EnvironmentObject private var controller: DraftsController
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
if draft.editedStatusID != nil {
|
||||||
|
// shouldn't happen unless the app crashed/was killed during an edit
|
||||||
|
Text("Edit")
|
||||||
|
.font(.body.bold())
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
if draft.contentWarningEnabled {
|
if draft.contentWarningEnabled {
|
||||||
Text(draft.contentWarning)
|
Text(draft.contentWarning)
|
||||||
.font(.body.bold())
|
.font(.body.bold())
|
||||||
|
@ -134,7 +142,7 @@ private struct DraftRow: View {
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(draft.draftAttachments) { attachment in
|
ForEach(draft.draftAttachments) { attachment in
|
||||||
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment) })
|
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment, parent: controller.parent) })
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
|
|
|
@ -59,10 +59,12 @@ class ToolbarController: ViewController {
|
||||||
MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly)
|
MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly)
|
||||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||||
.padding(.horizontal, -8)
|
.padding(.horizontal, -8)
|
||||||
|
.disabled(draft.editedStatusID != nil)
|
||||||
|
|
||||||
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
||||||
localOnlyPicker
|
localOnlyPicker
|
||||||
.padding(.horizontal, -8)
|
.padding(.horizontal, -8)
|
||||||
|
.disabled(draft.editedStatusID != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let currentInput = composeController.currentInput,
|
if let currentInput = composeController.currentInput,
|
||||||
|
|
|
@ -23,6 +23,7 @@ public class Draft: NSManagedObject, Identifiable {
|
||||||
@NSManaged public var accountID: String
|
@NSManaged public var accountID: String
|
||||||
@NSManaged public var contentWarning: String
|
@NSManaged public var contentWarning: String
|
||||||
@NSManaged public var contentWarningEnabled: Bool
|
@NSManaged public var contentWarningEnabled: Bool
|
||||||
|
@NSManaged public var editedStatusID: String?
|
||||||
@NSManaged public var id: UUID
|
@NSManaged public var id: UUID
|
||||||
@NSManaged public var initialText: String
|
@NSManaged public var initialText: String
|
||||||
@NSManaged public var inReplyToID: String?
|
@NSManaged public var inReplyToID: String?
|
||||||
|
|
|
@ -10,6 +10,7 @@ import PencilKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import Photos
|
import Photos
|
||||||
import InstanceFeatures
|
import InstanceFeatures
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
private let decoder = PropertyListDecoder()
|
private let decoder = PropertyListDecoder()
|
||||||
private let encoder = PropertyListEncoder()
|
private let encoder = PropertyListEncoder()
|
||||||
|
@ -20,6 +21,9 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
|
||||||
@NSManaged internal var assetID: String?
|
@NSManaged internal var assetID: String?
|
||||||
@NSManaged public var attachmentDescription: String
|
@NSManaged public var attachmentDescription: String
|
||||||
@NSManaged internal private(set) var drawingData: Data?
|
@NSManaged internal private(set) var drawingData: Data?
|
||||||
|
@NSManaged public var editedAttachmentID: String?
|
||||||
|
@NSManaged private var editedAttachmentKindString: String?
|
||||||
|
@NSManaged public var editedAttachmentURL: URL?
|
||||||
@NSManaged public var fileURL: URL?
|
@NSManaged public var fileURL: URL?
|
||||||
@NSManaged internal var fileType: String?
|
@NSManaged internal var fileType: String?
|
||||||
@NSManaged public var id: UUID
|
@NSManaged public var id: UUID
|
||||||
|
@ -41,7 +45,9 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public var data: AttachmentData {
|
public var data: AttachmentData {
|
||||||
if let assetID {
|
if let editedAttachmentID {
|
||||||
|
return .editing(editedAttachmentID, editedAttachmentKind!, editedAttachmentURL!)
|
||||||
|
} else if let assetID {
|
||||||
return .asset(assetID)
|
return .asset(assetID)
|
||||||
} else if let drawing {
|
} else if let drawing {
|
||||||
return .drawing(drawing)
|
return .drawing(drawing)
|
||||||
|
@ -52,10 +58,20 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var editedAttachmentKind: Attachment.Kind? {
|
||||||
|
get {
|
||||||
|
editedAttachmentKindString.flatMap(Attachment.Kind.init(rawValue:))
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
editedAttachmentKindString = newValue?.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public enum AttachmentData {
|
public enum AttachmentData {
|
||||||
case asset(String)
|
case asset(String)
|
||||||
case drawing(PKDrawing)
|
case drawing(PKDrawing)
|
||||||
case file(URL, UTType)
|
case file(URL, UTType)
|
||||||
|
case editing(String, Attachment.Kind, URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func prepareForDeletion() {
|
public override func prepareForDeletion() {
|
||||||
|
@ -69,7 +85,18 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
|
||||||
|
|
||||||
extension DraftAttachment {
|
extension DraftAttachment {
|
||||||
var type: AttachmentType {
|
var type: AttachmentType {
|
||||||
if let assetID {
|
if let editedAttachmentKind {
|
||||||
|
switch editedAttachmentKind {
|
||||||
|
case .image:
|
||||||
|
return .image
|
||||||
|
case .video:
|
||||||
|
return .video
|
||||||
|
case .gifv:
|
||||||
|
return .video
|
||||||
|
case .audio, .unknown:
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
} else if let assetID {
|
||||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
|
||||||
return .unknown
|
return .unknown
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
<attribute name="accountID" attributeType="String"/>
|
<attribute name="accountID" attributeType="String"/>
|
||||||
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
|
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="editedStatusID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
<attribute name="initialText" attributeType="String"/>
|
<attribute name="initialText" attributeType="String"/>
|
||||||
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
||||||
|
@ -19,6 +20,9 @@
|
||||||
<attribute name="assetID" optional="YES" attributeType="String"/>
|
<attribute name="assetID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="attachmentDescription" attributeType="String" defaultValueString=""/>
|
<attribute name="attachmentDescription" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="drawingData" optional="YES" attributeType="Binary"/>
|
<attribute name="drawingData" optional="YES" attributeType="Binary"/>
|
||||||
|
<attribute name="editedAttachmentID" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="editedAttachmentKindString" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="editedAttachmentURL" optional="YES" attributeType="URI"/>
|
||||||
<attribute name="fileType" optional="YES" attributeType="String"/>
|
<attribute name="fileType" optional="YES" attributeType="String"/>
|
||||||
<attribute name="fileURL" optional="YES" attributeType="URI"/>
|
<attribute name="fileURL" optional="YES" attributeType="URI"/>
|
||||||
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
|
|
@ -96,6 +96,67 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
return draft
|
return draft
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func createEditDraft(
|
||||||
|
accountID: String,
|
||||||
|
source: StatusSource,
|
||||||
|
inReplyToID: String?,
|
||||||
|
visibility: Visibility,
|
||||||
|
localOnly: Bool,
|
||||||
|
attachments: [Attachment],
|
||||||
|
poll: Pachyderm.Poll?
|
||||||
|
) -> Draft {
|
||||||
|
let draft = Draft(context: viewContext)
|
||||||
|
draft.accountID = accountID
|
||||||
|
draft.editedStatusID = source.id
|
||||||
|
draft.text = source.text
|
||||||
|
draft.initialText = source.text
|
||||||
|
draft.contentWarning = source.spoilerText
|
||||||
|
draft.contentWarningEnabled = !source.spoilerText.isEmpty
|
||||||
|
draft.inReplyToID = inReplyToID
|
||||||
|
draft.visibility = visibility
|
||||||
|
draft.localOnly = localOnly
|
||||||
|
for attachment in attachments {
|
||||||
|
createEditDraftAttachment(attachment, in: draft)
|
||||||
|
}
|
||||||
|
if let existingPoll = poll {
|
||||||
|
let poll = Poll(context: viewContext)
|
||||||
|
poll.draft = draft
|
||||||
|
draft.poll = poll
|
||||||
|
if let expiresAt = existingPoll.expiresAt,
|
||||||
|
!existingPoll.effectiveExpired {
|
||||||
|
poll.duration = PollController.Duration.allCases.max(by: {
|
||||||
|
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
|
||||||
|
})!.timeInterval
|
||||||
|
} else {
|
||||||
|
poll.duration = PollController.Duration.oneDay.timeInterval
|
||||||
|
}
|
||||||
|
poll.multiple = existingPoll.multiple
|
||||||
|
// rmeove default empty options
|
||||||
|
for opt in poll.pollOptions {
|
||||||
|
viewContext.delete(opt)
|
||||||
|
}
|
||||||
|
for existingOpt in existingPoll.options {
|
||||||
|
let opt = PollOption(context: viewContext)
|
||||||
|
opt.poll = poll
|
||||||
|
poll.options.add(opt)
|
||||||
|
opt.text = existingOpt.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createEditDraftAttachment(_ attachment: Attachment, in draft: Draft) {
|
||||||
|
let draftAttachment = DraftAttachment(context: viewContext)
|
||||||
|
draftAttachment.id = UUID()
|
||||||
|
draftAttachment.attachmentDescription = attachment.description ?? ""
|
||||||
|
draftAttachment.editedAttachmentID = attachment.id
|
||||||
|
draftAttachment.editedAttachmentKind = attachment.kind
|
||||||
|
draftAttachment.editedAttachmentURL = attachment.url
|
||||||
|
draftAttachment.draft = draft
|
||||||
|
draft.attachments.add(draftAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
||||||
guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||||
return
|
return
|
||||||
|
|
|
@ -17,7 +17,7 @@ public class InstanceFeatures: ObservableObject {
|
||||||
private let _featuresUpdated = PassthroughSubject<Void, Never>()
|
private let _featuresUpdated = PassthroughSubject<Void, Never>()
|
||||||
public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated }
|
public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated }
|
||||||
|
|
||||||
@Published private var instanceType: InstanceType = .mastodon(.vanilla, nil)
|
@Published @_spi(InstanceType) public private(set) var instanceType: InstanceType = .mastodon(.vanilla, nil)
|
||||||
@Published public private(set) var maxStatusChars = 500
|
@Published public private(set) var maxStatusChars = 500
|
||||||
@Published public private(set) var charsReservedPerURL = 23
|
@Published public private(set) var charsReservedPerURL = 23
|
||||||
@Published public private(set) var maxPollOptionChars: Int?
|
@Published public private(set) var maxPollOptionChars: Int?
|
||||||
|
@ -115,13 +115,25 @@ public class InstanceFeatures: ObservableObject {
|
||||||
instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil))
|
instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var editStatuses: Bool {
|
||||||
|
// todo: does this require a particular akkoma version?
|
||||||
|
hasMastodonVersion(3, 5, 0) || instanceType.isPleroma(.akkoma(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var needsEditAttachmentsInSeparateRequest: Bool {
|
||||||
|
instanceType.isPleroma(.akkoma(nil))
|
||||||
|
}
|
||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update(instance: Instance, nodeInfo: NodeInfo?) {
|
public func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
let ver = instance.version.lowercased()
|
let ver = instance.version.lowercased()
|
||||||
|
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo
|
||||||
if ver.contains("glitch") {
|
if ver.contains("glitch") {
|
||||||
instanceType = .mastodon(.glitch, Version(string: ver))
|
instanceType = .mastodon(.glitch, Version(string: ver))
|
||||||
|
} else if nodeInfo?.software.name == "mastodon" {
|
||||||
|
instanceType = .mastodon(.vanilla, Version(string: ver))
|
||||||
} else if nodeInfo?.software.name == "hometown" {
|
} else if nodeInfo?.software.name == "hometown" {
|
||||||
var mastoVersion: Version?
|
var mastoVersion: Version?
|
||||||
var hometownVersion: Version?
|
var hometownVersion: Version?
|
||||||
|
@ -157,6 +169,10 @@ public class InstanceFeatures: ObservableObject {
|
||||||
instanceType = .pleroma(.akkoma(akkomaVersion))
|
instanceType = .pleroma(.akkoma(akkomaVersion))
|
||||||
} else if ver.contains("pixelfed") {
|
} else if ver.contains("pixelfed") {
|
||||||
instanceType = .pixelfed
|
instanceType = .pixelfed
|
||||||
|
} else if nodeInfo?.software.name == "gotosocial" {
|
||||||
|
instanceType = .gotosocial
|
||||||
|
} else if ver.contains("calckey") {
|
||||||
|
instanceType = .calckey(nodeInfo?.software.version)
|
||||||
} else {
|
} else {
|
||||||
instanceType = .mastodon(.vanilla, Version(string: ver))
|
instanceType = .mastodon(.vanilla, Version(string: ver))
|
||||||
}
|
}
|
||||||
|
@ -190,10 +206,12 @@ public class InstanceFeatures: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceFeatures {
|
extension InstanceFeatures {
|
||||||
enum InstanceType {
|
@_spi(InstanceType) public enum InstanceType {
|
||||||
case mastodon(MastodonType, Version?)
|
case mastodon(MastodonType, Version?)
|
||||||
case pleroma(PleromaType)
|
case pleroma(PleromaType)
|
||||||
case pixelfed
|
case pixelfed
|
||||||
|
case gotosocial
|
||||||
|
case calckey(String?)
|
||||||
|
|
||||||
var isMastodon: Bool {
|
var isMastodon: Bool {
|
||||||
if case .mastodon(_, _) = self {
|
if case .mastodon(_, _) = self {
|
||||||
|
@ -230,7 +248,7 @@ extension InstanceFeatures {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MastodonType {
|
@_spi(InstanceType) public enum MastodonType {
|
||||||
case vanilla
|
case vanilla
|
||||||
case hometown(Version?)
|
case hometown(Version?)
|
||||||
case glitch
|
case glitch
|
||||||
|
@ -249,7 +267,7 @@ extension InstanceFeatures {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PleromaType {
|
@_spi(InstanceType) public enum PleromaType {
|
||||||
case vanilla(Version?)
|
case vanilla(Version?)
|
||||||
case akkoma(Version?)
|
case akkoma(Version?)
|
||||||
|
|
||||||
|
@ -267,7 +285,7 @@ extension InstanceFeatures {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceFeatures {
|
extension InstanceFeatures {
|
||||||
struct Version: Equatable, Comparable {
|
@_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible {
|
||||||
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
|
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
|
||||||
|
|
||||||
let major: Int
|
let major: Int
|
||||||
|
@ -298,11 +316,15 @@ extension InstanceFeatures {
|
||||||
self.patch = patch
|
self.patch = patch
|
||||||
}
|
}
|
||||||
|
|
||||||
static func ==(lhs: Version, rhs: Version) -> Bool {
|
public var description: String {
|
||||||
|
"\(major).\(minor).\(patch)"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func ==(lhs: Version, rhs: Version) -> Bool {
|
||||||
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
|
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
|
||||||
}
|
}
|
||||||
|
|
||||||
static func < (lhs: InstanceFeatures.Version, rhs: InstanceFeatures.Version) -> Bool {
|
public static func < (lhs: InstanceFeatures.Version, rhs: InstanceFeatures.Version) -> Bool {
|
||||||
if lhs.major < rhs.major {
|
if lhs.major < rhs.major {
|
||||||
return true
|
return true
|
||||||
} else if lhs.major > rhs.major {
|
} else if lhs.major > rhs.major {
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The base Mastodon API client.
|
The base Mastodon API client.
|
||||||
|
@ -186,9 +187,9 @@ public class Client {
|
||||||
|
|
||||||
case let .success(wellKnown, _):
|
case let .success(wellKnown, _):
|
||||||
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
||||||
let components = URLComponents(string: url.href),
|
let href = WebURL(url.href),
|
||||||
components.host == self.baseURL.host {
|
href.host == WebURL(self.baseURL)?.host {
|
||||||
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: components.path))
|
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
||||||
self.run(nodeInfo, completion: completion)
|
self.run(nodeInfo, completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -314,6 +315,13 @@ public class Client {
|
||||||
], attachment))
|
], attachment))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func updateAttachment(id: String, description: String?, focus: (Float, Float)?) -> Request<Attachment> {
|
||||||
|
return Request(method: .put, path: "/api/v1/media/\(id)", body: FormDataBody([
|
||||||
|
"description" => description,
|
||||||
|
"focus" => focus
|
||||||
|
], nil))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Mutes
|
// MARK: - Mutes
|
||||||
public static func getMutes(range: RequestRange) -> Request<[Account]> {
|
public static func getMutes(range: RequestRange) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
||||||
|
@ -381,7 +389,7 @@ public class Client {
|
||||||
public static func createStatus(text: String,
|
public static func createStatus(text: String,
|
||||||
contentType: StatusContentType = .plain,
|
contentType: StatusContentType = .plain,
|
||||||
inReplyTo: String? = nil,
|
inReplyTo: String? = nil,
|
||||||
media: [Attachment]? = nil,
|
mediaIDs: [String]? = nil,
|
||||||
sensitive: Bool? = nil,
|
sensitive: Bool? = nil,
|
||||||
spoilerText: String? = nil,
|
spoilerText: String? = nil,
|
||||||
visibility: Visibility? = nil,
|
visibility: Visibility? = nil,
|
||||||
|
@ -401,7 +409,32 @@ public class Client {
|
||||||
"poll[expires_in]" => pollExpiresIn,
|
"poll[expires_in]" => pollExpiresIn,
|
||||||
"poll[multiple]" => pollMultiple,
|
"poll[multiple]" => pollMultiple,
|
||||||
"local_only" => localOnly,
|
"local_only" => localOnly,
|
||||||
] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions))
|
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func editStatus(
|
||||||
|
id: String,
|
||||||
|
text: String,
|
||||||
|
contentType: StatusContentType = .plain,
|
||||||
|
spoilerText: String?,
|
||||||
|
sensitive: Bool,
|
||||||
|
language: String?,
|
||||||
|
mediaIDs: [String],
|
||||||
|
mediaAttributes: [EditStatusMediaAttributes],
|
||||||
|
poll: EditPollParameters?
|
||||||
|
) -> Request<Status> {
|
||||||
|
let params = EditStatusParameters(
|
||||||
|
id: id,
|
||||||
|
text: text,
|
||||||
|
contentType: contentType,
|
||||||
|
spoilerText: spoilerText,
|
||||||
|
sensitive: sensitive,
|
||||||
|
language: language,
|
||||||
|
mediaIDs: mediaIDs,
|
||||||
|
mediaAttributes: mediaAttributes,
|
||||||
|
poll: poll
|
||||||
|
)
|
||||||
|
return Request(method: .put, path: "/api/v1/statuses/\(id)", body: JsonBody(params))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timelines
|
// MARK: - Timelines
|
||||||
|
@ -482,6 +515,12 @@ public class Client {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Hashtags
|
||||||
|
/// Requires Mastodon 4.0.0+
|
||||||
|
public static func getHashtag(name: String) -> Request<Hashtag> {
|
||||||
|
return Request(method: .get, path: "/api/v1/tags/\(name)")
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Client {
|
extension Client {
|
||||||
|
@ -507,6 +546,8 @@ extension Client {
|
||||||
// todo: support more status codes
|
// todo: support more status codes
|
||||||
case .unexpectedStatus(413):
|
case .unexpectedStatus(413):
|
||||||
return "HTTP 413: Payload Too Large"
|
return "HTTP 413: Payload Too Large"
|
||||||
|
case .unexpectedStatus(429):
|
||||||
|
return "HTTP 429: Rate Limit Exceeded"
|
||||||
case .unexpectedStatus(let code):
|
case .unexpectedStatus(let code):
|
||||||
return "HTTP Code \(code)"
|
return "HTTP Code \(code)"
|
||||||
case .invalidRequest:
|
case .invalidRequest:
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
//
|
||||||
|
// EditStatusParameters.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/10/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct EditStatusParameters: Encodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let text: String
|
||||||
|
let contentType: StatusContentType
|
||||||
|
let spoilerText: String?
|
||||||
|
let sensitive: Bool
|
||||||
|
let language: String?
|
||||||
|
let mediaIDs: [String]
|
||||||
|
let mediaAttributes: [EditStatusMediaAttributes]
|
||||||
|
let poll: EditPollParameters?
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(self.id, forKey: .id)
|
||||||
|
try container.encode(self.text, forKey: .text)
|
||||||
|
try container.encode(self.contentType, forKey: .contentType)
|
||||||
|
try container.encodeIfPresent(self.spoilerText, forKey: .spoilerText)
|
||||||
|
try container.encode(self.sensitive, forKey: .sensitive)
|
||||||
|
try container.encodeIfPresent(self.language, forKey: .language)
|
||||||
|
try container.encode(self.mediaIDs, forKey: .mediaIDs)
|
||||||
|
try container.encode(self.mediaAttributes, forKey: .mediaAttributes)
|
||||||
|
try container.encodeIfPresent(self.poll, forKey: .poll)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case text = "status"
|
||||||
|
case contentType = "content_type"
|
||||||
|
case spoilerText = "spoiler_text"
|
||||||
|
case sensitive
|
||||||
|
case language
|
||||||
|
case mediaIDs = "media_ids"
|
||||||
|
case mediaAttributes = "media_attributes"
|
||||||
|
case poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EditPollParameters: Encodable, Sendable {
|
||||||
|
let options: [String]
|
||||||
|
let expiresIn: Int
|
||||||
|
let multiple: Bool
|
||||||
|
|
||||||
|
public init(options: [String], expiresIn: Int, multiple: Bool) {
|
||||||
|
self.options = options
|
||||||
|
self.expiresIn = expiresIn
|
||||||
|
self.multiple = multiple
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(self.options, forKey: .options)
|
||||||
|
try container.encode(self.expiresIn, forKey: .expiresIn)
|
||||||
|
try container.encode(self.multiple, forKey: .multiple)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case options
|
||||||
|
case expiresIn = "expires_in"
|
||||||
|
case multiple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EditStatusMediaAttributes: Encodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let description: String
|
||||||
|
let focus: (Float, Float)?
|
||||||
|
|
||||||
|
public init(id: String, description: String, focus: (Float, Float)?) {
|
||||||
|
self.id = id
|
||||||
|
self.description = description
|
||||||
|
self.focus = focus
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(description, forKey: .description)
|
||||||
|
if let focus {
|
||||||
|
try container.encode("\(focus.0),\(focus.1)", forKey: .focus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case description
|
||||||
|
case focus
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,6 +24,13 @@ public struct Mention: Codable, Sendable {
|
||||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(url: WebURL, username: String, acct: String, id: String) {
|
||||||
|
self.url = url
|
||||||
|
self.username = username
|
||||||
|
self.acct = acct
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case url
|
case url
|
||||||
case username
|
case username
|
||||||
|
|
|
@ -24,6 +24,20 @@ public struct Poll: Codable, Sendable {
|
||||||
expired || (expiresAt != nil && expiresAt! < Date())
|
expired || (expiresAt != nil && expiresAt! < Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
|
self.expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt)
|
||||||
|
self.expired = try container.decode(Bool.self, forKey: .expired)
|
||||||
|
self.multiple = try container.decode(Bool.self, forKey: .multiple)
|
||||||
|
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
|
||||||
|
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
|
||||||
|
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
|
||||||
|
self.ownVotes = try container.decodeIfPresent([Int].self, forKey: .ownVotes)
|
||||||
|
self.options = try container.decode([Poll.Option].self, forKey: .options)
|
||||||
|
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> {
|
public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> {
|
||||||
return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil))
|
return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil))
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
public let poll: Poll?
|
public let poll: Poll?
|
||||||
// Hometown, Glitch only
|
// Hometown, Glitch only
|
||||||
public let localOnly: Bool?
|
public let localOnly: Bool?
|
||||||
|
public let editedAt: Date?
|
||||||
|
|
||||||
public var applicationName: String? { application?.name }
|
public var applicationName: String? { application?.name }
|
||||||
|
|
||||||
|
@ -64,7 +65,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
|
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
|
||||||
self.content = try container.decode(String.self, forKey: .content)
|
self.content = try container.decode(String.self, forKey: .content)
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
|
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
||||||
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
|
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
|
||||||
self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount)
|
self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount)
|
||||||
self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged)
|
self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged)
|
||||||
|
@ -92,6 +93,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked)
|
self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked)
|
||||||
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
||||||
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
||||||
|
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||||
|
@ -163,6 +165,14 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func source(_ statusID: String) -> Request<StatusSource> {
|
||||||
|
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/source")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func history(_ statusID: String) -> Request<[StatusEdit]> {
|
||||||
|
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
|
||||||
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case uri
|
case uri
|
||||||
|
@ -193,6 +203,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
case card
|
case card
|
||||||
case poll
|
case poll
|
||||||
case localOnly = "local_only"
|
case localOnly = "local_only"
|
||||||
|
case editedAt = "edited_at"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// StatusEdit.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/11/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct StatusEdit: Decodable {
|
||||||
|
public let content: String
|
||||||
|
public let spoilerText: String
|
||||||
|
public let sensitive: Bool
|
||||||
|
public let createdAt: Date
|
||||||
|
public let account: Account
|
||||||
|
public let poll: Poll?
|
||||||
|
public let attachments: [Attachment]
|
||||||
|
public let emojis: [Emoji]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case content
|
||||||
|
case spoilerText = "spoiler_text"
|
||||||
|
case sensitive
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case account = "account"
|
||||||
|
case poll
|
||||||
|
case attachments = "media_attachments"
|
||||||
|
case emojis
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Poll: Decodable {
|
||||||
|
public let options: [Option]
|
||||||
|
|
||||||
|
public struct Option: Decodable {
|
||||||
|
public let title: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// StatusSource.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/10/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct StatusSource: Decodable {
|
||||||
|
public let id: String
|
||||||
|
public let text: String
|
||||||
|
public let spoilerText: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case text
|
||||||
|
case spoilerText = "spoiler_text"
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ import Foundation
|
||||||
public final class CollapseState: Sendable {
|
public final class CollapseState: Sendable {
|
||||||
public var collapsible: Bool?
|
public var collapsible: Bool?
|
||||||
public var collapsed: Bool?
|
public var collapsed: Bool?
|
||||||
|
public var statusPropertiesHash: Int?
|
||||||
|
|
||||||
public var unknown: Bool {
|
public var unknown: Bool {
|
||||||
collapsible == nil || collapsed == nil
|
collapsible == nil || collapsed == nil
|
||||||
|
|
|
@ -36,6 +36,7 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
|
||||||
config: ComposeUIConfig(),
|
config: ComposeUIConfig(),
|
||||||
mastodonController: mastodonContext,
|
mastodonController: mastodonContext,
|
||||||
fetchAvatar: Self.fetchAvatar,
|
fetchAvatar: Self.fetchAvatar,
|
||||||
|
fetchAttachment: { _ in fatalError("edits aren't allowed in share sheet, can't fetch existing attachment") },
|
||||||
fetchStatus: { _ in fatalError("replies aren't allowed in share sheet") },
|
fetchStatus: { _ in fatalError("replies aren't allowed in share sheet") },
|
||||||
displayNameLabel: { account, style, _ in AnyView(Text(account.displayName).font(.system(style))) },
|
displayNameLabel: { account, style, _ in AnyView(Text(account.displayName).font(.system(style))) },
|
||||||
currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) },
|
currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) },
|
||||||
|
|
|
@ -89,4 +89,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
||||||
func searchCachedHashtags(query: String) -> [Hashtag] {
|
func searchCachedHashtags(query: String) -> [Hashtag] {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func storeCreatedStatus(_ status: Status) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -296,6 +296,11 @@
|
||||||
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
|
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
|
||||||
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; };
|
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; };
|
||||||
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; };
|
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; };
|
||||||
|
D6D79F262A0C8D2700AB2315 /* FetchStatusSourceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */; };
|
||||||
|
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */; };
|
||||||
|
D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */; };
|
||||||
|
D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */; };
|
||||||
|
D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */; };
|
||||||
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
|
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
|
||||||
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
|
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
|
||||||
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
|
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
|
||||||
|
@ -690,6 +695,11 @@
|
||||||
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; };
|
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; };
|
||||||
D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; };
|
D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; };
|
||||||
D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = "<group>"; };
|
D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = "<group>"; };
|
||||||
|
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchStatusSourceService.swift; sourceTree = "<group>"; };
|
||||||
|
D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditContentTextView.swift; sourceTree = "<group>"; };
|
||||||
|
D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditPollView.swift; sourceTree = "<group>"; };
|
||||||
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
|
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
|
||||||
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
|
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
|
||||||
|
@ -977,6 +987,7 @@
|
||||||
D65B4B522971F6E300DABDFB /* Report */,
|
D65B4B522971F6E300DABDFB /* Report */,
|
||||||
D6BC9DD8232D8BCA002CA326 /* Search */,
|
D6BC9DD8232D8BCA002CA326 /* Search */,
|
||||||
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */,
|
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */,
|
||||||
|
D6D79F272A0D595D00AB2315 /* Status Edit History */,
|
||||||
D641C781213DD7DD004B4513 /* Timeline */,
|
D641C781213DD7DD004B4513 /* Timeline */,
|
||||||
D6C693FA2162FE5D007D6A6D /* Utilities */,
|
D6C693FA2162FE5D007D6A6D /* Utilities */,
|
||||||
);
|
);
|
||||||
|
@ -1530,6 +1541,17 @@
|
||||||
path = Gestures;
|
path = Gestures;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D6D79F272A0D595D00AB2315 /* Status Edit History */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */,
|
||||||
|
D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */,
|
||||||
|
D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */,
|
||||||
|
D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */,
|
||||||
|
);
|
||||||
|
path = "Status Edit History";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = {
|
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1596,6 +1618,7 @@
|
||||||
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */,
|
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */,
|
||||||
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
|
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
|
||||||
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
|
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
|
||||||
|
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
|
||||||
);
|
);
|
||||||
path = API;
|
path = API;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1949,6 +1972,7 @@
|
||||||
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
||||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
||||||
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
|
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
|
||||||
|
D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */,
|
||||||
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
|
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
|
||||||
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */,
|
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */,
|
||||||
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
|
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
|
||||||
|
@ -2025,6 +2049,7 @@
|
||||||
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
|
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
|
||||||
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
|
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
|
||||||
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
|
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
|
||||||
|
D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */,
|
||||||
D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */,
|
D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */,
|
||||||
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
|
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
|
||||||
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
|
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
|
||||||
|
@ -2040,6 +2065,7 @@
|
||||||
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
|
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
|
||||||
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */,
|
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */,
|
||||||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
|
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
|
||||||
|
D6D79F262A0C8D2700AB2315 /* FetchStatusSourceService.swift in Sources */,
|
||||||
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
|
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
|
||||||
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */,
|
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */,
|
||||||
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
|
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
|
||||||
|
@ -2118,6 +2144,7 @@
|
||||||
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
||||||
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
|
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
|
||||||
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
||||||
|
D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */,
|
||||||
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
|
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
|
||||||
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
|
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
|
||||||
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
|
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
|
||||||
|
@ -2156,6 +2183,7 @@
|
||||||
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
|
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
|
||||||
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
||||||
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
||||||
|
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */,
|
||||||
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
||||||
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */,
|
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */,
|
||||||
|
@ -2342,7 +2370,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 85;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2408,7 +2436,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 85;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||||
|
@ -2434,7 +2462,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 85;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
|
@ -2463,7 +2491,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 85;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
|
@ -2492,7 +2520,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 85;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
|
@ -2647,7 +2675,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 85;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2678,7 +2706,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 85;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2784,7 +2812,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 85;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||||
|
@ -2810,7 +2838,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 85;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
//
|
||||||
|
// FetchStatusSourceService.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/10/23.
|
||||||
|
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class FetchStatusSourceService {
|
||||||
|
let statusID: String
|
||||||
|
let delegate: TuskerNavigationDelegate
|
||||||
|
|
||||||
|
init(statusID: String, delegate: TuskerNavigationDelegate) {
|
||||||
|
self.statusID = statusID
|
||||||
|
self.delegate = delegate
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() async throws -> StatusSource {
|
||||||
|
// todo: show loading indicator if this takes longer than a certain time
|
||||||
|
return try await delegate.apiController.run(Status.source(statusID)).0
|
||||||
|
}
|
||||||
|
}
|
|
@ -506,6 +506,20 @@ class MastodonController: ObservableObject {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createDraft(editing status: StatusMO, source: StatusSource) -> Draft {
|
||||||
|
precondition(status.id == source.id)
|
||||||
|
let draft = DraftsPersistentContainer.shared.createEditDraft(
|
||||||
|
accountID: accountInfo!.id,
|
||||||
|
source: source,
|
||||||
|
inReplyToID: status.inReplyToID,
|
||||||
|
visibility: status.visibility,
|
||||||
|
localOnly: status.localOnly,
|
||||||
|
attachments: status.attachments,
|
||||||
|
poll: status.poll
|
||||||
|
)
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
|
private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
|
|
|
@ -12,12 +12,12 @@ import Pachyderm
|
||||||
@MainActor
|
@MainActor
|
||||||
class ToggleFollowHashtagService {
|
class ToggleFollowHashtagService {
|
||||||
|
|
||||||
private let hashtag: Hashtag
|
private let hashtagName: String
|
||||||
private let mastodonController: MastodonController
|
private let mastodonController: MastodonController
|
||||||
private let presenter: any TuskerNavigationDelegate
|
private let presenter: any TuskerNavigationDelegate
|
||||||
|
|
||||||
init(hashtag: Hashtag, presenter: any TuskerNavigationDelegate) {
|
init(hashtagName: String, presenter: any TuskerNavigationDelegate) {
|
||||||
self.hashtag = hashtag
|
self.hashtagName = hashtagName
|
||||||
self.mastodonController = presenter.apiController
|
self.mastodonController = presenter.apiController
|
||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
}
|
}
|
||||||
|
@ -25,9 +25,9 @@ class ToggleFollowHashtagService {
|
||||||
func toggleFollow() async {
|
func toggleFollow() async {
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
var config: ToastConfiguration
|
var config: ToastConfiguration
|
||||||
if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtag.name }) {
|
if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtagName }) {
|
||||||
do {
|
do {
|
||||||
let req = Hashtag.unfollow(name: hashtag.name)
|
let req = Hashtag.unfollow(name: hashtagName)
|
||||||
_ = try await mastodonController.run(req)
|
_ = try await mastodonController.run(req)
|
||||||
|
|
||||||
context.delete(existing)
|
context.delete(existing)
|
||||||
|
@ -44,7 +44,7 @@ class ToggleFollowHashtagService {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
let req = Hashtag.follow(name: hashtag.name)
|
let req = Hashtag.follow(name: hashtagName)
|
||||||
let (hashtag, _) = try await mastodonController.run(req)
|
let (hashtag, _) = try await mastodonController.run(req)
|
||||||
|
|
||||||
_ = FollowedHashtag(hashtag: hashtag, context: context)
|
_ = FollowedHashtag(hashtag: hashtag, context: context)
|
||||||
|
|
|
@ -116,7 +116,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
let objClazz = clazz as AnyObject as? NSObjectProtocol,
|
let objClazz = clazz as AnyObject as? NSObjectProtocol,
|
||||||
objClazz.responds(to: Selector(("id"))),
|
objClazz.responds(to: Selector(("id"))),
|
||||||
let id = objClazz.perform(Selector(("id"))).takeUnretainedValue() as? String {
|
let id = objClazz.perform(Selector(("id"))).takeUnretainedValue() as? String {
|
||||||
logger.info("Initialized Sentry with installation/user ID: \(id)")
|
logger.info("Initialized Sentry with installation/user ID: \(id, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,12 @@ public final class AccountMO: NSManagedObject, AccountProtocol {
|
||||||
return NSFetchRequest<AccountMO>(entityName: "Account")
|
return NSFetchRequest<AccountMO>(entityName: "Account")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest(url: URL) -> NSFetchRequest<AccountMO> {
|
||||||
|
let req = NSFetchRequest<AccountMO>(entityName: "Account")
|
||||||
|
req.predicate = NSPredicate(format: "url = %@", url as NSURL)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
@NSManaged public var acct: String
|
@NSManaged public var acct: String
|
||||||
@NSManaged public var avatar: URL?
|
@NSManaged public var avatar: URL?
|
||||||
@NSManaged public var botCD: Bool
|
@NSManaged public var botCD: Bool
|
||||||
|
|
|
@ -31,6 +31,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
|
||||||
@NSManaged public var cardData: Data?
|
@NSManaged public var cardData: Data?
|
||||||
@NSManaged public var content: String
|
@NSManaged public var content: String
|
||||||
@NSManaged public var createdAt: Date
|
@NSManaged public var createdAt: Date
|
||||||
|
@NSManaged public var editedAt: Date?
|
||||||
@NSManaged private var emojisData: Data?
|
@NSManaged private var emojisData: Data?
|
||||||
@NSManaged public var favourited: Bool
|
@NSManaged public var favourited: Bool
|
||||||
@NSManaged public var favouritesCount: Int
|
@NSManaged public var favouritesCount: Int
|
||||||
|
@ -113,6 +114,7 @@ extension StatusMO {
|
||||||
self.card = status.card
|
self.card = status.card
|
||||||
self.content = status.content
|
self.content = status.content
|
||||||
self.createdAt = status.createdAt
|
self.createdAt = status.createdAt
|
||||||
|
self.editedAt = status.editedAt
|
||||||
self.emojis = status.emojis
|
self.emojis = status.emojis
|
||||||
self.favourited = status.favourited ?? false
|
self.favourited = status.favourited ?? false
|
||||||
self.favouritesCount = status.favouritesCount
|
self.favouritesCount = status.favouritesCount
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22C65" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
||||||
<attribute name="acct" attributeType="String"/>
|
<attribute name="acct" attributeType="String"/>
|
||||||
<attribute name="avatar" optional="YES" attributeType="URI"/>
|
<attribute name="avatar" optional="YES" attributeType="URI"/>
|
||||||
|
@ -89,6 +89,7 @@
|
||||||
<attribute name="cardData" optional="YES" attributeType="Binary"/>
|
<attribute name="cardData" optional="YES" attributeType="Binary"/>
|
||||||
<attribute name="content" attributeType="String"/>
|
<attribute name="content" attributeType="String"/>
|
||||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="editedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="emojisData" attributeType="Binary" customClassName="[Data]"/>
|
<attribute name="emojisData" attributeType="Binary" customClassName="[Data]"/>
|
||||||
<attribute name="favourited" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="favourited" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
@ -97,7 +98,7 @@
|
||||||
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
|
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="localOnly" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
<attribute name="mentionsData" attributeType="Binary"/>
|
<attribute name="mentionsData" attributeType="Binary"/>
|
||||||
<attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="pinnedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="pinnedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
|
|
@ -9,12 +9,24 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
|
protocol CollapseStateResolving {
|
||||||
|
var spoilerText: String { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatusMO: CollapseStateResolving {}
|
||||||
|
extension StatusEdit: CollapseStateResolving {}
|
||||||
|
|
||||||
extension CollapseState {
|
extension CollapseState {
|
||||||
|
|
||||||
func resolveFor(status: StatusMO, height: CGFloat, textLength: Int? = nil) {
|
func resolveFor(status: CollapseStateResolving, height: () -> CGFloat, textLength: Int? = nil) -> Bool {
|
||||||
|
lazy var newHash = hashStatusProperties(status: status)
|
||||||
|
guard unknown || statusPropertiesHash != newHash else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
let longEnoughToCollapse: Bool
|
let longEnoughToCollapse: Bool
|
||||||
if Preferences.shared.collapseLongPosts,
|
if Preferences.shared.collapseLongPosts,
|
||||||
height > 600 || (textLength != nil && textLength! > 500) {
|
height() > 600 || (textLength != nil && textLength! > 500) {
|
||||||
longEnoughToCollapse = true
|
longEnoughToCollapse = true
|
||||||
} else {
|
} else {
|
||||||
longEnoughToCollapse = false
|
longEnoughToCollapse = false
|
||||||
|
@ -39,6 +51,12 @@ extension CollapseState {
|
||||||
self.collapsible = contentWarningCollapsible || longEnoughToCollapse
|
self.collapsible = contentWarningCollapsible || longEnoughToCollapse
|
||||||
// use ?? instead of || because the content warnig pref takes priority over length
|
// use ?? instead of || because the content warnig pref takes priority over length
|
||||||
self.collapsed = collapseDueToContentWarning ?? longEnoughToCollapse
|
self.collapsed = collapseDueToContentWarning ?? longEnoughToCollapse
|
||||||
|
self.statusPropertiesHash = newHash
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func hashStatusProperties(status: CollapseStateResolving) -> Int {
|
||||||
|
status.spoilerText.hashValue
|
||||||
|
}
|
||||||
|
|
|
@ -114,10 +114,8 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
|
||||||
let (status, _) = try await container.mastodonController.run(request)
|
let (status, _) = try await container.mastodonController.run(request)
|
||||||
container.mastodonController.persistentContainer.addOrUpdate(status: status)
|
container.mastodonController.persistentContainer.addOrUpdate(status: status)
|
||||||
} catch {
|
} catch {
|
||||||
if let toastable = container.toastableViewController {
|
let config = ToastConfiguration(from: error, with: "Error \(bookmarked ? "Unb" : "B")ookmarking", in: container.navigationDelegate, retryAction: nil)
|
||||||
let config = ToastConfiguration(from: error, with: "Error \(bookmarked ? "Unb" : "B")ookmarking", in: toastable, retryAction: nil)
|
container.navigationDelegate.showToast(configuration: config, animated: true)
|
||||||
toastable.showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -106,7 +106,17 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
|
||||||
|
|
||||||
private func timelineViewController(for timeline: Timeline, mastodonController: MastodonController) -> UIViewController {
|
private func timelineViewController(for timeline: Timeline, mastodonController: MastodonController) -> UIViewController {
|
||||||
switch timeline {
|
switch timeline {
|
||||||
// todo: list/hashtag controllers need whole objects which must be fetched asynchronously
|
case .tag(hashtag: let name):
|
||||||
|
return HashtagTimelineViewController(forNamed: name, mastodonController: mastodonController)
|
||||||
|
|
||||||
|
case .list(id: let id):
|
||||||
|
let req = ListMO.fetchRequest(id: id)
|
||||||
|
if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first {
|
||||||
|
return ListTimelineViewController(for: List(id: id, title: list.title), mastodonController: mastodonController)
|
||||||
|
} else {
|
||||||
|
return TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return TimelineViewController(for: timeline, mastodonController: mastodonController)
|
return TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||||
}
|
}
|
||||||
|
|
|
@ -238,7 +238,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createAppUI() -> TuskerRootViewController {
|
func createAppUI() -> AccountSwitchableViewController {
|
||||||
let mastodonController = window!.windowScene!.session.mastodonController!
|
let mastodonController = window!.windowScene!.session.mastodonController!
|
||||||
mastodonController.initialize()
|
mastodonController.initialize()
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
||||||
config: ComposeUIConfig(),
|
config: ComposeUIConfig(),
|
||||||
mastodonController: mastodonController,
|
mastodonController: mastodonController,
|
||||||
fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 },
|
fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 },
|
||||||
|
fetchAttachment: { @MainActor in await ImageCache.attachments.get($0).1 },
|
||||||
fetchStatus: { mastodonController.persistentContainer.status(for: $0) },
|
fetchStatus: { mastodonController.persistentContainer.status(for: $0) },
|
||||||
displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) },
|
displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) },
|
||||||
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
|
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
|
||||||
|
@ -210,6 +211,10 @@ extension MastodonController: ComposeMastodonContext {
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func storeCreatedStatus(_ status: Status) {
|
||||||
|
persistentContainer.addOrUpdate(status: status)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ComposeHostingController: PHPickerViewControllerDelegate {
|
extension ComposeHostingController: PHPickerViewControllerDelegate {
|
||||||
|
|
|
@ -11,14 +11,18 @@ import ScreenCorners
|
||||||
import UserAccounts
|
import UserAccounts
|
||||||
import ComposeUI
|
import ComposeUI
|
||||||
|
|
||||||
|
protocol AccountSwitchableViewController: TuskerRootViewController {
|
||||||
|
var isFastAccountSwitcherActive: Bool { get }
|
||||||
|
}
|
||||||
|
|
||||||
class AccountSwitchingContainerViewController: UIViewController {
|
class AccountSwitchingContainerViewController: UIViewController {
|
||||||
|
|
||||||
private var currentAccountID: String
|
private var currentAccountID: String
|
||||||
private(set) var root: TuskerRootViewController
|
private(set) var root: AccountSwitchableViewController
|
||||||
|
|
||||||
private var userActivities: [String: NSUserActivity] = [:]
|
private var userActivities: [String: NSUserActivity] = [:]
|
||||||
|
|
||||||
init(root: TuskerRootViewController, for account: UserAccountInfo) {
|
init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
|
||||||
self.currentAccountID = account.id
|
self.currentAccountID = account.id
|
||||||
self.root = root
|
self.root = root
|
||||||
|
|
||||||
|
@ -35,7 +39,7 @@ class AccountSwitchingContainerViewController: UIViewController {
|
||||||
embedChild(root)
|
embedChild(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setRoot(_ newRoot: TuskerRootViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
|
func setRoot(_ newRoot: AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
|
||||||
let oldRoot = self.root
|
let oldRoot = self.root
|
||||||
if direction == .none {
|
if direction == .none {
|
||||||
oldRoot.removeViewAndController()
|
oldRoot.removeViewAndController()
|
||||||
|
@ -148,10 +152,13 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
||||||
|
|
||||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
// TODO: check if fast account switcher is being presented?
|
if root.isFastAccountSwitcherActive {
|
||||||
|
return .stop
|
||||||
|
} else {
|
||||||
return root.handleStatusBarTapped(xPosition: xPosition)
|
return root.handleStatusBarTapped(xPosition: xPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension AccountSwitchingContainerViewController: BackgroundableViewController {
|
extension AccountSwitchingContainerViewController: BackgroundableViewController {
|
||||||
func sceneDidEnterBackground() {
|
func sceneDidEnterBackground() {
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Duckable
|
||||||
import ComposeUI
|
import ComposeUI
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
extension DuckableContainerViewController: TuskerRootViewController {
|
extension DuckableContainerViewController: AccountSwitchableViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
var activity = (child as? TuskerRootViewController)?.stateRestorationActivity()
|
var activity = (child as? TuskerRootViewController)?.stateRestorationActivity()
|
||||||
if let compose = duckedViewController as? ComposeHostingController,
|
if let compose = duckedViewController as? ComposeHostingController,
|
||||||
|
@ -52,4 +52,8 @@ extension DuckableContainerViewController: TuskerRootViewController {
|
||||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||||
(child as? TuskerRootViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
|
(child as? TuskerRootViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isFastAccountSwitcherActive: Bool {
|
||||||
|
(child as? AccountSwitchableViewController)?.isFastAccountSwitcherActive ?? false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -127,6 +127,11 @@ class MainSidebarViewController: UIViewController {
|
||||||
itemLastSelectedTimestamps[item] = Date()
|
itemLastSelectedTimestamps[item] = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasItem(_ item: Item) -> Bool {
|
||||||
|
loadViewIfNeeded()
|
||||||
|
return dataSource.snapshot().itemIdentifiers.contains(item)
|
||||||
|
}
|
||||||
|
|
||||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in
|
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in
|
||||||
var config = cell.defaultContentConfiguration()
|
var config = cell.defaultContentConfiguration()
|
||||||
|
@ -212,10 +217,10 @@ class MainSidebarViewController: UIViewController {
|
||||||
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
|
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
|
||||||
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? []
|
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? []
|
||||||
var items = saved.map {
|
var items = saved.map {
|
||||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
Item.savedHashtag($0.name)
|
||||||
}
|
}
|
||||||
for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
|
for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
|
||||||
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
|
items.append(.savedHashtag(followed.name))
|
||||||
}
|
}
|
||||||
items = items.uniques()
|
items = items.uniques()
|
||||||
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||||
|
@ -315,8 +320,8 @@ class MainSidebarViewController: UIViewController {
|
||||||
return UserActivityManager.myProfileActivity(accountID: id)
|
return UserActivityManager.myProfileActivity(accountID: id)
|
||||||
case let .list(list):
|
case let .list(list):
|
||||||
return UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: id)
|
return UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: id)
|
||||||
case let .savedHashtag(tag):
|
case let .savedHashtag(name):
|
||||||
return UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: id)
|
return UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: name), accountID: id)
|
||||||
case .savedInstance(_):
|
case .savedInstance(_):
|
||||||
// todo: show timeline activity doesn't work for public timelines
|
// todo: show timeline activity doesn't work for public timelines
|
||||||
return nil
|
return nil
|
||||||
|
@ -347,7 +352,7 @@ extension MainSidebarViewController {
|
||||||
case tab(MainTabBarViewController.Tab)
|
case tab(MainTabBarViewController.Tab)
|
||||||
case explore, bookmarks, favorites
|
case explore, bookmarks, favorites
|
||||||
case listsHeader, list(List), addList
|
case listsHeader, list(List), addList
|
||||||
case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag
|
case savedHashtagsHeader, savedHashtag(String), addSavedHashtag
|
||||||
case savedInstancesHeader, savedInstance(URL), addSavedInstance
|
case savedInstancesHeader, savedInstance(URL), addSavedInstance
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
|
@ -368,8 +373,8 @@ extension MainSidebarViewController {
|
||||||
return "New List..."
|
return "New List..."
|
||||||
case .savedHashtagsHeader:
|
case .savedHashtagsHeader:
|
||||||
return "Hashtags"
|
return "Hashtags"
|
||||||
case let .savedHashtag(hashtag):
|
case let .savedHashtag(name):
|
||||||
return hashtag.name
|
return name
|
||||||
case .addSavedHashtag:
|
case .addSavedHashtag:
|
||||||
return "Add Hashtag..."
|
return "Add Hashtag..."
|
||||||
case .savedInstancesHeader:
|
case .savedInstancesHeader:
|
||||||
|
|
|
@ -339,8 +339,8 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
exploreItem = .favorites
|
exploreItem = .favorites
|
||||||
case let listVC as ListTimelineViewController:
|
case let listVC as ListTimelineViewController:
|
||||||
exploreItem = .list(listVC.list)
|
exploreItem = .list(listVC.list)
|
||||||
case let hashtagVC as HashtagTimelineViewController where hashtagVC.isHashtagSaved:
|
case let hashtagVC as HashtagTimelineViewController where sidebar.hasItem(.savedHashtag(hashtagVC.hashtagName)):
|
||||||
exploreItem = .savedHashtag(hashtagVC.hashtag)
|
exploreItem = .savedHashtag(hashtagVC.hashtagName)
|
||||||
case let instanceVC as InstanceTimelineViewController:
|
case let instanceVC as InstanceTimelineViewController:
|
||||||
exploreItem = .savedInstance(instanceVC.instanceURL)
|
exploreItem = .savedInstance(instanceVC.instanceURL)
|
||||||
case is TrendsViewController:
|
case is TrendsViewController:
|
||||||
|
@ -428,8 +428,8 @@ fileprivate extension MainSidebarViewController.Item {
|
||||||
return FavoritesViewController(mastodonController: mastodonController)
|
return FavoritesViewController(mastodonController: mastodonController)
|
||||||
case let .list(list):
|
case let .list(list):
|
||||||
return ListTimelineViewController(for: list, mastodonController: mastodonController)
|
return ListTimelineViewController(for: list, mastodonController: mastodonController)
|
||||||
case let .savedHashtag(hashtag):
|
case let .savedHashtag(name):
|
||||||
return HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController)
|
return HashtagTimelineViewController(forNamed: name, mastodonController: mastodonController)
|
||||||
case let .savedInstance(url):
|
case let .savedInstance(url):
|
||||||
return InstanceTimelineViewController(for: url, parentMastodonController: mastodonController)
|
return InstanceTimelineViewController(for: url, parentMastodonController: mastodonController)
|
||||||
case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
|
case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
|
||||||
|
@ -610,3 +610,15 @@ extension MainSplitViewController: FastAccountSwitcherViewControllerDelegate {
|
||||||
return cellRect.contains(point)
|
return cellRect.contains(point)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MainSplitViewController: AccountSwitchableViewController {
|
||||||
|
var isFastAccountSwitcherActive: Bool {
|
||||||
|
if isCollapsed {
|
||||||
|
return tabBarViewController.isFastAccountSwitcherActive
|
||||||
|
} else if let fastAccountSwitcher {
|
||||||
|
return !fastAccountSwitcher.view.isHidden
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -363,3 +363,13 @@ extension MainTabBarViewController: BackgroundableViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MainTabBarViewController: AccountSwitchableViewController {
|
||||||
|
var isFastAccountSwitcherActive: Bool {
|
||||||
|
if let fastAccountSwitcher {
|
||||||
|
return !fastAccountSwitcher.view.isHidden
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -241,12 +241,12 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
|
||||||
} catch let error as Client.Error {
|
} catch let error as Client.Error {
|
||||||
acceptButton.isEnabled = true
|
acceptButton.isEnabled = true
|
||||||
rejectButton.isEnabled = true
|
rejectButton.isEnabled = true
|
||||||
if let toastable = delegate?.toastableViewController {
|
if let delegate = delegate {
|
||||||
let config = ToastConfiguration(from: error, with: "Rejecting Follow", in: toastable) { [weak self] toast in
|
let config = ToastConfiguration(from: error, with: "Rejecting Follow", in: delegate) { [weak self] toast in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
self?.rejectButtonPressed()
|
self?.rejectButtonPressed()
|
||||||
}
|
}
|
||||||
toastable.showToast(configuration: config, animated: true)
|
delegate.showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -268,12 +268,12 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
|
||||||
acceptButton.isEnabled = true
|
acceptButton.isEnabled = true
|
||||||
rejectButton.isEnabled = true
|
rejectButton.isEnabled = true
|
||||||
|
|
||||||
if let toastable = delegate?.toastableViewController {
|
if let delegate = delegate {
|
||||||
let config = ToastConfiguration(from: error, with: "Accepting Follow", in: toastable) { [weak self] toast in
|
let config = ToastConfiguration(from: error, with: "Accepting Follow", in: delegate) { [weak self] toast in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
self?.acceptButtonPressed()
|
self?.acceptButtonPressed()
|
||||||
}
|
}
|
||||||
toastable.showToast(configuration: config, animated: true)
|
delegate.showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
self.controller = TimelineLikeController(delegate: self)
|
self.controller = TimelineLikeController(delegate: self, ownerType: String(describing: self))
|
||||||
|
|
||||||
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications"))
|
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -132,7 +132,7 @@ class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
|
||||||
contentLabel.text = try! doc.text()
|
contentLabel.text = try! doc.text()
|
||||||
|
|
||||||
pollView.mastodonController = mastodonController
|
pollView.mastodonController = mastodonController
|
||||||
pollView.toastableViewController = delegate
|
pollView.delegate = delegate
|
||||||
pollView.updateUI(status: status, poll: poll)
|
pollView.updateUI(status: status, poll: poll)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,7 +45,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
self.controller = TimelineLikeController(delegate: self)
|
self.controller = TimelineLikeController(delegate: self, ownerType: String(describing: self))
|
||||||
|
|
||||||
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile"))
|
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -193,3 +193,7 @@ extension StatusActionAccountListViewController: ToastableViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension StatusActionAccountListViewController: TuskerNavigationDelegate {
|
||||||
|
nonisolated var apiController: MastodonController! { mastodonController }
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,165 @@
|
||||||
|
//
|
||||||
|
// StatusEditCollectionViewCell.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/11/23.
|
||||||
|
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol StatusEditCollectionViewCellDelegate: AnyObject, TuskerNavigationDelegate {
|
||||||
|
func statusEditCellNeedsReconfigure(_ cell: StatusEditCollectionViewCell, animated: Bool, completion: (() -> Void)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatusEditCollectionViewCell: UICollectionViewListCell {
|
||||||
|
|
||||||
|
private lazy var contentVStack = UIStackView(arrangedSubviews: [
|
||||||
|
timestampLabel,
|
||||||
|
contentWarningLabel,
|
||||||
|
collapseButton,
|
||||||
|
contentContainer,
|
||||||
|
]).configure {
|
||||||
|
$0.axis = .vertical
|
||||||
|
$0.spacing = 4
|
||||||
|
$0.alignment = .fill
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var timestampLabel = UILabel().configure {
|
||||||
|
$0.textColor = .secondaryLabel
|
||||||
|
$0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
|
||||||
|
.traits: [
|
||||||
|
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
|
||||||
|
]
|
||||||
|
]), size: 0)
|
||||||
|
$0.adjustsFontForContentSizeCategory = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var contentWarningLabel = EmojiLabel().configure {
|
||||||
|
$0.numberOfLines = 0
|
||||||
|
$0.textColor = .secondaryLabel
|
||||||
|
$0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
|
||||||
|
.traits: [
|
||||||
|
UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold.rawValue
|
||||||
|
]
|
||||||
|
]), size: 0)
|
||||||
|
$0.adjustsFontForContentSizeCategory = true
|
||||||
|
$0.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||||
|
$0.isUserInteractionEnabled = true
|
||||||
|
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(collapseButtonPressed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var collapseButton = StatusCollapseButton(configuration: {
|
||||||
|
var config = UIButton.Configuration.filled()
|
||||||
|
config.image = UIImage(systemName: "chevron.down")
|
||||||
|
return config
|
||||||
|
}()).configure {
|
||||||
|
$0.tintAdjustmentMode = .normal
|
||||||
|
$0.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||||
|
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let contentContainer = StatusContentContainer<StatusEditContentTextView, StatusEditPollView>(useTopSpacer: false).configure {
|
||||||
|
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
|
||||||
|
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
||||||
|
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
||||||
|
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||||
|
}
|
||||||
|
|
||||||
|
weak var delegate: StatusEditCollectionViewCellDelegate?
|
||||||
|
private var mastodonController: MastodonController! { delegate?.apiController }
|
||||||
|
|
||||||
|
private var edit: StatusEdit!
|
||||||
|
private var statusState: CollapseState!
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
contentVStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(contentVStack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
contentVStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
|
||||||
|
contentVStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6),
|
||||||
|
contentVStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
|
contentVStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: accessibility
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: Configure UI
|
||||||
|
|
||||||
|
func updateUI(edit: StatusEdit, state: CollapseState, index: Int) {
|
||||||
|
self.edit = edit
|
||||||
|
self.statusState = state
|
||||||
|
|
||||||
|
timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt)
|
||||||
|
|
||||||
|
contentContainer.contentTextView.setTextFrom(edit: edit, index: index)
|
||||||
|
contentContainer.contentTextView.navigationDelegate = delegate
|
||||||
|
contentContainer.attachmentsView.delegate = self
|
||||||
|
contentContainer.attachmentsView.updateUI(attachments: edit.attachments)
|
||||||
|
contentContainer.pollView.isHidden = edit.poll == nil
|
||||||
|
contentContainer.pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
|
||||||
|
contentContainer.cardView.isHidden = true
|
||||||
|
|
||||||
|
contentWarningLabel.text = edit.spoilerText
|
||||||
|
contentWarningLabel.isHidden = edit.spoilerText.isEmpty
|
||||||
|
if !contentWarningLabel.isHidden {
|
||||||
|
contentWarningLabel.setEmojis(edit.emojis, identifier: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = state.resolveFor(status: edit, height: {
|
||||||
|
layoutIfNeeded()
|
||||||
|
return contentContainer.visibleSubviewHeight
|
||||||
|
})
|
||||||
|
collapseButton.isHidden = !state.collapsible!
|
||||||
|
contentContainer.setCollapsed(state.collapsed!)
|
||||||
|
if state.collapsed! {
|
||||||
|
contentContainer.alpha = 0
|
||||||
|
// TODO: is this accessing the image view before the button's been laid out?
|
||||||
|
collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: 0)
|
||||||
|
collapseButton.accessibilityLabel = NSLocalizedString("Expand Status", comment: "expand status button accessibility label")
|
||||||
|
} else {
|
||||||
|
contentContainer.alpha = 1
|
||||||
|
collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: .pi)
|
||||||
|
collapseButton.accessibilityLabel = NSLocalizedString("Collapse Status", comment: "collapse status button accessibility label")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Interaction
|
||||||
|
|
||||||
|
@objc private func collapseButtonPressed() {
|
||||||
|
statusState.collapsed!.toggle()
|
||||||
|
contentContainer.layer.masksToBounds = true
|
||||||
|
delegate?.statusEditCellNeedsReconfigure(self, animated: true) {
|
||||||
|
self.contentContainer.layer.masksToBounds = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatusEditCollectionViewCell: AttachmentViewDelegate {
|
||||||
|
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? {
|
||||||
|
guard let delegate else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let attachments = contentContainer.attachmentsView.attachments!
|
||||||
|
let sourceViews = attachments.map {
|
||||||
|
contentContainer.attachmentsView.getAttachmentView(for: $0)
|
||||||
|
}
|
||||||
|
let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index)
|
||||||
|
return gallery
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachmentViewPresent(_ vc: UIViewController, animated: Bool) {
|
||||||
|
delegate?.present(vc, animated: animated)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
//
|
||||||
|
// StatusEditContentTextView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/11/23.
|
||||||
|
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
import WebURL
|
||||||
|
|
||||||
|
class StatusEditContentTextView: ContentTextView {
|
||||||
|
|
||||||
|
func setTextFrom(edit: StatusEdit, index: Int) {
|
||||||
|
setTextFromHtml(edit.content)
|
||||||
|
setEmojis(edit.emojis, identifier: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mention links aren't included in the edit content, nothing else to do
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,208 @@
|
||||||
|
//
|
||||||
|
// StatusEditHistoryViewController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/11/23.
|
||||||
|
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
class StatusEditHistoryViewController: UIViewController, CollectionViewController {
|
||||||
|
|
||||||
|
private let statusID: String
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
|
private var state = State.unloaded
|
||||||
|
|
||||||
|
private(set) var collectionView: UICollectionView!
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
|
init(statusID: String, mastodonController: MastodonController) {
|
||||||
|
self.statusID = statusID
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
title = "Edit History"
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||||
|
config.backgroundColor = .appGroupedBackground
|
||||||
|
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
|
||||||
|
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return sectionConfig
|
||||||
|
}
|
||||||
|
var config = sectionConfig
|
||||||
|
if item.hideSeparators {
|
||||||
|
config.topSeparatorVisibility = .hidden
|
||||||
|
config.bottomSeparatorVisibility = .hidden
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
|
||||||
|
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||||
|
section.contentInsetsReference = .readableContent
|
||||||
|
}
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
dataSource = createDataSource()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
|
let editCell = UICollectionView.CellRegistration<StatusEditCollectionViewCell, (StatusEdit, CollapseState, Int)> { [unowned self] cell, indexPath, itemIdentifier in
|
||||||
|
cell.delegate = self
|
||||||
|
cell.updateUI(edit: itemIdentifier.0, state: itemIdentifier.1, index: itemIdentifier.2)
|
||||||
|
}
|
||||||
|
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
|
||||||
|
cell.indicator.startAnimating()
|
||||||
|
}
|
||||||
|
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||||
|
switch itemIdentifier {
|
||||||
|
case .edit(let edit, let state, index: let index):
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: editCell, for: indexPath, item: (edit, state, index))
|
||||||
|
case .loadingIndicator:
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
clearSelectionOnAppear(animated: animated)
|
||||||
|
|
||||||
|
if case .unloaded = state {
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
guard case .unloaded = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .loading
|
||||||
|
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
snapshot.appendSections([.edits])
|
||||||
|
snapshot.appendItems([.loadingIndicator])
|
||||||
|
await apply(snapshot)
|
||||||
|
|
||||||
|
let req = Status.history(statusID)
|
||||||
|
do {
|
||||||
|
let (edits, _) = try await mastodonController.run(req)
|
||||||
|
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
snapshot.appendSections([.edits])
|
||||||
|
// show them in reverse chronological order
|
||||||
|
snapshot.appendItems(edits.reversed().enumerated().map {
|
||||||
|
.edit($1, .unknown, index: $0)
|
||||||
|
})
|
||||||
|
await apply(snapshot)
|
||||||
|
|
||||||
|
state = .loaded
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
state = .unloaded
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Loading Edits", in: self) { [weak self] toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
await self?.load()
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>) async {
|
||||||
|
await MainActor.run {
|
||||||
|
self.dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
case unloaded
|
||||||
|
case loading
|
||||||
|
case loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatusEditHistoryViewController {
|
||||||
|
enum Section {
|
||||||
|
case edits
|
||||||
|
}
|
||||||
|
enum Item: Hashable, Equatable {
|
||||||
|
case edit(StatusEdit, CollapseState, index: Int)
|
||||||
|
case loadingIndicator
|
||||||
|
|
||||||
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.edit(_, _, let a), .edit(_, _, let b)):
|
||||||
|
return a == b
|
||||||
|
case (.loadingIndicator, .loadingIndicator):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
switch self {
|
||||||
|
case .edit(_, _, index: let index):
|
||||||
|
hasher.combine(0)
|
||||||
|
hasher.combine(index)
|
||||||
|
case .loadingIndicator:
|
||||||
|
hasher.combine(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hideSeparators: Bool {
|
||||||
|
switch self {
|
||||||
|
case .loadingIndicator:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatusEditHistoryViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatusEditHistoryViewController: TuskerNavigationDelegate {
|
||||||
|
nonisolated var apiController: MastodonController! { mastodonController }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StatusEditHistoryViewController: StatusEditCollectionViewCellDelegate {
|
||||||
|
func statusEditCellNeedsReconfigure(_ cell: StatusEditCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
|
||||||
|
if let indexPath = collectionView.indexPath(for: cell) {
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
//
|
||||||
|
// StatusEditPollView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/11/23.
|
||||||
|
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
class StatusEditPollView: UIStackView {
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
axis = .vertical
|
||||||
|
alignment = .leading
|
||||||
|
spacing = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUI(poll: StatusEdit.Poll?, emojis: [Emoji]) {
|
||||||
|
arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
|
||||||
|
for option in poll?.options ?? [] {
|
||||||
|
// the edit poll doesn't actually include the multiple value
|
||||||
|
let icon = PollOptionCheckboxView(multiple: false)
|
||||||
|
icon.readOnly = false // this is a lie, but it's only used for stylistic changes
|
||||||
|
let label = EmojiLabel()
|
||||||
|
label.text = option.title
|
||||||
|
label.setEmojis(emojis, identifier: Optional<String>.none)
|
||||||
|
let stack = UIStackView(arrangedSubviews: [
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
])
|
||||||
|
stack.axis = .horizontal
|
||||||
|
stack.alignment = .center
|
||||||
|
stack.spacing = 8
|
||||||
|
addArrangedSubview(stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -11,23 +11,30 @@ import Pachyderm
|
||||||
|
|
||||||
class HashtagTimelineViewController: TimelineViewController {
|
class HashtagTimelineViewController: TimelineViewController {
|
||||||
|
|
||||||
let hashtag: Hashtag
|
let hashtagName: String
|
||||||
|
private var hashtag: Hashtag?
|
||||||
|
private var fetchHashtag: Task<Hashtag?, Never>?
|
||||||
|
|
||||||
var toggleSaveButton: UIBarButtonItem!
|
var toggleSaveButton: UIBarButtonItem!
|
||||||
|
|
||||||
var isHashtagSaved: Bool {
|
var isHashtagSaved: Bool {
|
||||||
let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)
|
let req = SavedHashtag.fetchRequest(name: hashtagName, account: mastodonController.accountInfo!)
|
||||||
return mastodonController.persistentContainer.viewContext.objectExists(for: req)
|
return mastodonController.persistentContainer.viewContext.objectExists(for: req)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isHashtagFollowed: Bool {
|
private var isHashtagFollowed: Bool {
|
||||||
mastodonController.followedHashtags.contains(where: { $0.name == hashtag.name })
|
mastodonController.followedHashtags.contains(where: { $0.name == hashtagName })
|
||||||
}
|
}
|
||||||
|
|
||||||
init(for hashtag: Hashtag, mastodonController: MastodonController) {
|
convenience init(for hashtag: Hashtag, mastodonController: MastodonController) {
|
||||||
|
self.init(forNamed: hashtag.name, mastodonController: mastodonController)
|
||||||
self.hashtag = hashtag
|
self.hashtag = hashtag
|
||||||
|
}
|
||||||
|
|
||||||
super.init(for: .tag(hashtag: hashtag.name), mastodonController: mastodonController)
|
init(forNamed name: String, mastodonController: MastodonController) {
|
||||||
|
self.hashtagName = name
|
||||||
|
|
||||||
|
super.init(for: .tag(hashtag: hashtagName), mastodonController: mastodonController)
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
@ -37,10 +44,37 @@ class HashtagTimelineViewController: TimelineViewController {
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
if hashtag == nil {
|
||||||
|
fetchHashtag = Task {
|
||||||
|
let name = hashtagName.lowercased()
|
||||||
|
let hashtag: Hashtag?
|
||||||
|
if mastodonController.instanceFeatures.hasMastodonVersion(4, 0, 0) {
|
||||||
|
let req = Client.getHashtag(name: name)
|
||||||
|
hashtag = try? await mastodonController.run(req).0
|
||||||
|
} else {
|
||||||
|
let req = Client.search(query: "#\(name)", types: [.hashtags])
|
||||||
|
let results = try? await mastodonController.run(req).0
|
||||||
|
hashtag = results?.hashtags.first(where: { $0.name.lowercased() == name })
|
||||||
|
}
|
||||||
|
self.hashtag = hashtag
|
||||||
|
return hashtag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let menu = UIMenu(children: [
|
let menu = UIMenu(children: [
|
||||||
// uncached so that the saved/followed updates every time
|
// uncached so that the saved/followed updates every time
|
||||||
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
|
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
|
||||||
|
if let hashtag = self.hashtag {
|
||||||
elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!)))
|
elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!)))
|
||||||
|
} else {
|
||||||
|
Task {
|
||||||
|
if let hashtag = await fetchHashtag?.value {
|
||||||
|
elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!)))
|
||||||
|
} else {
|
||||||
|
elementHandler([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu)
|
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu)
|
||||||
|
@ -48,17 +82,28 @@ class HashtagTimelineViewController: TimelineViewController {
|
||||||
|
|
||||||
private func toggleSave() {
|
private func toggleSave() {
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)).first {
|
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtagName, account: mastodonController.accountInfo!)).first {
|
||||||
context.delete(existing)
|
context.delete(existing)
|
||||||
} else {
|
|
||||||
_ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context)
|
|
||||||
}
|
|
||||||
mastodonController.persistentContainer.save(context: context)
|
mastodonController.persistentContainer.save(context: context)
|
||||||
|
} else {
|
||||||
|
Task { @MainActor in
|
||||||
|
let hashtag: Hashtag?
|
||||||
|
if let tag = self.hashtag {
|
||||||
|
hashtag = tag
|
||||||
|
} else {
|
||||||
|
hashtag = await fetchHashtag?.value
|
||||||
|
}
|
||||||
|
if let hashtag {
|
||||||
|
_ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context)
|
||||||
|
mastodonController.persistentContainer.save(context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleFollow() {
|
private func toggleFollow() {
|
||||||
Task {
|
Task {
|
||||||
await ToggleFollowHashtagService(hashtag: hashtag, presenter: self).toggleFollow()
|
await ToggleFollowHashtagService(hashtagName: hashtagName, presenter: self).toggleFollow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -64,7 +64,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
self.controller = TimelineLikeController(delegate: self)
|
self.controller = TimelineLikeController(delegate: self, ownerType: String(describing: self))
|
||||||
|
|
||||||
self.navigationItem.title = timeline.title
|
self.navigationItem.title = timeline.title
|
||||||
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
|
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
|
||||||
|
|
|
@ -141,13 +141,15 @@ class EnhancedNavigationViewController: UINavigationController {
|
||||||
if self.interactivePushTransition.interactive {
|
if self.interactivePushTransition.interactive {
|
||||||
// when an interactive push gesture is cancelled, make sure to adding the VC that was being pushed back onto the popped stack so it doesn't disappear
|
// when an interactive push gesture is cancelled, make sure to adding the VC that was being pushed back onto the popped stack so it doesn't disappear
|
||||||
self.poppedViewControllers.insert(self.interactivePushTransition.pushingViewController!, at: 0)
|
self.poppedViewControllers.insert(self.interactivePushTransition.pushingViewController!, at: 0)
|
||||||
} else {
|
} else if self.interactivePopGestureRecognizer?.state == .ended {
|
||||||
// when an interactive pop gesture is cancelled (i.e. the user lifts their finger before it triggers),
|
// when an interactive pop gesture is cancelled (i.e. the user lifts their finger before it triggers),
|
||||||
// the popViewController(animated:) method has already been called so the VC has already been added to the popped stack
|
// the popViewController(animated:) method has already been called so the VC has already been added to the popped stack
|
||||||
// so we make sure to remove it, otherwise there could be duplicate VCs on the navigation stasck
|
// so we make sure to remove it, otherwise there could be duplicate VCs on the navigation stasck
|
||||||
|
if !self.poppedViewControllers.isEmpty {
|
||||||
self.poppedViewControllers.remove(at: 0)
|
self.poppedViewControllers.remove(at: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -133,7 +133,7 @@ extension MenuActionProvider {
|
||||||
let name = hashtag.name.lowercased()
|
let name = hashtag.name.lowercased()
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first
|
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first
|
||||||
let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker"
|
let saveSubtitle = "Shown in the Explore section of Tusker"
|
||||||
let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus")
|
let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus")
|
||||||
actionsSection = [
|
actionsSection = [
|
||||||
UIAction(title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", subtitle: saveSubtitle, image: saveImage, handler: { (_) in
|
UIAction(title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", subtitle: saveSubtitle, image: saveImage, handler: { (_) in
|
||||||
|
@ -147,11 +147,11 @@ extension MenuActionProvider {
|
||||||
]
|
]
|
||||||
if mastodonController.instanceFeatures.canFollowHashtags {
|
if mastodonController.instanceFeatures.canFollowHashtags {
|
||||||
let existing = mastodonController.followedHashtags.first(where: { $0.name.lowercased() == name })
|
let existing = mastodonController.followedHashtags.first(where: { $0.name.lowercased() == name })
|
||||||
let subtitle = "Posts tagged with followed hashtags appear in your Home timeline"
|
let subtitle = "Posts appear in your Home timeline"
|
||||||
let image = UIImage(systemName: existing != nil ? "person.badge.minus" : "person.badge.plus")
|
let image = UIImage(systemName: existing != nil ? "person.badge.minus" : "person.badge.plus")
|
||||||
actionsSection.append(UIAction(title: existing != nil ? "Unfollow" : "Follow", subtitle: subtitle, image: image) { [unowned self] _ in
|
actionsSection.append(UIAction(title: existing != nil ? "Unfollow" : "Follow", subtitle: subtitle, image: image) { [unowned self] _ in
|
||||||
Task {
|
Task {
|
||||||
await ToggleFollowHashtagService(hashtag: hashtag, presenter: navigationDelegate!).toggleFollow()
|
await ToggleFollowHashtagService(hashtagName: hashtag.name, presenter: navigationDelegate!).toggleFollow()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -300,6 +300,41 @@ extension MenuActionProvider {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if mastodonController.instanceFeatures.editStatuses {
|
||||||
|
actionsSection.append(UIAction(title: "Edit Post", image: UIImage(systemName: "pencil"), handler: { [weak self] _ in
|
||||||
|
guard let navigationDelegate = self?.navigationDelegate else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
@MainActor func doEdit() async {
|
||||||
|
let source: StatusSource
|
||||||
|
do {
|
||||||
|
source = try await FetchStatusSourceService(statusID: status.id, delegate: navigationDelegate).run()
|
||||||
|
} catch let error as Client.Error {
|
||||||
|
self?.handleError(error, title: "Error Fetching Source")
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let draft = navigationDelegate.apiController.createDraft(editing: status, source: source)
|
||||||
|
navigationDelegate.compose(editing: draft)
|
||||||
|
}
|
||||||
|
if status.poll != nil {
|
||||||
|
let alert = UIAlertController(title: "Edit Post with Poll?", message: "This will remove any votes that have already been placed.", preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "Edit", style: .default, handler: { _ in
|
||||||
|
Task {
|
||||||
|
await doEdit()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
navigationDelegate.present(alert, animated: true)
|
||||||
|
} else {
|
||||||
|
Task {
|
||||||
|
await doEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
actionsSection.append(UIMenu(title: "Delete Post", image: UIImage(systemName: "trash"), children: [
|
actionsSection.append(UIMenu(title: "Delete Post", image: UIImage(systemName: "trash"), children: [
|
||||||
UIAction(title: "Cancel", handler: { _ in }),
|
UIAction(title: "Cancel", handler: { _ in }),
|
||||||
UIAction(title: "Delete Post", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [weak self] _ in
|
UIAction(title: "Delete Post", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [weak self] _ in
|
||||||
|
@ -409,10 +444,10 @@ extension MenuActionProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleError(_ error: Client.Error, title: String) {
|
private func handleError(_ error: Client.Error, title: String) {
|
||||||
if let toastable = self.toastableViewController {
|
if let navigationDelegate {
|
||||||
let config = ToastConfiguration(from: error, with: title, in: toastable, retryAction: nil)
|
let config = ToastConfiguration(from: error, with: title, in: navigationDelegate, retryAction: nil)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
toastable.showToast(configuration: config, animated: true)
|
navigationDelegate.showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -428,19 +463,24 @@ extension MenuActionProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func relationshipAction(_ fetch: Bool, accountID: String, mastodonController: MastodonController, builder: @escaping @MainActor (RelationshipMO, MastodonController) -> UIMenuElement) -> UIDeferredMenuElement {
|
private func relationshipAction(_ fetch: Bool, accountID: String, mastodonController: MastodonController, builder: @escaping @MainActor (RelationshipMO, MastodonController) -> UIMenuElement?) -> UIDeferredMenuElement {
|
||||||
return UIDeferredMenuElement.uncached({ @MainActor elementHandler in
|
return UIDeferredMenuElement.uncached({ @MainActor elementHandler in
|
||||||
// workaround for #198, may result in showing outdated relationship, so only do so where necessary
|
// workaround for #198, may result in showing outdated relationship, so only do so where necessary
|
||||||
if !fetch || ProcessInfo.processInfo.isiOSAppOnMac,
|
if !fetch || ProcessInfo.processInfo.isiOSAppOnMac,
|
||||||
let mo = mastodonController.persistentContainer.relationship(forAccount: accountID) {
|
let mo = mastodonController.persistentContainer.relationship(forAccount: accountID) {
|
||||||
elementHandler([builder(mo, mastodonController)])
|
if let action = builder(mo, mastodonController) {
|
||||||
|
elementHandler([action])
|
||||||
|
} else {
|
||||||
|
elementHandler([])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let relationship = Task {
|
let relationship = Task {
|
||||||
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
|
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
if let relationship = await relationship.value {
|
if let relationship = await relationship.value,
|
||||||
elementHandler([builder(relationship, mastodonController)])
|
let action = builder(relationship, mastodonController) {
|
||||||
|
elementHandler([action])
|
||||||
} else {
|
} else {
|
||||||
elementHandler([])
|
elementHandler([])
|
||||||
}
|
}
|
||||||
|
@ -549,7 +589,11 @@ extension MenuActionProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func hideReblogsAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
|
private func hideReblogsAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
|
||||||
|
// don't show action for people that the user isn't following and isn't already hiding reblogs for
|
||||||
|
guard relationship.following || relationship.showingReblogs else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"
|
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"
|
||||||
// todo: need alternate repeat icon to use here
|
// todo: need alternate repeat icon to use here
|
||||||
return UIAction(title: title, image: nil) { [weak self] _ in
|
return UIAction(title: title, image: nil) { [weak self] _ in
|
||||||
|
|
|
@ -10,7 +10,7 @@ import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeControllerDelegate, ToastableViewController {
|
protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeControllerDelegate, TuskerNavigationDelegate {
|
||||||
associatedtype Section: TimelineLikeCollectionViewSection
|
associatedtype Section: TimelineLikeCollectionViewSection
|
||||||
associatedtype Item: TimelineLikeCollectionViewItem where Item.TimelineItem == Self.TimelineItem
|
associatedtype Item: TimelineLikeCollectionViewItem where Item.TimelineItem == Self.TimelineItem
|
||||||
associatedtype Error: TimelineLikeCollectionViewError
|
associatedtype Error: TimelineLikeCollectionViewError
|
||||||
|
|
|
@ -40,19 +40,21 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category:
|
||||||
class TimelineLikeController<Item: Sendable> {
|
class TimelineLikeController<Item: Sendable> {
|
||||||
|
|
||||||
private unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
private unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
||||||
|
private let ownerType: String
|
||||||
|
|
||||||
private(set) var state = State.notLoadedInitial {
|
private(set) var state = State.notLoadedInitial {
|
||||||
willSet {
|
willSet {
|
||||||
guard state.canTransition(to: newValue) else {
|
guard state.canTransition(to: newValue) else {
|
||||||
logger.error("State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)")
|
logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)")
|
||||||
fatalError("State \(state) cannot transition to \(newValue)")
|
fatalError("State \(state) cannot transition to \(newValue)")
|
||||||
}
|
}
|
||||||
logger.debug("State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)")
|
logger.debug("\(self.ownerType, privacy: .public) State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(delegate: any TimelineLikeControllerDelegate<Item>) {
|
init(delegate: any TimelineLikeControllerDelegate<Item>, ownerType: String) {
|
||||||
self.delegate = delegate
|
self.delegate = delegate
|
||||||
|
self.ownerType = ownerType
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadInitial() async {
|
func loadInitial() async {
|
||||||
|
@ -171,7 +173,7 @@ class TimelineLikeController<Item: Sendable> {
|
||||||
|
|
||||||
private func emit(event: Event) async {
|
private func emit(event: Event) async {
|
||||||
guard state.canEmit(event: event) else {
|
guard state.canEmit(event: event) else {
|
||||||
logger.error("State \(self.state.debugDescription, privacy: .public) cannot emit event: \(event.debugDescription, privacy: .public)")
|
logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot emit event: \(event.debugDescription, privacy: .public)")
|
||||||
fatalError("State \(state) cannot emit event: \(event)")
|
fatalError("State \(state) cannot emit event: \(event)")
|
||||||
}
|
}
|
||||||
switch event {
|
switch event {
|
||||||
|
@ -324,7 +326,7 @@ class TimelineLikeController<Item: Sendable> {
|
||||||
case .loadAllError(let error, let token):
|
case .loadAllError(let error, let token):
|
||||||
return "loadAllError(\(error), \(token))"
|
return "loadAllError(\(error), \(token))"
|
||||||
case .replaceAllItems(_, let token):
|
case .replaceAllItems(_, let token):
|
||||||
return "replcaeAllItems(<omitted>, \(token))"
|
return "replaceAllItems(<omitted>, \(token))"
|
||||||
case .loadNewerError(let error, let token):
|
case .loadNewerError(let error, let token):
|
||||||
return "loadNewerError(\(error), \(token))"
|
return "loadNewerError(\(error), \(token))"
|
||||||
case .prependItems(_, let token):
|
case .prependItems(_, let token):
|
||||||
|
|
|
@ -13,7 +13,7 @@ import ComposeUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
||||||
var apiController: MastodonController! { get }
|
nonisolated var apiController: MastodonController! { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TuskerNavigationDelegate {
|
extension TuskerNavigationDelegate {
|
||||||
|
|
|
@ -15,7 +15,7 @@ class AttachmentsContainerView: UIView {
|
||||||
|
|
||||||
weak var delegate: AttachmentViewDelegate?
|
weak var delegate: AttachmentViewDelegate?
|
||||||
|
|
||||||
var statusID: String!
|
private var attachmentTokens: [AttachmentToken] = []
|
||||||
var attachments: [Attachment]!
|
var attachments: [Attachment]!
|
||||||
|
|
||||||
let attachmentViews: NSHashTable<AttachmentView> = .weakObjects()
|
let attachmentViews: NSHashTable<AttachmentView> = .weakObjects()
|
||||||
|
@ -60,13 +60,16 @@ class AttachmentsContainerView: UIView {
|
||||||
|
|
||||||
// MARK: - User Interaface
|
// MARK: - User Interaface
|
||||||
|
|
||||||
func updateUI(status: StatusMO) {
|
func updateUI(attachments: [Attachment]) {
|
||||||
guard self.statusID != status.id else {
|
let attachments = attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) }
|
||||||
|
let newTokens = attachments.map { AttachmentToken(attachment: $0) }
|
||||||
|
|
||||||
|
guard self.attachmentTokens != newTokens else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.statusID = status.id
|
self.attachments = attachments
|
||||||
attachments = status.attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) }
|
self.attachmentTokens = newTokens
|
||||||
|
|
||||||
attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
|
attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
|
||||||
attachmentViews.removeAllObjects()
|
attachmentViews.removeAllObjects()
|
||||||
|
@ -461,3 +464,15 @@ fileprivate extension UIView {
|
||||||
return heightAnchor.constraint(equalTo: superview!.heightAnchor, multiplier: 0.5, constant: -spacing / 2)
|
return heightAnchor.constraint(equalTo: superview!.heightAnchor, multiplier: 0.5, constant: -spacing / 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A token that represents properties of attachments that the container needs to take into account when deciding whether to update
|
||||||
|
fileprivate struct AttachmentToken: Equatable {
|
||||||
|
let url: URL
|
||||||
|
// to show the alt badge or not
|
||||||
|
let hasDescription: Bool
|
||||||
|
|
||||||
|
init(attachment: Attachment) {
|
||||||
|
self.url = attachment.url
|
||||||
|
self.hasDescription = attachment.description != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -13,17 +13,18 @@ import WebURLFoundationExtras
|
||||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||||
|
|
||||||
protocol BaseEmojiLabel: AnyObject {
|
protocol BaseEmojiLabel: AnyObject {
|
||||||
var emojiIdentifier: String? { get set }
|
var emojiIdentifier: AnyHashable? { get set }
|
||||||
var emojiRequests: [ImageCache.Request] { get set }
|
var emojiRequests: [ImageCache.Request] { get set }
|
||||||
var emojiFont: UIFont { get }
|
var emojiFont: UIFont { get }
|
||||||
var emojiTextColor: UIColor { get }
|
var emojiTextColor: UIColor { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension BaseEmojiLabel {
|
extension BaseEmojiLabel {
|
||||||
func replaceEmojis(in attributedString: NSAttributedString, emojis: [Emoji], identifier: String?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) {
|
func replaceEmojis<ID: Hashable>(in attributedString: NSAttributedString, emojis: [Emoji], identifier: ID?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) {
|
||||||
// blergh
|
// blergh
|
||||||
precondition(Thread.isMainThread)
|
precondition(Thread.isMainThread)
|
||||||
|
|
||||||
|
let identifier = AnyHashable(identifier)
|
||||||
emojiIdentifier = identifier
|
emojiIdentifier = identifier
|
||||||
emojiRequests.forEach { $0.cancel() }
|
emojiRequests.forEach { $0.cancel() }
|
||||||
emojiRequests = []
|
emojiRequests = []
|
||||||
|
@ -138,7 +139,7 @@ extension BaseEmojiLabel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceEmojis(in string: String, emojis: [Emoji], identifier: String?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) {
|
func replaceEmojis<ID: Hashable>(in string: String, emojis: [Emoji], identifier: ID?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) {
|
||||||
replaceEmojis(in: NSAttributedString(string: string), emojis: emojis, identifier: identifier, setAttributedString: setAttributedString)
|
replaceEmojis(in: NSAttributedString(string: string), emojis: emojis, identifier: identifier, setAttributedString: setAttributedString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
|
|
||||||
private(set) var hasEmojis = false
|
private(set) var hasEmojis = false
|
||||||
|
|
||||||
var emojiIdentifier: String?
|
var emojiIdentifier: AnyHashable?
|
||||||
var emojiRequests: [ImageCache.Request] = []
|
var emojiRequests: [ImageCache.Request] = []
|
||||||
var emojiFont: UIFont { defaultFont }
|
var emojiFont: UIFont { defaultFont }
|
||||||
var emojiTextColor: UIColor { defaultColor }
|
var emojiTextColor: UIColor { defaultColor }
|
||||||
|
@ -81,7 +81,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Emojis
|
// MARK: - Emojis
|
||||||
func setEmojis(_ emojis: [Emoji], identifier: String?) {
|
func setEmojis<ID: Hashable>(_ emojis: [Emoji], identifier: ID?) {
|
||||||
replaceEmojis(in: attributedText!, emojis: emojis, identifier: identifier) { attributedString, didReplaceEmojis in
|
replaceEmojis(in: attributedText!, emojis: emojis, identifier: identifier) { attributedString, didReplaceEmojis in
|
||||||
guard didReplaceEmojis else {
|
guard didReplaceEmojis else {
|
||||||
return
|
return
|
||||||
|
|
|
@ -13,16 +13,16 @@ class EmojiLabel: UILabel, BaseEmojiLabel {
|
||||||
|
|
||||||
private(set) var hasEmojis = false
|
private(set) var hasEmojis = false
|
||||||
|
|
||||||
var emojiIdentifier: String?
|
var emojiIdentifier: AnyHashable?
|
||||||
var emojiRequests: [ImageCache.Request] = []
|
var emojiRequests: [ImageCache.Request] = []
|
||||||
var emojiFont: UIFont { font }
|
var emojiFont: UIFont { font }
|
||||||
var emojiTextColor: UIColor { textColor }
|
var emojiTextColor: UIColor { textColor }
|
||||||
|
|
||||||
func setEmojis(_ emojis: [Emoji], identifier: String) {
|
func setEmojis<ID: Hashable>(_ emojis: [Emoji], identifier: ID?) {
|
||||||
guard emojis.count > 0, let attributedText = attributedText else { return }
|
guard emojis.count > 0, let attributedText = attributedText else { return }
|
||||||
|
|
||||||
replaceEmojis(in: attributedText, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in
|
replaceEmojis(in: attributedText, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in
|
||||||
guard let self = self, self.emojiIdentifier == identifier else { return }
|
guard let self = self, self.emojiIdentifier == AnyHashable(identifier) else { return }
|
||||||
self.hasEmojis = didReplaceEmojis
|
self.hasEmojis = didReplaceEmojis
|
||||||
self.attributedText = newAttributedText
|
self.attributedText = newAttributedText
|
||||||
self.setNeedsLayout()
|
self.setNeedsLayout()
|
||||||
|
|
|
@ -12,20 +12,16 @@ import Pachyderm
|
||||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||||
|
|
||||||
class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel {
|
class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel {
|
||||||
var emojiIdentifier: String?
|
var emojiIdentifier: AnyHashable?
|
||||||
var emojiRequests = [ImageCache.Request]()
|
var emojiRequests = [ImageCache.Request]()
|
||||||
var emojiFont: UIFont { font }
|
var emojiFont: UIFont { font }
|
||||||
var emojiTextColor: UIColor { textColor }
|
var emojiTextColor: UIColor { textColor }
|
||||||
|
|
||||||
var combiner: (([NSAttributedString]) -> NSAttributedString)?
|
var combiner: (([NSAttributedString]) -> NSAttributedString)?
|
||||||
|
|
||||||
func setEmojis(pairs: [(String, [Emoji])], identifier: String) {
|
func setEmojis<ID: Hashable>(pairs: [(String, [Emoji])], identifier: ID) {
|
||||||
guard pairs.count > 0 else { return }
|
guard pairs.count > 0 else { return }
|
||||||
|
|
||||||
self.emojiIdentifier = identifier
|
|
||||||
emojiRequests.forEach { $0.cancel() }
|
|
||||||
emojiRequests = []
|
|
||||||
|
|
||||||
var attributedStrings = pairs.map { NSAttributedString(string: $0.0) }
|
var attributedStrings = pairs.map { NSAttributedString(string: $0.0) }
|
||||||
|
|
||||||
func recombine() {
|
func recombine() {
|
||||||
|
@ -40,7 +36,7 @@ class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel {
|
||||||
self.replaceEmojis(in: string, emojis: emojis, identifier: identifier) { (attributedString, _) in
|
self.replaceEmojis(in: string, emojis: emojis, identifier: identifier) { (attributedString, _) in
|
||||||
attributedStrings[index] = attributedString
|
attributedStrings[index] = attributedString
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self = self, self.emojiIdentifier == identifier else { return }
|
guard let self else { return }
|
||||||
recombine()
|
recombine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,6 +59,10 @@ class PollOptionCheckboxView: UIView {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
CGSize(width: 20, height: 20)
|
||||||
|
}
|
||||||
|
|
||||||
private func updateStyle() {
|
private func updateStyle() {
|
||||||
imageView.isHidden = !isChecked
|
imageView.isHidden = !isChecked
|
||||||
if voted || readOnly {
|
if voted || readOnly {
|
||||||
|
|
|
@ -21,7 +21,7 @@ class StatusPollView: UIView {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
weak var toastableViewController: ToastableViewController?
|
weak var delegate: TuskerNavigationDelegate?
|
||||||
|
|
||||||
private var statusID: String!
|
private var statusID: String!
|
||||||
private(set) var poll: Poll?
|
private(set) var poll: Poll?
|
||||||
|
@ -145,29 +145,34 @@ class StatusPollView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func votePressed() {
|
@objc private func votePressed() {
|
||||||
|
guard let statusID,
|
||||||
|
let poll else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
optionsView.isEnabled = false
|
optionsView.isEnabled = false
|
||||||
voteButton.isEnabled = false
|
voteButton.isEnabled = false
|
||||||
voteButton.disabledTitle = "Voted"
|
voteButton.disabledTitle = "Voted"
|
||||||
|
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
|
||||||
let request = Poll.vote(poll!.id, choices: optionsView.checkedOptionIndices)
|
let request = Poll.vote(poll.id, choices: optionsView.checkedOptionIndices)
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
switch response {
|
switch response {
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.updateUI(status: self.mastodonController.persistentContainer.status(for: self.statusID)!, poll: self.poll)
|
self.updateUI(status: self.mastodonController.persistentContainer.status(for: statusID)!, poll: poll)
|
||||||
|
|
||||||
if let toastable = self.toastableViewController {
|
if let delegate = self.delegate {
|
||||||
let config = ToastConfiguration(from: error, with: "Error Voting", in: toastable, retryAction: nil)
|
let config = ToastConfiguration(from: error, with: "Error Voting", in: delegate, retryAction: nil)
|
||||||
toastable.showToast(configuration: config, animated: true)
|
delegate.showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case let .success(poll, _):
|
case let .success(poll, _):
|
||||||
let container = self.mastodonController.persistentContainer
|
let container = self.mastodonController.persistentContainer
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let status = container.status(for: self.statusID) else {
|
guard let status = container.status(for: statusID) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
status.poll = poll
|
status.poll = poll
|
||||||
|
|
|
@ -392,12 +392,12 @@ class ProfileHeaderView: UIView {
|
||||||
} catch {
|
} catch {
|
||||||
followButton.isEnabled = true
|
followButton.isEnabled = true
|
||||||
followButton.configuration!.showsActivityIndicator = false
|
followButton.configuration!.showsActivityIndicator = false
|
||||||
if let toastable = delegate?.toastableViewController {
|
if let delegate = self.delegate {
|
||||||
let config = ToastConfiguration(from: error, with: "Error \(action)", in: toastable) { toast in
|
let config = ToastConfiguration(from: error, with: "Error \(action)", in: delegate) { toast in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
self.followPressed()
|
self.followPressed()
|
||||||
}
|
}
|
||||||
toastable.showToast(configuration: config, animated: true)
|
delegate.showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -116,7 +116,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentContainer = StatusContentContainer(useTopSpacer: true).configure {
|
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: true).configure {
|
||||||
$0.contentTextView.defaultFont = ConversationMainStatusCollectionViewCell.contentFont
|
$0.contentTextView.defaultFont = ConversationMainStatusCollectionViewCell.contentFont
|
||||||
$0.contentTextView.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont
|
$0.contentTextView.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont
|
||||||
$0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle
|
$0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle
|
||||||
|
@ -154,6 +154,12 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
$0.adjustsFontForContentSizeCategory = true
|
$0.adjustsFontForContentSizeCategory = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private lazy var editTimestampButton = UIButton().configure {
|
||||||
|
$0.titleLabel!.adjustsFontForContentSizeCategory = true
|
||||||
|
$0.addTarget(self, action: #selector(editTimestampPressed), for: .touchUpInside)
|
||||||
|
$0.isPointerInteractionEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
private let firstSeparator = UIView().configure {
|
private let firstSeparator = UIView().configure {
|
||||||
$0.backgroundColor = .separator
|
$0.backgroundColor = .separator
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -171,6 +177,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
firstSeparator,
|
firstSeparator,
|
||||||
actionsCountHStack,
|
actionsCountHStack,
|
||||||
timestampAndClientLabel,
|
timestampAndClientLabel,
|
||||||
|
editTimestampButton,
|
||||||
secondSeparator,
|
secondSeparator,
|
||||||
]).configure {
|
]).configure {
|
||||||
$0.axis = .vertical
|
$0.axis = .vertical
|
||||||
|
@ -341,11 +348,40 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
accountDetailAccessibilityElement.navigationDelegate = delegate
|
accountDetailAccessibilityElement.navigationDelegate = delegate
|
||||||
accountDetailAccessibilityElement.accountID = accountID
|
accountDetailAccessibilityElement.accountID = accountID
|
||||||
|
|
||||||
|
let metaButtonAttributes = AttributeContainer([
|
||||||
|
.font: ConversationMainStatusCollectionViewCell.metaFont
|
||||||
|
])
|
||||||
|
|
||||||
|
let favoritesFormat = NSLocalizedString("favorites count", comment: "conv main status favorites button label")
|
||||||
|
var favoritesConfig = UIButton.Configuration.plain()
|
||||||
|
favoritesConfig.baseForegroundColor = .secondaryLabel
|
||||||
|
favoritesConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(favoritesFormat, status.favouritesCount), attributes: metaButtonAttributes)
|
||||||
|
favoritesConfig.contentInsets = .zero
|
||||||
|
favoritesCountButton.configuration = favoritesConfig
|
||||||
|
|
||||||
|
let reblogsFormat = NSLocalizedString("reblogs count", comment: "conv main status reblogs button label")
|
||||||
|
var reblogsConfig = UIButton.Configuration.plain()
|
||||||
|
reblogsConfig.baseForegroundColor = .secondaryLabel
|
||||||
|
reblogsConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(reblogsFormat, status.reblogsCount), attributes: metaButtonAttributes)
|
||||||
|
reblogsConfig.contentInsets = .zero
|
||||||
|
reblogsCountButton.configuration = reblogsConfig
|
||||||
|
|
||||||
var timestampAndClientText = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: status.createdAt)
|
var timestampAndClientText = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: status.createdAt)
|
||||||
if let application = status.applicationName {
|
if let application = status.applicationName {
|
||||||
timestampAndClientText += " • \(application)"
|
timestampAndClientText += " • \(application)"
|
||||||
}
|
}
|
||||||
timestampAndClientLabel.text = timestampAndClientText
|
timestampAndClientLabel.text = timestampAndClientText
|
||||||
|
|
||||||
|
if let editedAt = status.editedAt {
|
||||||
|
editTimestampButton.isHidden = false
|
||||||
|
var config = UIButton.Configuration.plain()
|
||||||
|
config.baseForegroundColor = .secondaryLabel
|
||||||
|
config.attributedTitle = AttributedString("Edited on \(ConversationMainStatusCollectionViewCell.dateFormatter.string(from: editedAt))", attributes: metaButtonAttributes)
|
||||||
|
config.contentInsets = .zero
|
||||||
|
editTimestampButton.configuration = config
|
||||||
|
} else {
|
||||||
|
editTimestampButton.isHidden = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createObservers() {
|
private func createObservers() {
|
||||||
|
@ -356,28 +392,6 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
baseCreateObservers()
|
baseCreateObservers()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateStatusState(status: StatusMO) {
|
|
||||||
baseUpdateStatusState(status: status)
|
|
||||||
|
|
||||||
let attributes = AttributeContainer([
|
|
||||||
.font: ConversationMainStatusCollectionViewCell.metaFont
|
|
||||||
])
|
|
||||||
|
|
||||||
let favoritesFormat = NSLocalizedString("favorites count", comment: "conv main status favorites button label")
|
|
||||||
var favoritesConfig = UIButton.Configuration.plain()
|
|
||||||
favoritesConfig.baseForegroundColor = .secondaryLabel
|
|
||||||
favoritesConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(favoritesFormat, status.favouritesCount), attributes: attributes)
|
|
||||||
favoritesConfig.contentInsets = .zero
|
|
||||||
favoritesCountButton.configuration = favoritesConfig
|
|
||||||
|
|
||||||
let reblogsFormat = NSLocalizedString("reblogs count", comment: "conv main status reblogs button label")
|
|
||||||
var reblogsConfig = UIButton.Configuration.plain()
|
|
||||||
reblogsConfig.baseForegroundColor = .secondaryLabel
|
|
||||||
reblogsConfig.attributedTitle = AttributedString(String.localizedStringWithFormat(reblogsFormat, status.reblogsCount), attributes: attributes)
|
|
||||||
reblogsConfig.contentInsets = .zero
|
|
||||||
reblogsCountButton.configuration = reblogsConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIForPreferences(status: StatusMO) {
|
func updateUIForPreferences(status: StatusMO) {
|
||||||
baseUpdateUIForPreferences(status: status)
|
baseUpdateUIForPreferences(status: status)
|
||||||
}
|
}
|
||||||
|
@ -441,6 +455,10 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
toggleReblog()
|
toggleReblog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func editTimestampPressed() {
|
||||||
|
delegate?.show(StatusEditHistoryViewController(statusID: statusID, mastodonController: mastodonController), sender: nil)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement {
|
private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement {
|
||||||
|
|
|
@ -24,7 +24,7 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
|
||||||
var usernameLabel: UILabel { get }
|
var usernameLabel: UILabel { get }
|
||||||
var contentWarningLabel: EmojiLabel { get }
|
var contentWarningLabel: EmojiLabel { get }
|
||||||
var collapseButton: StatusCollapseButton { get }
|
var collapseButton: StatusCollapseButton { get }
|
||||||
var contentContainer: StatusContentContainer { get }
|
var contentContainer: StatusContentContainer<StatusContentTextView, StatusPollView> { get }
|
||||||
var replyButton: UIButton { get }
|
var replyButton: UIButton { get }
|
||||||
var favoriteButton: UIButton { get }
|
var favoriteButton: UIButton { get }
|
||||||
var reblogButton: UIButton { get }
|
var reblogButton: UIButton { get }
|
||||||
|
@ -45,7 +45,6 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
|
||||||
var cancellables: Set<AnyCancellable> { get set }
|
var cancellables: Set<AnyCancellable> { get set }
|
||||||
|
|
||||||
func updateUIForPreferences(status: StatusMO)
|
func updateUIForPreferences(status: StatusMO)
|
||||||
func updateStatusState(status: StatusMO)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: UI Configuration
|
// MARK: UI Configuration
|
||||||
|
@ -58,11 +57,8 @@ extension StatusCollectionViewCell {
|
||||||
mastodonController.persistentContainer.statusSubject
|
mastodonController.persistentContainer.statusSubject
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.filter { [unowned self] in $0 == self.statusID }
|
.filter { [unowned self] in $0 == self.statusID }
|
||||||
.sink { [unowned self] in
|
.sink { [unowned self] _ in
|
||||||
if let mastodonController = self.mastodonController,
|
self.delegate?.statusCellNeedsReconfigure(self, animated: true, completion: nil)
|
||||||
let status = mastodonController.persistentContainer.status(for: $0) {
|
|
||||||
self.updateStatusState(status: status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
@ -87,7 +83,11 @@ extension StatusCollectionViewCell {
|
||||||
contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
|
contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
|
||||||
contentContainer.contentTextView.navigationDelegate = delegate
|
contentContainer.contentTextView.navigationDelegate = delegate
|
||||||
contentContainer.attachmentsView.delegate = self
|
contentContainer.attachmentsView.delegate = self
|
||||||
contentContainer.attachmentsView.updateUI(status: status)
|
contentContainer.attachmentsView.updateUI(attachments: status.attachments)
|
||||||
|
contentContainer.pollView.isHidden = status.poll == nil
|
||||||
|
contentContainer.pollView.mastodonController = mastodonController
|
||||||
|
contentContainer.pollView.delegate = delegate
|
||||||
|
contentContainer.pollView.updateUI(status: status, poll: status.poll)
|
||||||
if Preferences.shared.showLinkPreviews {
|
if Preferences.shared.showLinkPreviews {
|
||||||
contentContainer.cardView.updateUI(status: status)
|
contentContainer.cardView.updateUI(status: status)
|
||||||
contentContainer.cardView.isHidden = status.card == nil
|
contentContainer.cardView.isHidden = status.card == nil
|
||||||
|
@ -98,7 +98,6 @@ extension StatusCollectionViewCell {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUIForPreferences(status: status)
|
updateUIForPreferences(status: status)
|
||||||
updateStatusState(status: status)
|
|
||||||
|
|
||||||
contentWarningLabel.text = status.spoilerText
|
contentWarningLabel.text = status.spoilerText
|
||||||
contentWarningLabel.isHidden = status.spoilerText.isEmpty
|
contentWarningLabel.isHidden = status.spoilerText.isEmpty
|
||||||
|
@ -106,14 +105,36 @@ extension StatusCollectionViewCell {
|
||||||
contentWarningLabel.setEmojis(status.emojis, identifier: statusID)
|
contentWarningLabel.setEmojis(status.emojis, identifier: statusID)
|
||||||
}
|
}
|
||||||
|
|
||||||
reblogButton.isEnabled = reblogEnabled(status: status)
|
|
||||||
replyButton.isEnabled = mastodonController.loggedIn
|
replyButton.isEnabled = mastodonController.loggedIn
|
||||||
favoriteButton.isEnabled = mastodonController.loggedIn
|
|
||||||
|
|
||||||
if statusState.unknown {
|
favoriteButton.isEnabled = mastodonController.loggedIn
|
||||||
|
if status.favourited {
|
||||||
|
favoriteButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)
|
||||||
|
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
|
||||||
|
} else {
|
||||||
|
favoriteButton.tintColor = nil
|
||||||
|
favoriteButton.accessibilityLabel = NSLocalizedString("Favorite", comment: "favorite button accessibility label")
|
||||||
|
}
|
||||||
|
|
||||||
|
reblogButton.isEnabled = reblogEnabled(status: status)
|
||||||
|
if status.reblogged {
|
||||||
|
reblogButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)
|
||||||
|
reblogButton.accessibilityLabel = NSLocalizedString("Undo Reblog", comment: "undo reblog button accessibility label")
|
||||||
|
} else {
|
||||||
|
reblogButton.tintColor = nil
|
||||||
|
reblogButton.accessibilityLabel = NSLocalizedString("Reblog", comment: "reblog button accessibility label")
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep menu in sync with changed states e.g. bookmarked, muted
|
||||||
|
// do not include reply action here, because the cell already contains a button for it
|
||||||
|
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
|
||||||
|
|
||||||
|
let didResolve = statusState.resolveFor(status: status) {
|
||||||
// layout so that we can take the content height into consideration when deciding whether to collapse
|
// layout so that we can take the content height into consideration when deciding whether to collapse
|
||||||
layoutIfNeeded()
|
layoutIfNeeded()
|
||||||
statusState.resolveFor(status: status, height: contentContainer.visibleSubviewHeight)
|
return contentContainer.visibleSubviewHeight
|
||||||
|
}
|
||||||
|
if didResolve {
|
||||||
if statusState.collapsible! && showStatusAutomatically {
|
if statusState.collapsible! && showStatusAutomatically {
|
||||||
statusState.collapsed = false
|
statusState.collapsed = false
|
||||||
}
|
}
|
||||||
|
@ -191,32 +212,6 @@ extension StatusCollectionViewCell {
|
||||||
displayNameLabel.updateForAccountDisplayName(account: status.account)
|
displayNameLabel.updateForAccountDisplayName(account: status.account)
|
||||||
}
|
}
|
||||||
|
|
||||||
func baseUpdateStatusState(status: StatusMO) {
|
|
||||||
if status.favourited {
|
|
||||||
favoriteButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)
|
|
||||||
favoriteButton.accessibilityLabel = NSLocalizedString("Undo Favorite", comment: "undo favorite button accessibility label")
|
|
||||||
} else {
|
|
||||||
favoriteButton.tintColor = nil
|
|
||||||
favoriteButton.accessibilityLabel = NSLocalizedString("Favorite", comment: "favorite button accessibility label")
|
|
||||||
}
|
|
||||||
if status.reblogged {
|
|
||||||
reblogButton.tintColor = UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1)
|
|
||||||
reblogButton.accessibilityLabel = NSLocalizedString("Undo Reblog", comment: "undo reblog button accessibility label")
|
|
||||||
} else {
|
|
||||||
reblogButton.tintColor = nil
|
|
||||||
reblogButton.accessibilityLabel = NSLocalizedString("Reblog", comment: "reblog button accessibility label")
|
|
||||||
}
|
|
||||||
|
|
||||||
// keep menu in sync with changed states e.g. bookmarked, muted
|
|
||||||
// do not include reply action here, because the cell already contains a button for it
|
|
||||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
|
|
||||||
|
|
||||||
contentContainer.pollView.isHidden = status.poll == nil
|
|
||||||
contentContainer.pollView.mastodonController = mastodonController
|
|
||||||
contentContainer.pollView.toastableViewController = delegate?.toastableViewController
|
|
||||||
contentContainer.pollView.updateUI(status: status, poll: status.poll)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setShowThreadLinks(prev: Bool, next: Bool) {
|
func setShowThreadLinks(prev: Bool, next: Bool) {
|
||||||
if prev {
|
if prev {
|
||||||
if let prevThreadLinkView {
|
if let prevThreadLinkView {
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class StatusContentContainer: UIView {
|
class StatusContentContainer<ContentView: ContentTextView, PollView: UIView>: UIView {
|
||||||
|
|
||||||
private var useTopSpacer = false
|
private var useTopSpacer = false
|
||||||
private let topSpacer = UIView().configure {
|
private let topSpacer = UIView().configure {
|
||||||
|
@ -17,7 +17,7 @@ class StatusContentContainer: UIView {
|
||||||
$0.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
$0.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentTextView = StatusContentTextView().configure {
|
let contentTextView = ContentView().configure {
|
||||||
$0.adjustsFontForContentSizeCategory = true
|
$0.adjustsFontForContentSizeCategory = true
|
||||||
$0.isScrollEnabled = false
|
$0.isScrollEnabled = false
|
||||||
$0.backgroundColor = nil
|
$0.backgroundColor = nil
|
||||||
|
@ -33,7 +33,7 @@ class StatusContentContainer: UIView {
|
||||||
|
|
||||||
let attachmentsView = AttachmentsContainerView()
|
let attachmentsView = AttachmentsContainerView()
|
||||||
|
|
||||||
let pollView = StatusPollView()
|
let pollView = PollView()
|
||||||
|
|
||||||
private var arrangedSubviews: [UIView] {
|
private var arrangedSubviews: [UIView] {
|
||||||
if useTopSpacer {
|
if useTopSpacer {
|
||||||
|
|
|
@ -178,7 +178,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentContainer = StatusContentContainer(useTopSpacer: false).configure {
|
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: false).configure {
|
||||||
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
|
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
|
||||||
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
|
||||||
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
|
||||||
|
@ -625,10 +625,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateStatusState(status: StatusMO) {
|
|
||||||
baseUpdateStatusState(status: status)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateTimestamp() {
|
private func updateTimestamp() {
|
||||||
guard let mastodonController,
|
guard let mastodonController,
|
||||||
let status = mastodonController.persistentContainer.status(for: statusID) else {
|
let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||||
|
@ -641,7 +637,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
// if there's a pending update timestamp work item, cancel it
|
// if there's a pending update timestamp work item, cancel it
|
||||||
updateTimestampWorkItem?.cancel()
|
updateTimestampWorkItem?.cancel()
|
||||||
|
|
||||||
timestampLabel.text = status.createdAt.timeAgoString()
|
let timeAgo = status.createdAt.timeAgoString()
|
||||||
|
timestampLabel.text = "\(timeAgo)\(status.editedAt != nil ? "*" : "")"
|
||||||
|
|
||||||
let delay: DispatchTimeInterval?
|
let delay: DispatchTimeInterval?
|
||||||
switch status.createdAt.timeAgo().1 {
|
switch status.createdAt.timeAgo().1 {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Sentry
|
import Sentry
|
||||||
import OSLog
|
import OSLog
|
||||||
|
@_spi(InstanceType) import InstanceFeatures
|
||||||
|
|
||||||
struct ToastConfiguration {
|
struct ToastConfiguration {
|
||||||
var systemImageName: String?
|
var systemImageName: String?
|
||||||
|
@ -39,7 +40,7 @@ struct ToastConfiguration {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ToastConfiguration {
|
extension ToastConfiguration {
|
||||||
init(from error: Error, with title: String, in viewController: UIViewController, retryAction: ((ToastView) -> Void)?) {
|
init(from error: Error, with title: String, in viewController: TuskerNavigationDelegate, retryAction: ((ToastView) -> Void)?) {
|
||||||
self.init(title: title)
|
self.init(title: title)
|
||||||
// localizedDescription is statically dispatched, so we need to call it after the downcast
|
// localizedDescription is statically dispatched, so we need to call it after the downcast
|
||||||
if let error = error as? Pachyderm.Client.Error {
|
if let error = error as? Pachyderm.Client.Error {
|
||||||
|
@ -59,7 +60,7 @@ extension ToastConfiguration {
|
||||||
viewController.present(reporter, animated: true)
|
viewController.present(reporter, animated: true)
|
||||||
}
|
}
|
||||||
// TODO: this is a bizarre place to do this, but code path covers basically all errors
|
// TODO: this is a bizarre place to do this, but code path covers basically all errors
|
||||||
captureError(error, title: title)
|
captureError(error, in: viewController.apiController, title: title)
|
||||||
} else {
|
} else {
|
||||||
self.subtitle = error.localizedDescription
|
self.subtitle = error.localizedDescription
|
||||||
self.systemImageName = "exclamationmark.triangle"
|
self.systemImageName = "exclamationmark.triangle"
|
||||||
|
@ -70,7 +71,7 @@ extension ToastConfiguration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from error: Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) {
|
init(from error: Error, with title: String, in viewController: TuskerNavigationDelegate, retryAction: @escaping @MainActor (ToastView) async -> Void) {
|
||||||
self.init(from: error, with: title, in: viewController) { toast in
|
self.init(from: error, with: title, in: viewController) { toast in
|
||||||
Task {
|
Task {
|
||||||
await retryAction(toast)
|
await retryAction(toast)
|
||||||
|
@ -84,6 +85,8 @@ fileprivate extension Pachyderm.Client.Error {
|
||||||
switch type {
|
switch type {
|
||||||
case .networkError(_):
|
case .networkError(_):
|
||||||
return "wifi.exclamationmark"
|
return "wifi.exclamationmark"
|
||||||
|
case .unexpectedStatus(429):
|
||||||
|
return "clock.badge.exclamationmark"
|
||||||
default:
|
default:
|
||||||
return "exclamationmark.triangle"
|
return "exclamationmark.triangle"
|
||||||
}
|
}
|
||||||
|
@ -92,7 +95,7 @@ fileprivate extension Pachyderm.Client.Error {
|
||||||
|
|
||||||
private let toastErrorLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ToastError")
|
private let toastErrorLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ToastError")
|
||||||
|
|
||||||
private func captureError(_ error: Client.Error, title: String) {
|
private func captureError(_ error: Client.Error, in mastodonController: MastodonController, title: String) {
|
||||||
let event = Event(error: error)
|
let event = Event(error: error)
|
||||||
event.message = SentryMessage(formatted: "\(title): \(error)")
|
event.message = SentryMessage(formatted: "\(title): \(error)")
|
||||||
event.tags = [
|
event.tags = [
|
||||||
|
@ -125,6 +128,37 @@ private func captureError(_ error: Client.Error, title: String) {
|
||||||
code == "401" || code == "403" || code == "404" || code == "502" {
|
code == "401" || code == "403" || code == "404" || code == "502" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
switch mastodonController.instanceFeatures.instanceType {
|
||||||
|
case .mastodon(let mastodonType, let mastodonVersion):
|
||||||
|
event.tags!["instance_type"] = "mastodon"
|
||||||
|
event.tags!["mastodon_version"] = mastodonVersion?.description ?? "unknown"
|
||||||
|
switch mastodonType {
|
||||||
|
case .vanilla:
|
||||||
|
break
|
||||||
|
case .hometown(_):
|
||||||
|
event.tags!["mastodon_type"] = "hometown"
|
||||||
|
case .glitch:
|
||||||
|
event.tags!["mastodon_type"] = "glitch"
|
||||||
|
}
|
||||||
|
case .pleroma(let pleromaType):
|
||||||
|
event.tags!["instance_type"] = "pleroma"
|
||||||
|
switch pleromaType {
|
||||||
|
case .vanilla(let version):
|
||||||
|
event.tags!["pleroma_version"] = version?.description ?? "unknown"
|
||||||
|
case .akkoma(let version):
|
||||||
|
event.tags!["pleroma_type"] = "akkoma"
|
||||||
|
event.tags!["pleroma_version"] = version?.description ?? "unknown"
|
||||||
|
}
|
||||||
|
case .pixelfed:
|
||||||
|
event.tags!["instance_type"] = "pixelfed"
|
||||||
|
case .gotosocial:
|
||||||
|
event.tags!["instance_type"] = "gotosocial"
|
||||||
|
case .calckey(let calckeyVersion):
|
||||||
|
event.tags!["instance_type"] = "calckey"
|
||||||
|
if let calckeyVersion {
|
||||||
|
event.tags!["calckey_version"] = calckeyVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
SentrySDK.capture(event: event)
|
SentrySDK.capture(event: event)
|
||||||
|
|
||||||
toastErrorLogger.error("\(title, privacy: .public): \(error), \(event.tags!.debugDescription, privacy: .public)")
|
toastErrorLogger.error("\(title, privacy: .public): \(error), \(event.tags!.debugDescription, privacy: .public)")
|
||||||
|
|
Loading…
Reference in New Issue