mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2025-01-03 12:58:50 +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"
|
public static let defaultServer = "mastodon.social"
|
||||||
|
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@StateObject private var appAccountsManager = AppAccountsManager()
|
@StateObject private var appAccountsManager = AppAccountsManager()
|
||||||
@StateObject private var currentAccount = CurrentAccount()
|
@StateObject private var currentAccount = CurrentAccount()
|
||||||
|
@StateObject private var watcher = StreamWatcher()
|
||||||
@StateObject private var quickLook = QuickLook()
|
@StateObject private var quickLook = QuickLook()
|
||||||
@StateObject private var theme = Theme()
|
@StateObject private var theme = Theme()
|
||||||
|
|
||||||
|
@ -66,16 +68,34 @@ struct IceCubesApp: App {
|
||||||
.tint(theme.tintColor)
|
.tint(theme.tintColor)
|
||||||
.onChange(of: appAccountsManager.currentClient) { newClient in
|
.onChange(of: appAccountsManager.currentClient) { newClient in
|
||||||
currentAccount.setClient(client: newClient)
|
currentAccount.setClient(client: newClient)
|
||||||
|
watcher.setClient(client: newClient)
|
||||||
|
if newClient.isAuth {
|
||||||
|
watcher.watch(stream: .user)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
currentAccount.setClient(client: appAccountsManager.currentClient)
|
currentAccount.setClient(client: appAccountsManager.currentClient)
|
||||||
|
watcher.setClient(client: appAccountsManager.currentClient)
|
||||||
}
|
}
|
||||||
.environmentObject(appAccountsManager)
|
.environmentObject(appAccountsManager)
|
||||||
.environmentObject(appAccountsManager.currentClient)
|
.environmentObject(appAccountsManager.currentClient)
|
||||||
.environmentObject(quickLook)
|
.environmentObject(quickLook)
|
||||||
.environmentObject(currentAccount)
|
.environmentObject(currentAccount)
|
||||||
.environmentObject(theme)
|
.environmentObject(theme)
|
||||||
|
.environmentObject(watcher)
|
||||||
.quickLookPreview($quickLook.url, in: quickLook.urls)
|
.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
|
self.oauthToken = oauthToken
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeURL(endpoint: Endpoint) -> URL {
|
private func makeURL(scheme: String = "https", endpoint: Endpoint) -> URL {
|
||||||
var components = URLComponents()
|
var components = URLComponents()
|
||||||
components.scheme = "https"
|
components.scheme = scheme
|
||||||
components.host = server
|
components.host = server
|
||||||
if type(of: endpoint) == Oauth.self {
|
if type(of: endpoint) == Oauth.self {
|
||||||
components.path += "/\(endpoint.path())"
|
components.path += "/\(endpoint.path())"
|
||||||
|
@ -92,6 +92,13 @@ public class Client: ObservableObject, Equatable {
|
||||||
return try decoder.decode(Entity.self, from: data)
|
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 {
|
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
|
||||||
|
@ -113,6 +120,12 @@ public class Client: ObservableObject, Equatable {
|
||||||
return token
|
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) {
|
private func logResponseOnError(httpResponse: URLResponse, data: Data) {
|
||||||
if let httpResponse = httpResponse as? HTTPURLResponse, httpResponse.statusCode > 299 {
|
if let httpResponse = httpResponse as? HTTPURLResponse, httpResponse.statusCode > 299 {
|
||||||
print(httpResponse)
|
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,14 +45,16 @@ class StatusEditorViewModel: ObservableObject {
|
||||||
let mentionPattern = "(@+[a-zA-Z0-9(_).]{1,})"
|
let mentionPattern = "(@+[a-zA-Z0-9(_).]{1,})"
|
||||||
var ranges: [NSRange] = [NSRange]()
|
var ranges: [NSRange] = [NSRange]()
|
||||||
|
|
||||||
let hashtagRegex = try! NSRegularExpression(pattern: hashtagPattern, options: [])
|
do {
|
||||||
let mentionRegex = try! NSRegularExpression(pattern: mentionPattern, options: [])
|
let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: [])
|
||||||
|
let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: [])
|
||||||
|
|
||||||
ranges = hashtagRegex.matches(in: mutableString.string,
|
ranges = hashtagRegex.matches(in: mutableString.string,
|
||||||
options: [],
|
options: [],
|
||||||
range: NSMakeRange(0, mutableString.string.utf8.count)).map {$0.range}
|
range: NSMakeRange(0, mutableString.string.utf16.count)).map { $0.range }
|
||||||
ranges.append(contentsOf: mentionRegex.matches(in: mutableString.string,
|
ranges.append(contentsOf: mentionRegex.matches(in: mutableString.string,
|
||||||
options: [],
|
options: [],
|
||||||
range: NSMakeRange(0, mutableString.string.utf8.count)).map {$0.range})
|
range: NSMakeRange(0, mutableString.string.utf16.count)).map {$0.range})
|
||||||
|
|
||||||
for range in ranges {
|
for range in ranges {
|
||||||
mutableString.addAttributes([.foregroundColor: UIColor(Color.brand)],
|
mutableString.addAttributes([.foregroundColor: UIColor(Color.brand)],
|
||||||
|
@ -61,6 +63,9 @@ class StatusEditorViewModel: ObservableObject {
|
||||||
internalUpdate = true
|
internalUpdate = true
|
||||||
statusText = mutableString
|
statusText = mutableString
|
||||||
internalUpdate = false
|
internalUpdate = false
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import Network
|
||||||
|
|
||||||
public struct StatusRowView: View {
|
public struct StatusRowView: View {
|
||||||
@Environment(\.redactionReasons) private var reasons
|
@Environment(\.redactionReasons) private var reasons
|
||||||
|
@EnvironmentObject private var account: CurrentAccount
|
||||||
@EnvironmentObject private var theme: Theme
|
@EnvironmentObject private var theme: Theme
|
||||||
@EnvironmentObject private var client: Client
|
@EnvironmentObject private var client: Client
|
||||||
@EnvironmentObject private var routeurPath: RouterPath
|
@EnvironmentObject private var routeurPath: RouterPath
|
||||||
|
@ -72,11 +73,15 @@ public struct StatusRowView: View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if let status: AnyStatus = viewModel.status.reblog ?? viewModel.status {
|
if let status: AnyStatus = viewModel.status.reblog ?? viewModel.status {
|
||||||
if !viewModel.isEmbed {
|
if !viewModel.isEmbed {
|
||||||
|
HStack(alignment: .top) {
|
||||||
Button {
|
Button {
|
||||||
routeurPath.navigate(to: .accountDetailWithAccount(account: status.account))
|
routeurPath.navigate(to: .accountDetailWithAccount(account: status.account))
|
||||||
} label: {
|
} label: {
|
||||||
makeAccountView(status: status)
|
makeAccountView(status: status)
|
||||||
}.buttonStyle(.plain)
|
}.buttonStyle(.plain)
|
||||||
|
Spacer()
|
||||||
|
menuButton
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(status.content.asSafeAttributedString)
|
Text(status.content.asSafeAttributedString)
|
||||||
|
@ -119,4 +124,37 @@ public struct StatusRowView: View {
|
||||||
.foregroundColor(.gray)
|
.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) {
|
private func updateFromStatus(status: Status) {
|
||||||
if let reblog = status.reblog {
|
if let reblog = status.reblog {
|
||||||
isFavourited = reblog.favourited == true
|
isFavourited = reblog.favourited == true
|
||||||
|
|
|
@ -4,8 +4,10 @@ import Models
|
||||||
import Shimmer
|
import Shimmer
|
||||||
import Status
|
import Status
|
||||||
import DesignSystem
|
import DesignSystem
|
||||||
|
import Env
|
||||||
|
|
||||||
public struct TimelineView: View {
|
public struct TimelineView: View {
|
||||||
|
@EnvironmentObject private var watcher: StreamWatcher
|
||||||
@EnvironmentObject private var client: Client
|
@EnvironmentObject private var client: Client
|
||||||
@StateObject private var viewModel = TimelineViewModel()
|
@StateObject private var viewModel = TimelineViewModel()
|
||||||
|
|
||||||
|
@ -44,6 +46,11 @@ public struct TimelineView: View {
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.fetchStatuses()
|
await viewModel.fetchStatuses()
|
||||||
}
|
}
|
||||||
|
.onChange(of: watcher.latestEvent?.id) { id in
|
||||||
|
if let latestEvent = watcher.latestEvent {
|
||||||
|
viewModel.handleEvent(event: latestEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
|
@ -13,6 +13,9 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
|
||||||
@Published var timeline: TimelineFilter = .pub {
|
@Published var timeline: TimelineFilter = .pub {
|
||||||
didSet {
|
didSet {
|
||||||
Task {
|
Task {
|
||||||
|
if oldValue != timeline {
|
||||||
|
statuses = []
|
||||||
|
}
|
||||||
await fetchStatuses()
|
await fetchStatuses()
|
||||||
switch timeline {
|
switch timeline {
|
||||||
case let .hashtag(tag, _):
|
case let .hashtag(tag, _):
|
||||||
|
@ -79,4 +82,15 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
|
||||||
tag = try await client.post(endpoint: Tags.unfollow(id: id))
|
tag = try await client.post(endpoint: Tags.unfollow(id: id))
|
||||||
} catch {}
|
} 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