Compare commits

...

7 Commits

45 changed files with 1172 additions and 143 deletions

View File

@ -15,20 +15,18 @@ class PostService: ObservableObject {
private let mastodonController: ComposeMastodonContext
private let config: ComposeUIConfig
private let draft: Draft
let totalSteps: Int
@Published var currentStep = 1
@Published private(set) var totalSteps = 2
init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
self.mastodonController = mastodonController
self.config = config
self.draft = draft
// 2 steps (request data, then upload) for each attachment
self.totalSteps = 2 + (draft.attachments.count * 2)
}
func post() async throws {
guard draft.hasContent else {
guard draft.hasContent || draft.editedStatusID != nil else {
return
}
@ -37,14 +35,41 @@ class PostService: ObservableObject {
let uploadedAttachments = try await uploadAttachments()
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil
let sensitive = contentWarning != nil
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : ""
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(),
contentType: config.contentType,
inReplyTo: draft.inReplyToID,
media: uploadedAttachments,
mediaIDs: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: draft.visibility,
@ -54,18 +79,32 @@ class PostService: ObservableObject {
pollMultiple: draft.poll?.multiple,
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
)
}
do {
let (_, _) = try await mastodonController.run(request)
let (status, _) = try await mastodonController.run(request)
currentStep += 1
mastodonController.storeCreatedStatus(status)
} catch let error as Client.Error {
throw Error.posting(error)
}
}
private func uploadAttachments() async throws -> [Attachment] {
var attachments: [Attachment] = []
private func uploadAttachments() async throws -> [String] {
// 2 steps (request data, then upload) for each attachment
self.totalSteps += 2 * draft.attachments.count
var attachments: [String] = []
attachments.reserveCapacity(draft.attachments.count)
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 utType: UTType
do {
@ -76,7 +115,7 @@ class PostService: ObservableObject {
}
do {
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
attachments.append(uploaded)
attachments.append(uploaded.id)
currentStep += 1
} catch let error as Client.Error {
throw Error.attachmentUpload(index: index, cause: error)
@ -117,6 +156,17 @@ class PostService: ObservableObject {
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 {
case attachmentData(index: Int, cause: AttachmentData.Error)
case attachmentUpload(index: Int, cause: Client.Error)

View File

@ -23,4 +23,6 @@ public protocol ComposeMastodonContext {
func cachedRelationship(for accountID: String) -> RelationshipProtocol?
@MainActor
func searchCachedHashtags(query: String) -> [Hashtag]
func storeCreatedStatus(_ status: Status)
}

View File

@ -25,7 +25,7 @@ class AttachmentRowController: ViewController {
init(parent: ComposeController, attachment: DraftAttachment) {
self.parent = parent
self.attachment = attachment
self.thumbnailController = AttachmentThumbnailController(attachment: attachment)
self.thumbnailController = AttachmentThumbnailController(attachment: attachment, parent: parent)
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

View File

@ -11,14 +11,16 @@ import Photos
import TuskerComponents
class AttachmentThumbnailController: ViewController {
unowned let parent: ComposeController
let attachment: DraftAttachment
@Published private var image: UIImage?
@Published private var gifController: GIFController?
@Published private var fullSize: Bool = false
init(attachment: DraftAttachment) {
init(attachment: DraftAttachment, parent: ComposeController) {
self.attachment = attachment
self.parent = parent
}
func loadImageIfNecessary(fullSize: Bool) {
@ -28,6 +30,24 @@ class AttachmentThumbnailController: ViewController {
self.fullSize = fullSize
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):
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
return

View File

@ -12,6 +12,7 @@ import TuskerComponents
import MatchedGeometryPresentation
public final class ComposeController: ViewController {
public typealias FetchAttachment = (URL) async -> UIImage?
public typealias FetchStatus = (String) -> (any StatusProtocol)?
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
public typealias CurrentAccountContainerView = (AnyView) -> AnyView
@ -22,6 +23,7 @@ public final class ComposeController: ViewController {
@Published public var config: ComposeUIConfig
@Published public var mastodonController: ComposeMastodonContext
let fetchAvatar: AvatarImageView.FetchAvatar
let fetchAttachment: FetchAttachment
let fetchStatus: FetchStatus
let displayNameLabel: DisplayNameLabel
let currentAccountContainerView: CurrentAccountContainerView
@ -64,11 +66,12 @@ public final class ComposeController: ViewController {
}
var postButtonEnabled: Bool {
draft.hasContent
draft.editedStatusID != nil ||
(draft.hasContent
&& charactersRemaining >= 0
&& !isPosting
&& attachmentsListController.isValid
&& isPollValid
&& isPollValid)
}
private var isPollValid: Bool {
@ -79,6 +82,8 @@ public final class ComposeController: ViewController {
if let id = draft.inReplyToID,
let status = fetchStatus(id) {
return "Reply to @\(status.account.acct)"
} else if draft.editedStatusID != nil {
return "Edit Post"
} else {
return "New Post"
}
@ -89,6 +94,7 @@ public final class ComposeController: ViewController {
config: ComposeUIConfig,
mastodonController: ComposeMastodonContext,
fetchAvatar: @escaping AvatarImageView.FetchAvatar,
fetchAttachment: @escaping FetchAttachment,
fetchStatus: @escaping FetchStatus,
displayNameLabel: @escaping DisplayNameLabel,
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
@ -99,6 +105,7 @@ public final class ComposeController: ViewController {
self.config = config
self.mastodonController = mastodonController
self.fetchAvatar = fetchAvatar
self.fetchAttachment = fetchAttachment
self.fetchStatus = fetchStatus
self.displayNameLabel = displayNameLabel
self.currentAccountContainerView = currentAccountContainerView
@ -170,7 +177,7 @@ public final class ComposeController: ViewController {
func postStatus() {
guard !isPosting,
draft.hasContent else {
draft.editedStatusID != nil || draft.hasContent else {
return
}
@ -396,20 +403,27 @@ public final class ComposeController: ViewController {
.font(.system(size: 17, weight: .regular))
}
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
// edit drafts can't be saved
if draft.editedStatusID == nil {
Button(action: { controller.cancel(deleteDraft: false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Delete Draft")
}
} else {
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Cancel Edit")
}
}
}
}
@ViewBuilder
private var postButton: some View {
if draft.hasContent || !controller.config.allowSwitchingDrafts {
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
Button(action: controller.postStatus) {
Text("Post")
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled)

View File

@ -119,10 +119,18 @@ class DraftsController: ViewController {
private struct DraftRow: View {
@ObservedObject var draft: Draft
@EnvironmentObject private var controller: DraftsController
var body: some View {
HStack {
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 {
Text(draft.contentWarning)
.font(.body.bold())
@ -134,7 +142,7 @@ private struct DraftRow: View {
HStack(spacing: 8) {
ForEach(draft.draftAttachments) { attachment in
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment) })
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment, parent: controller.parent) })
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 5))
.frame(height: 50)

View File

@ -59,10 +59,12 @@ class ToolbarController: ViewController {
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
.padding(.horizontal, -8)
.disabled(draft.editedStatusID != nil)
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
localOnlyPicker
.padding(.horizontal, -8)
.disabled(draft.editedStatusID != nil)
}
if let currentInput = composeController.currentInput,

View File

@ -23,6 +23,7 @@ public class Draft: NSManagedObject, Identifiable {
@NSManaged public var accountID: String
@NSManaged public var contentWarning: String
@NSManaged public var contentWarningEnabled: Bool
@NSManaged public var editedStatusID: String?
@NSManaged public var id: UUID
@NSManaged public var initialText: String
@NSManaged public var inReplyToID: String?

View File

@ -10,6 +10,7 @@ import PencilKit
import UniformTypeIdentifiers
import Photos
import InstanceFeatures
import Pachyderm
private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder()
@ -20,6 +21,9 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
@NSManaged internal var assetID: String?
@NSManaged public var attachmentDescription: String
@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 internal var fileType: String?
@NSManaged public var id: UUID
@ -41,7 +45,9 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
}
public var data: AttachmentData {
if let assetID {
if let editedAttachmentID {
return .editing(editedAttachmentID, editedAttachmentKind!, editedAttachmentURL!)
} else if let assetID {
return .asset(assetID)
} else if let 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 {
case asset(String)
case drawing(PKDrawing)
case file(URL, UTType)
case editing(String, Attachment.Kind, URL)
}
public override func prepareForDeletion() {
@ -69,7 +85,18 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
extension DraftAttachment {
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 {
return .unknown
}

View File

@ -4,6 +4,7 @@
<attribute name="accountID" attributeType="String"/>
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
<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="initialText" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
@ -19,6 +20,9 @@
<attribute name="assetID" optional="YES" attributeType="String"/>
<attribute name="attachmentDescription" attributeType="String" defaultValueString=""/>
<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="fileURL" optional="YES" attributeType="URI"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>

View File

@ -96,6 +96,67 @@ public class DraftsPersistentContainer: NSPersistentContainer {
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) {
guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return

View File

@ -115,6 +115,15 @@ public class InstanceFeatures: ObservableObject {
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() {
}

View File

@ -315,6 +315,13 @@ public class Client {
], 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
public static func getMutes(range: RequestRange) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
@ -382,7 +389,7 @@ public class Client {
public static func createStatus(text: String,
contentType: StatusContentType = .plain,
inReplyTo: String? = nil,
media: [Attachment]? = nil,
mediaIDs: [String]? = nil,
sensitive: Bool? = nil,
spoilerText: String? = nil,
visibility: Visibility? = nil,
@ -402,7 +409,32 @@ public class Client {
"poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple,
"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

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)
}
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 {
case url
case username

View File

@ -41,6 +41,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
public let poll: Poll?
// Hometown, Glitch only
public let localOnly: Bool?
public let editedAt: Date?
public var applicationName: String? { application?.name }
@ -92,6 +93,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked)
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
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> {
@ -163,6 +165,14 @@ public final class Status: StatusProtocol, Decodable, Sendable {
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 {
case id
case uri
@ -193,6 +203,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
case card
case poll
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 var collapsible: Bool?
public var collapsed: Bool?
public var statusPropertiesHash: Int?
public var unknown: Bool {
collapsible == nil || collapsed == nil

View File

@ -36,6 +36,7 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
config: ComposeUIConfig(),
mastodonController: mastodonContext,
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") },
displayNameLabel: { account, style, _ in AnyView(Text(account.displayName).font(.system(style))) },
currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) },

View File

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

View File

@ -296,6 +296,11 @@
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.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 */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.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>"; };
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>"; };
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>"; };
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>"; };
@ -977,6 +987,7 @@
D65B4B522971F6E300DABDFB /* Report */,
D6BC9DD8232D8BCA002CA326 /* Search */,
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */,
D6D79F272A0D595D00AB2315 /* Status Edit History */,
D641C781213DD7DD004B4513 /* Timeline */,
D6C693FA2162FE5D007D6A6D /* Utilities */,
);
@ -1530,6 +1541,17 @@
path = Gestures;
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 */ = {
isa = PBXGroup;
children = (
@ -1596,6 +1618,7 @@
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */,
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
);
path = API;
sourceTree = "<group>";
@ -1949,6 +1972,7 @@
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */,
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */,
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
@ -2025,6 +2049,7 @@
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */,
D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */,
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
@ -2040,6 +2065,7 @@
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */,
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
D6D79F262A0C8D2700AB2315 /* FetchStatusSourceService.swift in Sources */,
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */,
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
@ -2118,6 +2144,7 @@
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */,
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
@ -2156,6 +2183,7 @@
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */,
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */,

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

View File

@ -18,6 +18,12 @@ public final class AccountMO: NSManagedObject, AccountProtocol {
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 avatar: URL?
@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 content: String
@NSManaged public var createdAt: Date
@NSManaged public var editedAt: Date?
@NSManaged private var emojisData: Data?
@NSManaged public var favourited: Bool
@NSManaged public var favouritesCount: Int
@ -113,6 +114,7 @@ extension StatusMO {
self.card = status.card
self.content = status.content
self.createdAt = status.createdAt
self.editedAt = status.editedAt
self.emojis = status.emojis
self.favourited = status.favourited ?? false
self.favouritesCount = status.favouritesCount

View File

@ -1,5 +1,5 @@
<?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">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" optional="YES" attributeType="URI"/>
@ -89,6 +89,7 @@
<attribute name="cardData" optional="YES" attributeType="Binary"/>
<attribute name="content" attributeType="String"/>
<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="favourited" attributeType="Boolean" 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="inReplyToID" optional="YES" attributeType="String"/>
<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="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pinnedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>

View File

@ -9,12 +9,24 @@
import Foundation
import Pachyderm
protocol CollapseStateResolving {
var spoilerText: String { get }
}
extension StatusMO: CollapseStateResolving {}
extension StatusEdit: CollapseStateResolving {}
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
if Preferences.shared.collapseLongPosts,
height > 600 || (textLength != nil && textLength! > 500) {
height() > 600 || (textLength != nil && textLength! > 500) {
longEnoughToCollapse = true
} else {
longEnoughToCollapse = false
@ -39,6 +51,12 @@ extension CollapseState {
self.collapsible = contentWarningCollapsible || longEnoughToCollapse
// use ?? instead of || because the content warnig pref takes priority over length
self.collapsed = collapseDueToContentWarning ?? longEnoughToCollapse
self.statusPropertiesHash = newHash
return true
}
}
private func hashStatusProperties(status: CollapseStateResolving) -> Int {
status.spoilerText.hashValue
}

View File

@ -39,6 +39,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
config: ComposeUIConfig(),
mastodonController: mastodonController,
fetchAvatar: { @MainActor in await ImageCache.avatars.get($0).1 },
fetchAttachment: { @MainActor in await ImageCache.attachments.get($0).1 },
fetchStatus: { mastodonController.persistentContainer.status(for: $0) },
displayNameLabel: { AnyView(AccountDisplayNameLabel(account: $0, textStyle: $1, emojiSize: $2)) },
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
@ -210,6 +211,10 @@ extension MastodonController: ComposeMastodonContext {
}
return results
}
func storeCreatedStatus(_ status: Status) {
persistentContainer.addOrUpdate(status: status)
}
}
extension ComposeHostingController: PHPickerViewControllerDelegate {

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

@ -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: [
UIAction(title: "Cancel", handler: { _ in }),
UIAction(title: "Delete Post", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [weak self] _ in
@ -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
// workaround for #198, may result in showing outdated relationship, so only do so where necessary
if !fetch || ProcessInfo.processInfo.isiOSAppOnMac,
let mo = mastodonController.persistentContainer.relationship(forAccount: accountID) {
elementHandler([builder(mo, mastodonController)])
if let action = builder(mo, mastodonController) {
elementHandler([action])
} else {
elementHandler([])
}
} else {
let relationship = Task {
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
}
Task { @MainActor in
if let relationship = await relationship.value {
elementHandler([builder(relationship, mastodonController)])
if let relationship = await relationship.value,
let action = builder(relationship, mastodonController) {
elementHandler([action])
} else {
elementHandler([])
}
@ -549,7 +589,11 @@ extension MenuActionProvider {
}
@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"
// todo: need alternate repeat icon to use here
return UIAction(title: title, image: nil) { [weak self] _ in

View File

@ -15,7 +15,7 @@ class AttachmentsContainerView: UIView {
weak var delegate: AttachmentViewDelegate?
var statusID: String!
private var attachmentTokens: [AttachmentToken] = []
var attachments: [Attachment]!
let attachmentViews: NSHashTable<AttachmentView> = .weakObjects()
@ -60,13 +60,16 @@ class AttachmentsContainerView: UIView {
// MARK: - User Interaface
func updateUI(status: StatusMO) {
guard self.statusID != status.id else {
func updateUI(attachments: [Attachment]) {
let attachments = attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) }
let newTokens = attachments.map { AttachmentToken(attachment: $0) }
guard self.attachmentTokens != newTokens else {
return
}
self.statusID = status.id
attachments = status.attachments.filter { AttachmentsContainerView.supportedAttachmentTypes.contains($0.kind) }
self.attachments = attachments
self.attachmentTokens = newTokens
attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
attachmentViews.removeAllObjects()
@ -461,3 +464,15 @@ fileprivate extension UIView {
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: [])
protocol BaseEmojiLabel: AnyObject {
var emojiIdentifier: String? { get set }
var emojiIdentifier: AnyHashable? { get set }
var emojiRequests: [ImageCache.Request] { get set }
var emojiFont: UIFont { get }
var emojiTextColor: UIColor { get }
}
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
precondition(Thread.isMainThread)
let identifier = AnyHashable(identifier)
emojiIdentifier = identifier
emojiRequests.forEach { $0.cancel() }
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)
}
}

View File

@ -42,7 +42,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
private(set) var hasEmojis = false
var emojiIdentifier: String?
var emojiIdentifier: AnyHashable?
var emojiRequests: [ImageCache.Request] = []
var emojiFont: UIFont { defaultFont }
var emojiTextColor: UIColor { defaultColor }
@ -81,7 +81,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
}
// 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
guard didReplaceEmojis else {
return

View File

@ -13,16 +13,16 @@ class EmojiLabel: UILabel, BaseEmojiLabel {
private(set) var hasEmojis = false
var emojiIdentifier: String?
var emojiIdentifier: AnyHashable?
var emojiRequests: [ImageCache.Request] = []
var emojiFont: UIFont { font }
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 }
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.attributedText = newAttributedText
self.setNeedsLayout()

View File

@ -12,14 +12,14 @@ import Pachyderm
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel {
var emojiIdentifier: String?
var emojiIdentifier: AnyHashable?
var emojiRequests = [ImageCache.Request]()
var emojiFont: UIFont { font }
var emojiTextColor: UIColor { textColor }
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 }
self.emojiIdentifier = identifier
@ -40,7 +40,7 @@ class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel {
self.replaceEmojis(in: string, emojis: emojis, identifier: identifier) { (attributedString, _) in
attributedStrings[index] = attributedString
DispatchQueue.main.async { [weak self] in
guard let self = self, self.emojiIdentifier == identifier else { return }
guard let self = self, self.emojiIdentifier == AnyHashable(identifier) else { return }
recombine()
}
}

View File

@ -59,6 +59,10 @@ class PollOptionCheckboxView: UIView {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
CGSize(width: 20, height: 20)
}
private func updateStyle() {
imageView.isHidden = !isChecked
if voted || readOnly {

View File

@ -145,18 +145,23 @@ class StatusPollView: UIView {
}
@objc private func votePressed() {
guard let statusID,
let poll else {
return
}
optionsView.isEnabled = false
voteButton.isEnabled = false
voteButton.disabledTitle = "Voted"
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
switch response {
case let .failure(error):
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 delegate = self.delegate {
let config = ToastConfiguration(from: error, with: "Error Voting", in: delegate, retryAction: nil)
@ -167,7 +172,7 @@ class StatusPollView: UIView {
case let .success(poll, _):
let container = self.mastodonController.persistentContainer
DispatchQueue.main.async {
guard let status = container.status(for: self.statusID) else {
guard let status = container.status(for: statusID) else {
return
}
status.poll = poll

View File

@ -116,7 +116,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$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.monospaceFont = ConversationMainStatusCollectionViewCell.monospaceFont
$0.contentTextView.paragraphStyle = ConversationMainStatusCollectionViewCell.contentParagraphStyle
@ -154,6 +154,12 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$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 {
$0.backgroundColor = .separator
NSLayoutConstraint.activate([
@ -171,6 +177,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
firstSeparator,
actionsCountHStack,
timestampAndClientLabel,
editTimestampButton,
secondSeparator,
]).configure {
$0.axis = .vertical
@ -341,11 +348,40 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
accountDetailAccessibilityElement.navigationDelegate = delegate
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)
if let application = status.applicationName {
timestampAndClientText += "\(application)"
}
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() {
@ -356,28 +392,6 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
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) {
baseUpdateUIForPreferences(status: status)
}
@ -441,6 +455,10 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
toggleReblog()
}
@objc private func editTimestampPressed() {
delegate?.show(StatusEditHistoryViewController(statusID: statusID, mastodonController: mastodonController), sender: nil)
}
}
private class ConversationMainStatusAccountDetailAccessibilityElement: UIAccessibilityElement {

View File

@ -24,7 +24,7 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
var usernameLabel: UILabel { get }
var contentWarningLabel: EmojiLabel { get }
var collapseButton: StatusCollapseButton { get }
var contentContainer: StatusContentContainer { get }
var contentContainer: StatusContentContainer<StatusContentTextView, StatusPollView> { get }
var replyButton: UIButton { get }
var favoriteButton: UIButton { get }
var reblogButton: UIButton { get }
@ -45,7 +45,6 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
var cancellables: Set<AnyCancellable> { get set }
func updateUIForPreferences(status: StatusMO)
func updateStatusState(status: StatusMO)
}
// MARK: UI Configuration
@ -58,11 +57,8 @@ extension StatusCollectionViewCell {
mastodonController.persistentContainer.statusSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.statusID }
.sink { [unowned self] in
if let mastodonController = self.mastodonController,
let status = mastodonController.persistentContainer.status(for: $0) {
self.updateStatusState(status: status)
}
.sink { [unowned self] _ in
self.delegate?.statusCellNeedsReconfigure(self, animated: true, completion: nil)
}
.store(in: &cancellables)
@ -87,7 +83,11 @@ extension StatusCollectionViewCell {
contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
contentContainer.contentTextView.navigationDelegate = delegate
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 {
contentContainer.cardView.updateUI(status: status)
contentContainer.cardView.isHidden = status.card == nil
@ -98,7 +98,6 @@ extension StatusCollectionViewCell {
}
updateUIForPreferences(status: status)
updateStatusState(status: status)
contentWarningLabel.text = status.spoilerText
contentWarningLabel.isHidden = status.spoilerText.isEmpty
@ -106,14 +105,36 @@ extension StatusCollectionViewCell {
contentWarningLabel.setEmojis(status.emojis, identifier: statusID)
}
reblogButton.isEnabled = reblogEnabled(status: status)
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
layoutIfNeeded()
statusState.resolveFor(status: status, height: contentContainer.visibleSubviewHeight)
return contentContainer.visibleSubviewHeight
}
if didResolve {
if statusState.collapsible! && showStatusAutomatically {
statusState.collapsed = false
}
@ -191,32 +212,6 @@ extension StatusCollectionViewCell {
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.delegate = delegate
contentContainer.pollView.updateUI(status: status, poll: status.poll)
}
func setShowThreadLinks(prev: Bool, next: Bool) {
if prev {
if let prevThreadLinkView {

View File

@ -8,7 +8,7 @@
import UIKit
class StatusContentContainer: UIView {
class StatusContentContainer<ContentView: ContentTextView, PollView: UIView>: UIView {
private var useTopSpacer = false
private let topSpacer = UIView().configure {
@ -17,7 +17,7 @@ class StatusContentContainer: UIView {
$0.heightAnchor.constraint(equalToConstant: 4).isActive = true
}
let contentTextView = StatusContentTextView().configure {
let contentTextView = ContentView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
@ -33,7 +33,7 @@ class StatusContentContainer: UIView {
let attachmentsView = AttachmentsContainerView()
let pollView = StatusPollView()
let pollView = PollView()
private var arrangedSubviews: [UIView] {
if useTopSpacer {

View File

@ -178,7 +178,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$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.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
@ -625,10 +625,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
}
}
func updateStatusState(status: StatusMO) {
baseUpdateStatusState(status: status)
}
private func updateTimestamp() {
guard let mastodonController,
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
updateTimestampWorkItem?.cancel()
timestampLabel.text = status.createdAt.timeAgoString()
let timeAgo = status.createdAt.timeAgoString()
timestampLabel.text = "\(timeAgo)\(status.editedAt != nil ? "*" : "")"
let delay: DispatchTimeInterval?
switch status.createdAt.timeAgo().1 {