Editor: Simple edit
This commit is contained in:
parent
fded30bb76
commit
bda77571b6
|
@ -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",
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"]),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue