Compare commits

...

21 Commits

Author SHA1 Message Date
Shadowfacts fc391cc18c Bump build number and update changelog 2023-05-11 20:24:21 -04:00
Shadowfacts 35b390d3c1 Fix MultiSourceEmojiLabel 2023-05-11 18:38:49 -04:00
Shadowfacts b21703f6d9 Fix decoding polls on Calckey
See #362
2023-05-11 16:15:36 -04:00
Shadowfacts d003098146 Better TimelineLikeController logging 2023-05-11 15:11:43 -04:00
Shadowfacts db7c183d06 Add status edit history view 2023-05-11 14:57:47 -04:00
Shadowfacts 7d3c82f4b7 Fix collapsible state not changing when post edited 2023-05-11 14:46:45 -04:00
Shadowfacts 13ec3366d3 Fix content warning not being removed by edit 2023-05-11 14:39:49 -04:00
Shadowfacts f9a41fd4f3 Show edit timestamps on statuses 2023-05-11 13:10:45 -04:00
Shadowfacts 2157126332 Unseparate out updateStatusState method 2023-05-11 10:03:09 -04:00
Shadowfacts e87dcfe48e Add support for editing posts
Closes #321
2023-05-11 10:03:09 -04:00
Shadowfacts 566c3d474d Don't show Show Reblogs action for non-followed people 2023-05-10 22:22:37 -04:00
Shadowfacts ca03cf3b08 Shorten hashtag action titles 2023-05-10 11:55:23 -04:00
Shadowfacts f0e530722f FIx hashtag timelines opened in new window not having save/follow actions 2023-05-10 11:54:36 -04:00
Shadowfacts dcd1b4ad94 Fix being able to scroll to top while fast account switcher is active 2023-05-10 11:41:59 -04:00
Shadowfacts 3394c2126c Fix list timelines opened in new window not showing Edit button 2023-05-10 11:32:08 -04:00
Shadowfacts 85765928b4 Fix crash when trying to remove popped view controller that doesn't exist 2023-05-10 11:04:56 -04:00
Shadowfacts f13874ee01 Improve rate limit exceeded error message 2023-05-10 10:59:22 -04:00
Shadowfacts bac272a2db Detect gotosocial and calckey instances 2023-05-10 10:48:52 -04:00
Shadowfacts 48bd957276 Fix nodeinfo not being fetched on punycode domains 2023-05-10 10:40:27 -04:00
Shadowfacts d4d42e7856 Report instance type/version in Sentry events 2023-05-10 10:34:48 -04:00
Shadowfacts 671a8e0cb3 Fix error decoding statuses on Calckey lacking emojis 2023-05-10 10:13:34 -04:00
70 changed files with 1456 additions and 243 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -89,4 +89,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
func searchCachedHashtags(query: String) -> [Hashtag] { func searchCachedHashtags(query: String) -> [Hashtag] {
return [] return []
} }
func storeCreatedStatus(_ status: Status) {
}
} }

View File

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

View File

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

View File

@ -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?) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

@ -363,3 +363,13 @@ extension MainTabBarViewController: BackgroundableViewController {
} }
} }
} }
extension MainTabBarViewController: AccountSwitchableViewController {
var isFastAccountSwitcherActive: Bool {
if let fastAccountSwitcher {
return !fastAccountSwitcher.view.isHidden
} else {
return false
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -193,3 +193,7 @@ extension StatusActionAccountListViewController: ToastableViewController {
} }
} }
} }
extension StatusActionAccountListViewController: TuskerNavigationDelegate {
nonisolated var apiController: MastodonController! { mastodonController }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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