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"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "6778575285177365cbad3e5b8a72f2a20583cfec",
"version" : "2.4.3"
}
},
{
"identity" : "swiftui-shimmer",
"kind" : "remoteSourceControl",

View file

@ -32,8 +32,12 @@ extension View {
func withSheetDestinations(sheetDestinations: Binding<SheetDestinations?>) -> some View {
self.sheet(item: sheetDestinations) { destination in
switch destination {
case let .statusEditor(replyToStatus):
StatusEditorView(inReplyTo: replyToStatus)
case let .replyToStatusEditor(status):
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 {
ToolbarItem(placement: .navigationBarLeading) {
Button {
routeurPath.presentedSheet = .statusEditor(replyToStatus: nil)
routeurPath.presentedSheet = .newStatusEditor
} label: {
Image(systemName: "square.and.pencil")
}

View file

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

View file

@ -14,12 +14,14 @@ let package = Package(
targets: ["Models"]),
],
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: [
.target(
name: "Models",
dependencies: ["HTML2Markdown"]),
dependencies: ["HTML2Markdown",
"SwiftSoup"]),
.testTarget(
name: "ModelsTests",
dependencies: ["Models"]),

View file

@ -1,5 +1,6 @@
import Foundation
import HTML2Markdown
import SwiftSoup
import SwiftUI
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 {
do {
// Add space between hashtags and mentions that follow each other

View file

@ -8,10 +8,12 @@ public struct Application: Codable, Identifiable {
}
public protocol AnyStatus {
var viewId: String { get }
var id: String { get }
var content: HTMLString { get }
var account: Account { get }
var createdAt: String { get }
var createdAt: ServerDate { get }
var editedAt: ServerDate? { get }
var mediaAttachments: [MediaAttachement] { get }
var mentions: [Mention] { get }
var repliesCount: Int { get }
@ -27,11 +29,17 @@ public protocol AnyStatus {
var inReplyToAccountId: String? { get }
}
public struct Status: AnyStatus, Codable, Identifiable {
public var viewId: String {
id + createdAt + (editedAt ?? "")
}
public let id: String
public let content: HTMLString
public let account: Account
public let createdAt: ServerDate
public let editedAt: ServerDate?
public let reblog: ReblogStatus?
public let mediaAttachments: [MediaAttachement]
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",
account: .placeholder(),
createdAt: "2022-12-16T10:20:54.000Z",
editedAt: nil,
reblog: nil,
mediaAttachments: [],
mentions: [],
@ -74,10 +83,15 @@ public struct Status: AnyStatus, Codable, Identifiable {
}
public struct ReblogStatus: AnyStatus, Codable, Identifiable {
public var viewId: String {
id + createdAt + (editedAt ?? "")
}
public let id: String
public let content: String
public let account: Account
public let createdAt: String
public let editedAt: ServerDate?
public let mediaAttachments: [MediaAttachement]
public let mentions: [Mention]
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 {
let (data, httpResponse) = try await urlSession.data(for: makeGet(endpoint: endpoint))
logResponseOnError(httpResponse: httpResponse, data: data)
return try decoder.decode(Entity.self, from: data)
try await makeEntityRequest(endpoint: endpoint, method: "GET")
}
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 {
let url = makeURL(endpoint: endpoint)
let request = makeURLRequest(url: url, httpMethod: "POST")
let (data, httpResponse) = try await urlSession.data(for: request)
logResponseOnError(httpResponse: httpResponse, data: data)
return try decoder.decode(Entity.self, from: data)
try await makeEntityRequest(endpoint: endpoint, method: "POST")
}
public func put<Entity: Decodable>(endpoint: Endpoint) async throws -> Entity {
try await makeEntityRequest(endpoint: endpoint, method: "PUT")
}
public func delete(endpoint: Endpoint) async throws -> HTTPURLResponse? {
@ -99,6 +97,14 @@ public class Client: ObservableObject, Equatable {
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 {
let app: InstanceApp = try await post(endpoint: Apps.registerApp)
self.oauthApp = app

View file

@ -5,6 +5,10 @@ public enum Statuses: Endpoint {
inReplyTo: String?,
mediaIds: [String]?,
spoilerText: String?)
case editStatus(id: String,
status: String,
mediaIds: [String]?,
spoilerText: String?)
case status(id: String)
case context(id: String)
case favourite(id: String)
@ -20,6 +24,8 @@ public enum Statuses: Endpoint {
return "statuses"
case .status(let id):
return "statuses/\(id)"
case .editStatus(let id, _, _, _):
return "statuses/\(id)"
case .context(let id):
return "statuses/\(id)/context"
case .favourite(let id):
@ -53,6 +59,17 @@ public enum Statuses: Endpoint {
params.append(.init(name: "spoiler_text", value: spoilerText))
}
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):
return makePaginationParam(sinceId: nil, maxId: maxId)
case let .favouritedBy(_, maxId):

View file

@ -18,7 +18,7 @@ let package = Package(
.package(name: "Network", path: "../Network"),
.package(name: "Env", path: "../Env"),
.package(name: "DesignSystem", path: "../DesignSystem"),
.package(url: "https://github.com/Dimillian/TextView", branch: "main")
.package(url: "https://github.com/Dimillian/TextView", branch: "main"),
],
targets: [
.target(
@ -28,7 +28,7 @@ let package = Package(
.product(name: "Network", package: "Network"),
.product(name: "Env", package: "Env"),
.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
public init(inReplyTo: Status?) {
_viewModel = StateObject(wrappedValue: .init(inReplyTo: inReplyTo))
public init(mode: StatusEditorViewModel.Mode) {
_viewModel = StateObject(wrappedValue: .init(mode: mode))
}
public var body: some View {
@ -33,10 +33,10 @@ public struct StatusEditorView: View {
}
.onAppear {
viewModel.client = client
viewModel.insertReplyTo()
viewModel.prepareStatusText()
}
.padding(.horizontal, DS.Constants.layoutPadding)
.navigationTitle("New post")
.navigationTitle(viewModel.mode.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {

View file

@ -5,7 +5,35 @@ import Network
import PhotosUI
@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: "") {
didSet {
guard !internalUpdate else { return }
@ -28,25 +56,33 @@ class StatusEditorViewModel: ObservableObject {
var client: Client?
private var internalUpdate: Bool = false
private var inReplyTo: Status?
let generator = UINotificationFeedbackGenerator()
init(inReplyTo: Status?) {
self.inReplyTo = inReplyTo
init(mode: Mode) {
self.mode = mode
}
func postStatus() async -> Status? {
guard let client else { return nil }
do {
isPosting = true
let status: Status = try await client.post(endpoint: Statuses.postStatus(status: statusText.string,
inReplyTo: inReplyTo?.id,
mediaIds: nil,
spoilerText: nil))
let postStatus: Status?
switch mode {
case .new, .replyTo:
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)
isPosting = false
return status
return postStatus
} catch {
isPosting = false
generator.notificationOccurred(.error)
@ -54,9 +90,14 @@ class StatusEditorViewModel: ObservableObject {
}
}
func insertReplyTo() {
if let inReplyTo {
statusText = .init(string: "@\(inReplyTo.account.acct) ")
func prepareStatusText() {
switch mode {
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):
Text(error.localizedDescription)
case let .display(statuses, nextPageState):
ForEach(statuses) { status in
ForEach(statuses, id: \.viewId) { status in
StatusRowView(viewModel: .init(status: status, isEmbed: false))
Divider()
.padding(.vertical, DS.Constants.dividerPadding)

View file

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

View file

@ -152,6 +152,11 @@ public struct StatusRowView: View {
}
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: {
Label("Delete", systemImage: "trash")
}

View file

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

View file

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