Post / Delete a status and watch main timeline

This commit is contained in:
Thomas Ricouard 2022-12-25 12:46:42 +01:00
parent 8df70043cb
commit 93543cad6b
11 changed files with 313 additions and 21 deletions

View file

@ -13,8 +13,10 @@ struct IceCubesApp: App {
public static let defaultServer = "mastodon.social"
@Environment(\.scenePhase) private var scenePhase
@StateObject private var appAccountsManager = AppAccountsManager()
@StateObject private var currentAccount = CurrentAccount()
@StateObject private var watcher = StreamWatcher()
@StateObject private var quickLook = QuickLook()
@StateObject private var theme = Theme()
@ -66,16 +68,34 @@ struct IceCubesApp: App {
.tint(theme.tintColor)
.onChange(of: appAccountsManager.currentClient) { newClient in
currentAccount.setClient(client: newClient)
watcher.setClient(client: newClient)
if newClient.isAuth {
watcher.watch(stream: .user)
}
}
.onAppear {
currentAccount.setClient(client: appAccountsManager.currentClient)
watcher.setClient(client: appAccountsManager.currentClient)
}
.environmentObject(appAccountsManager)
.environmentObject(appAccountsManager.currentClient)
.environmentObject(quickLook)
.environmentObject(currentAccount)
.environmentObject(theme)
.environmentObject(watcher)
.quickLookPreview($quickLook.url, in: quickLook.urls)
}
.onChange(of: scenePhase, perform: { scenePhase in
switch scenePhase {
case .background:
watcher.stopWatching()
case .active:
watcher.watch(stream: .user)
case .inactive:
break
default:
break
}
})
}
}

View file

@ -0,0 +1,118 @@
import Foundation
import Models
import Network
@MainActor
public class StreamWatcher: ObservableObject {
private var client: Client?
private var task: URLSessionWebSocketTask?
private var watchedStream: Stream?
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
public enum Stream: String {
case publicTimeline = "public"
case user
}
@Published public var events: [any StreamEvent] = []
@Published public var latestEvent: (any StreamEvent)?
public init() {
decoder.keyDecodingStrategy = .convertFromSnakeCase
}
public func setClient(client: Client) {
if self.client != nil {
stopWatching()
}
self.client = client
connect()
}
private func connect() {
task = client?.makeWebSocketTask(endpoint: Streaming.streaming)
task?.resume()
receiveMessage()
}
public func watch(stream: Stream) {
if task == nil {
connect()
}
watchedStream = stream
sendMessage(message: StreamMessage(type: "subscribe", stream: stream.rawValue))
}
public func stopWatching() {
task?.cancel()
task = nil
}
private func sendMessage(message: StreamMessage) {
task?.send(.data(try! encoder.encode(message)),
completionHandler: { _ in })
}
private func receiveMessage() {
task?.receive(completionHandler: { result in
switch result {
case let .success(message):
switch message {
case let .string(string):
do {
guard let data = string.data(using: .utf8) else {
print("Error decoding streaming event string")
return
}
let rawEvent = try self.decoder.decode(RawStreamEvent.self, from: data)
if let event = self.rawEventToEvent(rawEvent: rawEvent) {
Task { @MainActor in
self.events.append(event)
self.latestEvent = event
}
}
} catch {
print("Error decoding streaming event: \(error.localizedDescription)")
}
default:
break
}
case .failure:
self.stopWatching()
self.connect()
if let watchedStream = self.watchedStream {
self.watch(stream: watchedStream)
}
}
self.receiveMessage()
})
}
private func rawEventToEvent(rawEvent: RawStreamEvent) -> (any StreamEvent)? {
guard let payloadData = rawEvent.payload.data(using: .utf8) else {
return nil
}
do {
switch rawEvent.event {
case "update":
let status = try decoder.decode(Status.self, from: payloadData)
return StreamEventUpdate(status: status)
case "delete":
return StreamEventDelete(status: rawEvent.payload)
case "notification":
let notification = try decoder.decode(Notification.self, from: payloadData)
return StreamEventNotification(notification: notification)
default:
return nil
}
} catch {
print("Error decoding streaming event to final event: \(error.localizedDescription)")
print("Raw data: \(rawEvent.payload)")
return nil
}
}
}

