mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-12-22 15:16:36 +00:00
Post / Delete a status and watch main timeline
This commit is contained in:
parent
8df70043cb
commit
93543cad6b
11 changed files with 313 additions and 21 deletions
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
118
Packages/Env/Sources/Env/StreamWatcher.swift
Normal file
118
Packages/Env/Sources/Env/StreamWatcher.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
39
Packages/Models/Sources/Models/Stream/StreamEvent.swift
Normal file
39
Packages/Models/Sources/Models/Stream/StreamEvent.swift
Normal 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
|
||||
}
|
||||
}
|
12
Packages/Models/Sources/Models/Stream/StreamMessage.swift
Normal file
12
Packages/Models/Sources/Models/Stream/StreamMessage.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
19
Packages/Network/Sources/Network/Endpoint/Streaming.swift
Normal file
19
Packages/Network/Sources/Network/Endpoint/Streaming.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
internalUpdate = true
|
||||
statusText = mutableString
|
||||
internalUpdate = false
|
||||
} catch {
|
||||
|
||||
for range in ranges {
|
||||
mutableString.addAttributes([.foregroundColor: UIColor(Color.brand)],
|
||||
range: NSRange(location: range.location, length: range.length))
|
||||
}
|
||||
internalUpdate = true
|
||||
statusText = mutableString
|
||||
internalUpdate = false
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue