Editor: Simple edit

This commit is contained in:
Thomas Ricouard 2022-12-26 08:24:55 +01:00
parent fded30bb76
commit bda77571b6
17 changed files with 165 additions and 39 deletions

View file

@ -27,6 +27,15 @@
"version" : "11.5.0" "version" : "11.5.0"
} }
}, },
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "6778575285177365cbad3e5b8a72f2a20583cfec",
"version" : "2.4.3"
}
},
{ {
"identity" : "swiftui-shimmer", "identity" : "swiftui-shimmer",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View file

@ -32,8 +32,12 @@ extension View {
func withSheetDestinations(sheetDestinations: Binding<SheetDestinations?>) -> some View { func withSheetDestinations(sheetDestinations: Binding<SheetDestinations?>) -> some View {
self.sheet(item: sheetDestinations) { destination in self.sheet(item: sheetDestinations) { destination in
switch destination { switch destination {
case let .statusEditor(replyToStatus): case let .replyToStatusEditor(status):
StatusEditorView(inReplyTo: replyToStatus) StatusEditorView(mode: .replyTo(status: status))
case .newStatusEditor:
StatusEditorView(mode: .new)
case let .editStatusEditor(status):
StatusEditorView(mode: .edit(status: status))
} }
} }
} }

View file

@ -19,7 +19,7 @@ struct TimelineTab: View {
if client.isAuth { if client.isAuth {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button { Button {
routeurPath.presentedSheet = .statusEditor(replyToStatus: nil) routeurPath.presentedSheet = .newStatusEditor
} label: { } label: {
Image(systemName: "square.and.pencil") Image(systemName: "square.and.pencil")
} }

View file

@ -14,11 +14,13 @@ public enum RouteurDestinations: Hashable {
} }
public enum SheetDestinations: Identifiable { public enum SheetDestinations: Identifiable {
case statusEditor(replyToStatus: Status?) case newStatusEditor
case editStatusEditor(status: Status)
case replyToStatusEditor(status: Status)
public var id: String { public var id: String {
switch self { switch self {
case .statusEditor: case .editStatusEditor, .newStatusEditor, .replyToStatusEditor:
return "statusEditor" return "statusEditor"
} }
} }

View file

@ -14,12 +14,14 @@ let package = Package(
targets: ["Models"]), targets: ["Models"]),
], ],
dependencies: [ dependencies: [
.package(url: "https://gitlab.com/mflint/HTML2Markdown", exact: "1.0.0") .package(url: "https://gitlab.com/mflint/HTML2Markdown", exact: "1.0.0"),
.package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.4.3"),
], ],
targets: [ targets: [
.target( .target(
name: "Models", name: "Models",
dependencies: ["HTML2Markdown"]), dependencies: ["HTML2Markdown",
"SwiftSoup"]),
.testTarget( .testTarget(
name: "ModelsTests", name: "ModelsTests",
dependencies: ["Models"]), dependencies: ["Models"]),

View file

@ -1,5 +1,6 @@
import Foundation import Foundation
import HTML2Markdown import HTML2Markdown
import SwiftSoup
import SwiftUI import SwiftUI
public typealias HTMLString = String public typealias HTMLString = String
@ -14,6 +15,15 @@ extension HTMLString {
} }
} }
public var asRawText: String {
do {
let document: Document = try SwiftSoup.parse(self)
return try document.text()
} catch {
return self
}
}
public var asSafeAttributedString: AttributedString { public var asSafeAttributedString: AttributedString {
do { do {
// Add space between hashtags and mentions that follow each other // Add space between hashtags and mentions that follow each other

View file

@ -8,10 +8,12 @@ public struct Application: Codable, Identifiable {
} }
public protocol AnyStatus { public protocol AnyStatus {
var viewId: String { get }
var id: String { get } var id: String { get }
var content: HTMLString { get } var content: HTMLString { get }
var account: Account { get } var account: Account { get }
var createdAt: String { get } var createdAt: ServerDate { get }
var editedAt: ServerDate? { get }
var mediaAttachments: [MediaAttachement] { get } var mediaAttachments: [MediaAttachement] { get }
var mentions: [Mention] { get } var mentions: [Mention] { get }
var repliesCount: Int { get } var repliesCount: Int { get }
@ -27,11 +29,17 @@ public protocol AnyStatus {
var inReplyToAccountId: String? { get } var inReplyToAccountId: String? { get }
} }
public struct Status: AnyStatus, Codable, Identifiable { public struct Status: AnyStatus, Codable, Identifiable {
public var viewId: String {
id + createdAt + (editedAt ?? "")
}
public let id: String public let id: String
public let content: HTMLString public let content: HTMLString
public let account: Account public let account: Account
public let createdAt: ServerDate public let createdAt: ServerDate
public let editedAt: ServerDate?
public let reblog: ReblogStatus? public let reblog: ReblogStatus?
public let mediaAttachments: [MediaAttachement] public let mediaAttachments: [MediaAttachement]
public let mentions: [Mention] public let mentions: [Mention]
@ -52,6 +60,7 @@ public struct Status: AnyStatus, Codable, Identifiable {
content: "Some post content\n Some more post content \n Some more", content: "Some post content\n Some more post content \n Some more",
account: .placeholder(), account: .placeholder(),
createdAt: "2022-12-16T10:20:54.000Z", createdAt: "2022-12-16T10:20:54.000Z",
editedAt: nil,
reblog: nil, reblog: nil,
mediaAttachments: [], mediaAttachments: [],
mentions: [], mentions: [],
@ -74,10 +83,15 @@ public struct Status: AnyStatus, Codable, Identifiable {
} }
public struct ReblogStatus: AnyStatus, Codable, Identifiable { public struct ReblogStatus: AnyStatus, Codable, Identifiable {
public var viewId: String {
id + createdAt + (editedAt ?? "")
}
public let id: String public let id: String
public let content: String public let content: String
public let account: Account public let account: Account
public let createdAt: String public let createdAt: String
public let editedAt: ServerDate?
public let mediaAttachments: [MediaAttachement] public let mediaAttachments: [MediaAttachement]
public let mentions: [Mention] public let mentions: [Mention]
public let repliesCount: Int public let repliesCount: Int

View file

@ -68,9 +68,7 @@ public class Client: ObservableObject, Equatable {
} }
public func get<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity { public func get<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
let (data, httpResponse) = try await urlSession.data(for: makeGet(endpoint: endpoint)) try await makeEntityRequest(endpoint: endpoint, method: "GET")
logResponseOnError(httpResponse: httpResponse, data: data)
return try decoder.decode(Entity.self, from: data)
} }
public func getWithLink<Entity: Decodable>(endpoint: Endpoint) async throws -> (Entity, LinkHandler?) { public func getWithLink<Entity: Decodable>(endpoint: Endpoint) async throws -> (Entity, LinkHandler?) {
@ -85,11 +83,11 @@ public class Client: ObservableObject, Equatable {
} }
public func post<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity { public func post<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
let url = makeURL(endpoint: endpoint) try await makeEntityRequest(endpoint: endpoint, method: "POST")
let request = makeURLRequest(url: url, httpMethod: "POST") }
let (data, httpResponse) = try await urlSession.data(for: request)
logResponseOnError(httpResponse: httpResponse, data: data) public func put<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
return try decoder.decode(Entity.self, from: data) try await makeEntityRequest(endpoint: endpoint, method: "PUT")
} }
public func delete(endpoint: Endpoint) async throws -> HTTPURLResponse? { public func delete(endpoint: Endpoint) async throws -> HTTPURLResponse? {
@ -99,6 +97,14 @@ public class Client: ObservableObject, Equatable {
return httpResponse as? HTTPURLResponse return httpResponse as? HTTPURLResponse
} }
private func makeEntityRequest<Entity: Decodable>(endpoint: Endpoint, method: String) async throws -> Entity {
let url = makeURL(endpoint: endpoint)
let request = makeURLRequest(url: url, httpMethod: method)
let (data, httpResponse) = try await urlSession.data(for: request)
logResponseOnError(httpResponse: httpResponse, data: data)
return try decoder.decode(Entity.self, from: data)
}
public func oauthURL() async throws -> URL { public func oauthURL() async throws -> URL {
let app: InstanceApp = try await post(endpoint: Apps.registerApp) let app: InstanceApp = try await post(endpoint: Apps.registerApp)
self.oauthApp = app self.oauthApp = app

View file

@ -5,6 +5,10 @@ public enum Statuses: Endpoint {
inReplyTo: String?, inReplyTo: String?,
mediaIds: [String]?, mediaIds: [String]?,
spoilerText: String?) spoilerText: String?)
case editStatus(id: String,
status: String,
mediaIds: [String]?,
spoilerText: String?)
case status(id: String) case status(id: String)
case context(id: String) case context(id: String)
case favourite(id: String) case favourite(id: String)
@ -20,6 +24,8 @@ public enum Statuses: Endpoint {
return "statuses" return "statuses"
case .status(let id): case .status(let id):
return "statuses/\(id)" return "statuses/\(id)"
case .editStatus(let id, _, _, _):
return "statuses/\(id)"
case .context(let id): case .context(let id):
return "statuses/\(id)/context" return "statuses/\(id)/context"
case .favourite(let id): case .favourite(let id):
@ -53,6 +59,17 @@ public enum Statuses: Endpoint {
params.append(.init(name: "spoiler_text", value: spoilerText)) params.append(.init(name: "spoiler_text", value: spoilerText))
} }
return params return params
case let .editStatus(_, status, mediaIds, spoilerText):
var params: [URLQueryItem] = [.init(name: "status", value: status)]
if let mediaIds {
for mediaId in mediaIds {
params.append(.init(name: "media_ids[]", value: mediaId))
}
}
if let spoilerText {
params.append(.init(name: "spoiler_text", value: spoilerText))
}
return params
case let .rebloggedBy(_, maxId): case let .rebloggedBy(_, maxId):
return makePaginationParam(sinceId: nil, maxId: maxId) return makePaginationParam(sinceId: nil, maxId: maxId)
case let .favouritedBy(_, maxId): case let .favouritedBy(_, maxId):

View file

@ -18,7 +18,7 @@ let package = Package(
.package(name: "Network", path: "../Network"), .package(name: "Network", path: "../Network"),
.package(name: "Env", path: "../Env"), .package(name: "Env", path: "../Env"),
.package(name: "DesignSystem", path: "../DesignSystem"), .package(name: "DesignSystem", path: "../DesignSystem"),
.package(url: "https://github.com/Dimillian/TextView", branch: "main") .package(url: "https://github.com/Dimillian/TextView", branch: "main"),
], ],
targets: [ targets: [
.target( .target(
@ -28,7 +28,7 @@ let package = Package(
.product(name: "Network", package: "Network"), .product(name: "Network", package: "Network"),
.product(name: "Env", package: "Env"), .product(name: "Env", package: "Env"),
.product(name: "DesignSystem", package: "DesignSystem"), .product(name: "DesignSystem", package: "DesignSystem"),
.product(name: "TextView", package: "TextView") .product(name: "TextView", package: "TextView"),
]), ]),
] ]
) )

View file

@ -14,8 +14,8 @@ public struct StatusEditorView: View {
@StateObject private var viewModel: StatusEditorViewModel @StateObject private var viewModel: StatusEditorViewModel
public init(inReplyTo: Status?) { public init(mode: StatusEditorViewModel.Mode) {
_viewModel = StateObject(wrappedValue: .init(inReplyTo: inReplyTo)) _viewModel = StateObject(wrappedValue: .init(mode: mode))
} }
public var body: some View { public var body: some View {
@ -33,10 +33,10 @@ public struct StatusEditorView: View {
} }
.onAppear { .onAppear {
viewModel.client = client viewModel.client = client
viewModel.insertReplyTo() viewModel.prepareStatusText()
} }
.padding(.horizontal, DS.Constants.layoutPadding) .padding(.horizontal, DS.Constants.layoutPadding)
.navigationTitle("New post") .navigationTitle(viewModel.mode.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {

View file

@ -5,7 +5,35 @@ import Network
import PhotosUI import PhotosUI
@MainActor @MainActor
class StatusEditorViewModel: ObservableObject { public class StatusEditorViewModel: ObservableObject {
public enum Mode {
case replyTo(status: Status)
case new
case edit(status: Status)
var replyToStatus: Status? {
switch self {
case let .replyTo(status):
return status
default:
return nil
}
}
var title: String {
switch self {
case .new:
return "New Post"
case .edit:
return "Edit your post"
case let .replyTo(status):
return "Reply to \(status.account.displayName)"
}
}
}
let mode: Mode
@Published var statusText = NSAttributedString(string: "") { @Published var statusText = NSAttributedString(string: "") {
didSet { didSet {
guard !internalUpdate else { return } guard !internalUpdate else { return }
@ -28,25 +56,33 @@ class StatusEditorViewModel: ObservableObject {
var client: Client? var client: Client?
private var internalUpdate: Bool = false private var internalUpdate: Bool = false
private var inReplyTo: Status?
let generator = UINotificationFeedbackGenerator() let generator = UINotificationFeedbackGenerator()
init(inReplyTo: Status?) { init(mode: Mode) {
self.inReplyTo = inReplyTo self.mode = mode
} }
func postStatus() async -> Status? { func postStatus() async -> Status? {
guard let client else { return nil } guard let client else { return nil }
do { do {
isPosting = true isPosting = true
let status: Status = try await client.post(endpoint: Statuses.postStatus(status: statusText.string, let postStatus: Status?
inReplyTo: inReplyTo?.id, switch mode {
mediaIds: nil, case .new, .replyTo:
spoilerText: nil)) postStatus = try await client.post(endpoint: Statuses.postStatus(status: statusText.string,
inReplyTo: mode.replyToStatus?.id,
mediaIds: nil,
spoilerText: nil))
case let .edit(status):
postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id,
status: statusText.string,
mediaIds: nil,
spoilerText: nil))
}
generator.notificationOccurred(.success) generator.notificationOccurred(.success)
isPosting = false isPosting = false
return status return postStatus
} catch { } catch {
isPosting = false isPosting = false
generator.notificationOccurred(.error) generator.notificationOccurred(.error)
@ -54,9 +90,14 @@ class StatusEditorViewModel: ObservableObject {
} }
} }
func insertReplyTo() { func prepareStatusText() {
if let inReplyTo { switch mode {
statusText = .init(string: "@\(inReplyTo.account.acct) ") case let .replyTo(status):
statusText = .init(string: "@\(status.account.acct) ")
case let .edit(status):
statusText = .init(string: status.content.asRawText)
default:
break
} }
} }

View file

@ -24,7 +24,7 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
case let .error(error): case let .error(error):
Text(error.localizedDescription) Text(error.localizedDescription)
case let .display(statuses, nextPageState): case let .display(statuses, nextPageState):
ForEach(statuses) { status in ForEach(statuses, id: \.viewId) { status in
StatusRowView(viewModel: .init(status: status, isEmbed: false)) StatusRowView(viewModel: .init(status: status, isEmbed: false))
Divider() Divider()
.padding(.vertical, DS.Constants.dividerPadding) .padding(.vertical, DS.Constants.dividerPadding)

View file

@ -128,7 +128,7 @@ struct StatusActionsView: View {
generator.notificationOccurred(.success) generator.notificationOccurred(.success)
switch action { switch action {
case .respond: case .respond:
routeurPath.presentedSheet = .statusEditor(replyToStatus: viewModel.status) routeurPath.presentedSheet = .replyToStatusEditor(status: viewModel.status)
case .favourite: case .favourite:
if viewModel.isFavourited { if viewModel.isFavourited {
await viewModel.unFavourite() await viewModel.unFavourite()

View file

@ -152,6 +152,11 @@ public struct StatusRowView: View {
} }
if account.account?.id == viewModel.status.account.id { if account.account?.id == viewModel.status.account.id {
Button {
routeurPath.presentedSheet = .editStatusEditor(status: viewModel.status)
} label: {
Label("Edit", systemImage: "pencil")
}
Button(role: .destructive) { Task { await viewModel.delete() } } label: { Button(role: .destructive) { Task { await viewModel.delete() } } label: {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }

View file

@ -7,6 +7,7 @@ import DesignSystem
import Env import Env
public struct TimelineView: View { public struct TimelineView: View {
@Environment(\.scenePhase) private var scenePhase
@EnvironmentObject private var account: CurrentAccount @EnvironmentObject private var account: CurrentAccount
@EnvironmentObject private var watcher: StreamWatcher @EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@ -53,6 +54,16 @@ public struct TimelineView: View {
.onChange(of: timeline) { newTimeline in .onChange(of: timeline) { newTimeline in
viewModel.timeline = timeline viewModel.timeline = timeline
} }
.onChange(of: scenePhase, perform: { scenePhase in
switch scenePhase {
case .active:
Task {
await viewModel.fetchStatuses(userIntent: false)
}
default:
break
}
})
} }
@ViewBuilder @ViewBuilder

View file

@ -17,6 +17,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
Task { Task {
if oldValue != timeline { if oldValue != timeline {
statuses = [] statuses = []
pendingStatuses = []
} }
await fetchStatuses(userIntent: false) await fetchStatuses(userIntent: false)
switch timeline { switch timeline {
@ -61,18 +62,22 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
func fetchStatuses(userIntent: Bool) async { func fetchStatuses(userIntent: Bool) async {
guard let client else { return } guard let client else { return }
do { do {
pendingStatuses = []
if statuses.isEmpty { if statuses.isEmpty {
pendingStatuses = []
statusesState = .loading statusesState = .loading
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil, maxId: nil)) statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil, maxId: nil))
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} else if let first = statuses.first { } else if let first = statuses.first {
let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: first.id, maxId: nil)) var newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: first.id, maxId: nil))
if userIntent || !pendingStatusesEnabled { if userIntent || !pendingStatusesEnabled {
pendingStatuses = []
statuses.insert(contentsOf: newStatuses, at: 0) statuses.insert(contentsOf: newStatuses, at: 0)
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} else { } else {
pendingStatuses = newStatuses newStatuses = newStatuses.filter { status in
!pendingStatuses.contains(where: { $0.id == status.id })
}
pendingStatuses.insert(contentsOf: newStatuses, at: 0)
pendingStatusesState = .refresh pendingStatusesState = .refresh
} }
} }