View file

@ -0,0 +1,39 @@
import Foundation
public struct RawStreamEvent: Decodable {
public let event: String
public let stream: [String]
public let payload: String
}
public protocol StreamEvent: Identifiable{
var date: Date { get }
var id: String { get }
}
public struct StreamEventUpdate: StreamEvent {
public let date = Date()
public var id: String { status.id }
public let status: Status
public init(status: Status) {
self.status = status
}
}
public struct StreamEventDelete: StreamEvent {
public let date = Date()
public var id: String { status + date.description }
public let status: String
public init(status: String) {
self.status = status
}
}
public struct StreamEventNotification: StreamEvent {
public let date = Date()
public var id: String { notification.id }
public let notification: Notification
public init(notification: Notification) {
self.notification = notification
}
}

View file

@ -0,0 +1,12 @@
import Foundation
public struct StreamMessage: Encodable {
public let type: String
public let stream: String
public init(type: String, stream: String) {
self.type = type
self.stream = stream
}
}

View file

@ -40,9 +40,9 @@ public class Client: ObservableObject, Equatable {
self.oauthToken = oauthToken
}
private func makeURL(endpoint: Endpoint) -> URL {
private func makeURL(scheme: String = "https", endpoint: Endpoint) -> URL {
var components = URLComponents()
components.scheme = "https"
components.scheme = scheme
components.host = server
if type(of: endpoint) == Oauth.self {
components.path += "/\(endpoint.path())"
@ -92,6 +92,13 @@ public class Client: ObservableObject, Equatable {
return try decoder.decode(Entity.self, from: data)
}
public func delete(endpoint: Endpoint) async throws -> HTTPURLResponse? {
let url = makeURL(endpoint: endpoint)
let request = makeURLRequest(url: url, httpMethod: "DELETE")
let (_, httpResponse) = try await urlSession.data(for: request)
return httpResponse as? HTTPURLResponse
}
public func oauthURL() async throws -> URL {
let app: InstanceApp = try await post(endpoint: Apps.registerApp)
self.oauthApp = app
@ -113,6 +120,12 @@ public class Client: ObservableObject, Equatable {
return token
}
public func makeWebSocketTask(endpoint: Endpoint) -> URLSessionWebSocketTask {
let url = makeURL(scheme: "wss", endpoint: endpoint)
let request = makeURLRequest(url: url, httpMethod: "GET")
return urlSession.webSocketTask(with: request)
}
private func logResponseOnError(httpResponse: URLResponse, data: Data) {
if let httpResponse = httpResponse as? HTTPURLResponse, httpResponse.statusCode > 299 {
print(httpResponse)

View file

@ -0,0 +1,19 @@
import Foundation
public enum Streaming: Endpoint {
case streaming
public func path() -> String {
switch self {
case .streaming:
return "streaming"
}
}
public func queryItems() -> [URLQueryItem]? {
switch self {
default:
return nil
}
}
}

View file

@ -45,22 +45,27 @@ class StatusEditorViewModel: ObservableObject {
let mentionPattern = "(@+[a-zA-Z0-9(_).]{1,})"
var ranges: [NSRange] = [NSRange]()
let hashtagRegex = try! NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionRegex = try! NSRegularExpression(pattern: mentionPattern, options: [])
ranges = hashtagRegex.matches(in: mutableString.string,
options: [],
range: NSMakeRange(0, mutableString.string.utf8.count)).map {$0.range}
ranges.append(contentsOf: mentionRegex.matches(in: mutableString.string,
options: [],
range: NSMakeRange(0, mutableString.string.utf8.count)).map {$0.range})
do {
let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: [])
ranges = hashtagRegex.matches(in: mutableString.string,
options: [],
range: NSMakeRange(0, mutableString.string.utf16.count)).map { $0.range }
ranges.append(contentsOf: mentionRegex.matches(in: mutableString.string,
options: [],
range: NSMakeRange(0, mutableString.string.utf16.count)).map {$0.range})
for range in ranges {
mutableString.addAttributes([.foregroundColor: UIColor(Color.brand)],
range: NSRange(location: range.location, length: range.length))
for range in ranges {
mutableString.addAttributes([.foregroundColor: UIColor(Color.brand)],
range: NSRange(location: range.location, length: range.length))
}
internalUpdate = true
statusText = mutableString
internalUpdate = false
} catch {
}
internalUpdate = true
statusText = mutableString
internalUpdate = false
}
}

View file

@ -6,6 +6,7 @@ import Network
public struct StatusRowView: View {
@Environment(\.redactionReasons) private var reasons
@EnvironmentObject private var account: CurrentAccount
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
@EnvironmentObject private var routeurPath: RouterPath
@ -72,11 +73,15 @@ public struct StatusRowView: View {
VStack(alignment: .leading, spacing: 8) {
if let status: AnyStatus = viewModel.status.reblog ?? viewModel.status {
if !viewModel.isEmbed {
Button {
routeurPath.navigate(to: .accountDetailWithAccount(account: status.account))
} label: {
makeAccountView(status: status)
}.buttonStyle(.plain)
HStack(alignment: .top) {
Button {
routeurPath.navigate(to: .accountDetailWithAccount(account: status.account))
} label: {
makeAccountView(status: status)
}.buttonStyle(.plain)
Spacer()
menuButton
}
}
Text(status.content.asSafeAttributedString)
@ -119,4 +124,37 @@ public struct StatusRowView: View {
.foregroundColor(.gray)
}
}
private var menuButton: some View {
Menu {
contextMenu
} label: {
Image(systemName: "ellipsis")
}
.foregroundColor(.gray)
}
@ViewBuilder
private var contextMenu: some View {
Button { Task {
if viewModel.isFavourited {
await viewModel.unFavourite()
} else {
await viewModel.favourite()
}
} } label: {
Label(viewModel.isFavourited ? "Unfavorite" : "Favorite", systemImage: "star")
}
if let url = viewModel.status.reblog?.url ?? viewModel.status.url {
Button { UIApplication.shared.open(url) } label: {
Label("View in Browser", systemImage: "safari")
}
}
if account.account?.id == viewModel.status.account.id {
Button(role: .destructive) { Task { await viewModel.delete() } } label: {
Label("Delete", systemImage: "trash")
}
}
}
}

View file

@ -86,6 +86,13 @@ public class StatusRowViewModel: ObservableObject {
}
}
func delete() async {
guard let client else { return }
do {
_ = try await client.delete(endpoint: Statuses.status(id: status.id))
} catch { }
}
private func updateFromStatus(status: Status) {
if let reblog = status.reblog {
isFavourited = reblog.favourited == true

View file

@ -4,8 +4,10 @@ import Models
import Shimmer
import Status
import DesignSystem
import Env
public struct TimelineView: View {
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var client: Client
@StateObject private var viewModel = TimelineViewModel()
@ -44,6 +46,11 @@ public struct TimelineView: View {
.refreshable {
await viewModel.fetchStatuses()
}
.onChange(of: watcher.latestEvent?.id) { id in
if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent)
}
}
}
@ViewBuilder

View file

@ -13,6 +13,9 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
@Published var timeline: TimelineFilter = .pub {
didSet {
Task {
if oldValue != timeline {
statuses = []
}
await fetchStatuses()
switch timeline {
case let .hashtag(tag, _):
@ -79,4 +82,15 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
tag = try await client.post(endpoint: Tags.unfollow(id: id))
} catch {}
}
func handleEvent(event: any StreamEvent) {
guard timeline == .home else { return }
if let event = event as? StreamEventUpdate {
statuses.insert(event.status, at: 0)
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} else if let event = event as? StreamEventDelete {
statuses.removeAll(where: { $0.id == event.status })
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
}
}
}