Direct message + empty screen for notifications and messages

This commit is contained in:
Thomas Ricouard 2023-01-05 12:21:54 +01:00
parent 88b56fe016
commit e1ad5efd80
25 changed files with 432 additions and 54 deletions

View file

@ -17,7 +17,7 @@
9F35DB44294F9A7D00B3281A /* Status in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB43294F9A7D00B3281A /* Status */; };
9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F35DB4629506F6600B3281A /* NotificationTab.swift */; };
9F35DB4A29506FA100B3281A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB4929506FA100B3281A /* Notifications */; };
9F35DB4C2952005C00B3281A /* AccountTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F35DB4B2952005C00B3281A /* AccountTab.swift */; };
9F35DB4C2952005C00B3281A /* MessagesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F35DB4B2952005C00B3281A /* MessagesTab.swift */; };
9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */; };
9F398AA92935FFDB00A889F2 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AA82935FFDB00A889F2 /* Account */; };
9F398AAB2935FFDB00A889F2 /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F398AAA2935FFDB00A889F2 /* Models */; };
@ -25,6 +25,7 @@
9F55C68D2955968700F94077 /* ExploreTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F55C68C2955968700F94077 /* ExploreTab.swift */; };
9F55C6902955993C00F94077 /* Explore in Frameworks */ = {isa = PBXBuildFile; productRef = 9F55C68F2955993C00F94077 /* Explore */; };
9F5E581929545BE700A53960 /* Env in Frameworks */ = {isa = PBXBuildFile; productRef = 9F5E581829545BE700A53960 /* Env */; };
9F7335EA2966B3F800AFF0BA /* Conversations in Frameworks */ = {isa = PBXBuildFile; productRef = 9F7335E92966B3F800AFF0BA /* Conversations */; };
9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4ACA293783B000772766 /* SettingsTab.swift */; };
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9FAE4ACD29379A5A00772766 /* KeychainSwift */; };
9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE4AD029379AD600772766 /* AppAccount.swift */; };
@ -49,7 +50,7 @@
9F35DB45294FA04C00B3281A /* DesignSystem */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DesignSystem; path = Packages/DesignSystem; sourceTree = "<group>"; };
9F35DB4629506F6600B3281A /* NotificationTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTab.swift; sourceTree = "<group>"; };
9F35DB4829506F7F00B3281A /* Notifications */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Notifications; path = Packages/Notifications; sourceTree = "<group>"; };
9F35DB4B2952005C00B3281A /* AccountTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTab.swift; sourceTree = "<group>"; };
9F35DB4B2952005C00B3281A /* MessagesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesTab.swift; sourceTree = "<group>"; };
9F398AA32935F90100A889F2 /* Models */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Models; path = Packages/Models; sourceTree = "<group>"; };
9F398AA52935FE8A00A889F2 /* AppRouteur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteur.swift; sourceTree = "<group>"; };
9F398AAC2936005300A889F2 /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Account; path = Packages/Account; sourceTree = "<group>"; };
@ -57,6 +58,7 @@
9F55C68C2955968700F94077 /* ExploreTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreTab.swift; sourceTree = "<group>"; };
9F55C68E295598F900F94077 /* Explore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Explore; path = Packages/Explore; sourceTree = "<group>"; };
9F5E581729545B5500A53960 /* Env */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Env; path = Packages/Env; sourceTree = "<group>"; };
9F7335E82966B3DC00AFF0BA /* Conversations */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Conversations; path = Packages/Conversations; sourceTree = "<group>"; };
9FAE4AC8293774FF00772766 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
9FAE4ACA293783B000772766 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = "<group>"; };
9FAE4AD029379AD600772766 /* AppAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccount.swift; sourceTree = "<group>"; };
@ -76,6 +78,7 @@
files = (
9F55C6902955993C00F94077 /* Explore in Frameworks */,
9FAE4ACE29379A5A00772766 /* KeychainSwift in Frameworks */,
9F7335EA2966B3F800AFF0BA /* Conversations in Frameworks */,
9F398AA92935FFDB00A889F2 /* Account in Frameworks */,
9FBFE64E292A72BD00C250E9 /* Network in Frameworks */,
9FD542E72962D2FF0045321A /* Lists in Frameworks */,
@ -115,7 +118,7 @@
9FE151A4293C90EA00E9683D /* Settings */,
9F398AB229360A4C00A889F2 /* TimelineTab.swift */,
9F35DB4629506F6600B3281A /* NotificationTab.swift */,
9F35DB4B2952005C00B3281A /* AccountTab.swift */,
9F35DB4B2952005C00B3281A /* MessagesTab.swift */,
9F55C68C2955968700F94077 /* ExploreTab.swift */,
9F2B92F5295AE04800DE16D0 /* Tabs.swift */,
);
@ -140,6 +143,7 @@
9FBFE63A292A715500C250E9 /* Products */,
9FBFE64C292A72BD00C250E9 /* Frameworks */,
9F398AAC2936005300A889F2 /* Account */,
9F7335E82966B3DC00AFF0BA /* Conversations */,
9F35DB45294FA04C00B3281A /* DesignSystem */,
9F55C68E295598F900F94077 /* Explore */,
9F5E581729545B5500A53960 /* Env */,
@ -217,6 +221,7 @@
9F5E581829545BE700A53960 /* Env */,
9F55C68F2955993C00F94077 /* Explore */,
9FD542E62962D2FF0045321A /* Lists */,
9F7335E92966B3F800AFF0BA /* Conversations */,
);
productName = IceCubesApp;
productReference = 9FBFE639292A715500C250E9 /* IceCubesApp.app */;
@ -277,7 +282,7 @@
files = (
9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */,
9F2B92FC295DA94500DE16D0 /* InstanceInfoView.swift in Sources */,
9F35DB4C2952005C00B3281A /* AccountTab.swift in Sources */,
9F35DB4C2952005C00B3281A /* MessagesTab.swift in Sources */,
9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */,
9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */,
9F2B92FF295EB87100DE16D0 /* AppAccountView.swift in Sources */,
@ -566,6 +571,10 @@
isa = XCSwiftPackageProductDependency;
productName = Env;
};
9F7335E92966B3F800AFF0BA /* Conversations */ = {
isa = XCSwiftPackageProductDependency;
productName = Conversations;
};
9FAE4ACD29379A5A00772766 /* KeychainSwift */ = {
isa = XCSwiftPackageProductDependency;
package = 9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */;

View file

@ -37,8 +37,8 @@ extension View {
switch destination {
case let .replyToStatusEditor(status):
StatusEditorView(mode: .replyTo(status: status))
case .newStatusEditor:
StatusEditorView(mode: .new)
case let .newStatusEditor(visibility):
StatusEditorView(mode: .new(vivibilty: visibility))
case let .editStatusEditor(status):
StatusEditorView(mode: .edit(status: status))
case let .quoteStatusEditor(status):

View file

@ -49,7 +49,7 @@ struct IceCubesApp: App {
.onChange(of: appAccountsManager.currentClient) { newClient in
setNewClientsInEnv(client: newClient)
if newClient.isAuth {
watcher.watch(stream: .user)
watcher.watch(streams: [.user, .direct])
}
}
.onChange(of: theme.primaryBackgroundColor) { newValue in
@ -66,6 +66,15 @@ struct IceCubesApp: App {
}
}
private func badgeFor(tab: Tab) -> Int {
if tab == .notifications && selectedTab != tab {
return watcher.unreadNotificationsCount
} else if tab == .messages && selectedTab != tab {
return watcher.unreadMessagesCount
}
return 0
}
private var tabBarView: some View {
TabView(selection: .init(get: {
selectedTab
@ -85,7 +94,7 @@ struct IceCubesApp: App {
tab.label
}
.tag(tab)
.badge(tab == .notifications ? watcher.unreadNotificationsCount : 0)
.badge(badgeFor(tab: tab))
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .tabBar)
}
}
@ -116,7 +125,7 @@ struct IceCubesApp: App {
case .background:
watcher.stopWatching()
case .active:
watcher.watch(stream: .user)
watcher.watch(streams: [.user, .direct])
case .inactive:
break
default:

View file

@ -18,7 +18,7 @@ struct ExploreTab: View {
.withAppRouteur()
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
.toolbar {
statusEditorToolbarItem(routeurPath: routeurPath)
statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub)
}
}
.environmentObject(routeurPath)

View file

@ -4,8 +4,11 @@ import Network
import Account
import Models
import Shimmer
import Conversations
import Env
struct AccountTab: View {
struct MessagesTab: View {
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var client: Client
@EnvironmentObject private var currentAccount: CurrentAccount
@StateObject private var routeurPath = RouterPath()
@ -13,23 +16,14 @@ struct AccountTab: View {
var body: some View {
NavigationStack(path: $routeurPath.path) {
if let account = currentAccount.account {
AccountDetailView(account: account)
.withAppRouteur()
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
.toolbar {
statusEditorToolbarItem(routeurPath: routeurPath)
}
.id(account.id)
} else {
AccountDetailView(account: .placeholder())
.redacted(reason: .placeholder)
.shimmering()
}
ConversationsListView()
.withAppRouteur()
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
.id(currentAccount.account?.id)
}
.environmentObject(routeurPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .account {
if popToRootTab == .messages {
routeurPath.path = []
}
}
@ -38,6 +32,7 @@ struct AccountTab: View {
}
.onAppear {
routeurPath.client = client
watcher.unreadMessagesCount = 0
}
}
}

View file

@ -17,7 +17,7 @@ struct NotificationsTab: View {
.withAppRouteur()
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
.toolbar {
statusEditorToolbarItem(routeurPath: routeurPath)
statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub)
}
.id(currentAccount.account?.id)
}

View file

@ -5,7 +5,7 @@ import Explore
import SwiftUI
enum Tab: Int, Identifiable, Hashable {
case timeline, notifications, explore, account, settings, other
case timeline, notifications, explore, messages, settings, other
var id: Int {
rawValue
@ -16,7 +16,7 @@ enum Tab: Int, Identifiable, Hashable {
}
static func loggedInTabs() -> [Tab] {
[.timeline, .notifications, .explore, .account, .settings]
[.timeline, .notifications, .explore, .messages, .settings]
}
@ViewBuilder
@ -28,8 +28,8 @@ enum Tab: Int, Identifiable, Hashable {
NotificationsTab(popToRootTab: popToRootTab)
case .explore:
ExploreTab(popToRootTab: popToRootTab)
case .account:
AccountTab(popToRootTab: popToRootTab)
case .messages:
MessagesTab(popToRootTab: popToRootTab)
case .settings:
SettingsTabs()
case .other:
@ -46,8 +46,8 @@ enum Tab: Int, Identifiable, Hashable {
Label("Notifications", systemImage: "bell")
case .explore:
Label("Explore", systemImage: "magnifyingglass")
case .account:
Label("Profile", systemImage: "person.circle")
case .messages:
Label("Messages", systemImage: "tray")
case .settings:
Label("Settings", systemImage: "gear")
case .other:

View file

@ -32,7 +32,7 @@ struct TimelineTab: View {
ToolbarItem(placement: .navigationBarLeading) {
accountButton
}
statusEditorToolbarItem(routeurPath: routeurPath)
statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub)
} else {
ToolbarItem(placement: .navigationBarTrailing) {
addAccountButton

9
Packages/Conversations/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View file

@ -0,0 +1,33 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Conversations",
platforms: [
.iOS(.v16),
],
products: [
.library(
name: "Conversations",
targets: ["Conversations"]),
],
dependencies: [
.package(name: "Models", path: "../Models"),
.package(name: "Network", path: "../Network"),
.package(name: "Env", path: "../Env"),
.package(name: "DesignSystem", path: "../DesignSystem"),
],
targets: [
.target(
name: "Conversations",
dependencies: [
.product(name: "Models", package: "Models"),
.product(name: "Network", package: "Network"),
.product(name: "Env", package: "Env"),
.product(name: "DesignSystem", package: "DesignSystem"),
]),
]
)

View file

@ -0,0 +1,3 @@
# Conversations
A description of this package.

View file

@ -0,0 +1,95 @@
import SwiftUI
import Models
import Account
import DesignSystem
import Env
import Network
struct ConversationsListRow: View {
@EnvironmentObject private var client: Client
@EnvironmentObject private var routerPath: RouterPath
@EnvironmentObject private var theme: Theme
let conversation: Conversation
@ObservedObject var viewModel: ConversationsListViewModel
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .top, spacing: 8) {
AvatarView(url: conversation.accounts.first!.avatar)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(conversation.accounts.map{ $0.safeDisplayName }.joined(separator: ", "))
.font(.headline)
.foregroundColor(theme.labelColor)
.multilineTextAlignment(.leading)
Spacer()
if conversation.unread {
Circle()
.foregroundColor(theme.tintColor)
.frame(width: 10, height: 10)
}
Text(conversation.lastStatus.createdAt.formatted)
.font(.footnote)
.foregroundColor(.gray)
}
Text(conversation.lastStatus.content.asRawText)
.foregroundColor(.gray)
.multilineTextAlignment(.leading)
}
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
Task {
await viewModel.markAsRead(conversation: conversation)
}
routerPath.navigate(to: .statusDetail(id: conversation.lastStatus.id))
}
.padding(.top, 4)
actionsView
.padding(.bottom, 4)
}
.contextMenu {
contextMenu
}
}
private var actionsView: some View {
HStack(spacing: 12) {
Button {
routerPath.presentedSheet = .replyToStatusEditor(status: conversation.lastStatus)
} label: {
Image(systemName: "arrowshape.turn.up.left.fill")
}
Menu {
contextMenu
} label: {
Image(systemName: "ellipsis")
.frame(width: 30, height: 30)
.contentShape(Rectangle())
}
}
.padding(.leading, 48)
.foregroundColor(.gray)
}
@ViewBuilder
private var contextMenu: some View {
Button {
Task {
await viewModel.markAsRead(conversation: conversation)
}
} label: {
Label("Mark as read", systemImage: "eye")
}
Button(role: .destructive) {
Task {
await viewModel.delete(conversation: conversation)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}

View file

@ -0,0 +1,73 @@
import SwiftUI
import Network
import Models
import DesignSystem
import Shimmer
import Env
public struct ConversationsListView: View {
@EnvironmentObject private var routeurPath: RouterPath
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var client: Client
@EnvironmentObject private var theme: Theme
@StateObject private var viewModel = ConversationsListViewModel()
public init() { }
private var conversations: [Conversation] {
if viewModel.isLoadingFirstPage {
return Conversation.placeholders()
}
return viewModel.conversations
}
public var body: some View {
ScrollView {
LazyVStack {
if !conversations.isEmpty || viewModel.isLoadingFirstPage {
ForEach(conversations) { conversation in
if viewModel.isLoadingFirstPage {
ConversationsListRow(conversation: conversation, viewModel: viewModel)
.padding(.horizontal, .layoutPadding)
.redacted(reason: .placeholder)
.shimmering()
} else {
ConversationsListRow(conversation: conversation, viewModel: viewModel)
.padding(.horizontal, .layoutPadding)
}
Divider()
}
} else if conversations.isEmpty && !viewModel.isLoadingFirstPage {
EmptyView(iconName: "tray",
title: "Inbox Zero",
message: "Looking for some social media love? You'll find all your direct messages and private mentions right here. Happy messaging! 📱❤️")
}
}
.padding(.top, .layoutPadding)
}
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
.navigationTitle("Direct Messages")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
statusEditorToolbarItem(routeurPath: routeurPath, visibility: .direct)
}
.onChange(of: watcher.latestEvent?.id) { id in
if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent)
}
}
.refreshable {
await viewModel.fetchConversations()
}
.onAppear {
viewModel.client = client
if client.isAuth {
Task {
await viewModel.fetchConversations()
}
}
}
}
}

View file

@ -0,0 +1,50 @@
import Models
import Network
import SwiftUI
@MainActor
class ConversationsListViewModel: ObservableObject {
var client: Client?
@Published var isLoadingFirstPage: Bool = true
@Published var conversations: [Conversation] = []
private let feedbackGenerator = UINotificationFeedbackGenerator()
public init() { }
func fetchConversations() async {
guard let client else { return }
if conversations.isEmpty {
isLoadingFirstPage = true
}
do {
conversations = try await client.get(endpoint: Conversations.conversations)
isLoadingFirstPage = false
} catch {
isLoadingFirstPage = false
}
}
func markAsRead(conversation: Conversation) async {
guard let client else { return }
_ = try? await client.post(endpoint: Conversations.read(id: conversation.id))
}
func delete(conversation: Conversation) async {
guard let client else { return }
_ = try? await client.delete(endpoint: Conversations.delete(id: conversation.id))
await fetchConversations()
}
func handleEvent(event: any StreamEvent) {
if let event = event as? StreamEventConversation {
if let index = conversations.firstIndex(where: { $0.id == event.conversation.id }) {
conversations.remove(at: index)
}
conversations.insert(event.conversation, at: 0)
conversations = conversations.sorted(by: { $0.lastStatus.createdAt.asDate > $1.lastStatus.createdAt.asDate })
feedbackGenerator.notificationOccurred(.success)
}
}
}

View file

@ -0,0 +1,31 @@
import SwiftUI
public struct EmptyView: View {
public let iconName: String
public let title: String
public let message: String
public init(iconName: String, title: String, message: String) {
self.iconName = iconName
self.title = title
self.message = message
}
public var body: some View {
VStack {
Image(systemName: iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 50)
Text(title)
.font(.title)
.padding(.top, 16)
Text(message)
.font(.subheadline)
.multilineTextAlignment(.center)
.foregroundColor(.gray)
}
.padding(.top, 100)
.padding(.layoutPadding)
}
}

View file

@ -1,12 +1,13 @@
import SwiftUI
import Env
import Models
@MainActor
extension View {
public func statusEditorToolbarItem(routeurPath: RouterPath) -> some ToolbarContent {
public func statusEditorToolbarItem(routeurPath: RouterPath, visibility: Models.Visibility) -> some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
routeurPath.presentedSheet = .newStatusEditor
routeurPath.presentedSheet = .newStatusEditor(visibility: visibility)
} label: {
Image(systemName: "square.and.pencil")
}
@ -16,13 +17,16 @@ extension View {
public struct StatusEditorToolbarItem: ToolbarContent {
@EnvironmentObject private var routerPath: RouterPath
let visibility: Models.Visibility
public init() { }
public init(visibility: Models.Visibility) {
self.visibility = visibility
}
public var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
routerPath.presentedSheet = .newStatusEditor
routerPath.presentedSheet = .newStatusEditor(visibility: visibility)
} label: {
Image(systemName: "square.and.pencil")
}

View file

@ -16,7 +16,7 @@ public enum RouteurDestinations: Hashable {
}
public enum SheetDestinations: Identifiable {
case newStatusEditor
case newStatusEditor(visibility: Models.Visibility)
case editStatusEditor(status: Status)
case replyToStatusEditor(status: Status)
case quoteStatusEditor(status: Status)

View file

@ -6,7 +6,7 @@ import Network
public class StreamWatcher: ObservableObject {
private var client: Client?
private var task: URLSessionWebSocketTask?
private var watchedStream: Stream?
private var watchedStreams: [Stream] = []
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
@ -14,10 +14,12 @@ public class StreamWatcher: ObservableObject {
public enum Stream: String {
case publicTimeline = "public"
case user
case direct
}
@Published public var events: [any StreamEvent] = []
@Published public var unreadNotificationsCount: Int = 0
@Published public var unreadMessagesCount: Int = 0
@Published public var latestEvent: (any StreamEvent)?
public init() {
@ -38,15 +40,17 @@ public class StreamWatcher: ObservableObject {
receiveMessage()
}
public func watch(stream: Stream) {
if client?.isAuth == false && stream == .user {
public func watch(streams: [Stream]) {
if client?.isAuth == false {
return
}
if task == nil {
connect()
}
watchedStream = stream
sendMessage(message: StreamMessage(type: "subscribe", stream: stream.rawValue))
watchedStreams = streams
streams.forEach { stream in
sendMessage(message: StreamMessage(type: "subscribe", stream: stream.rawValue))
}
}
public func stopWatching() {
@ -75,8 +79,10 @@ public class StreamWatcher: ObservableObject {
Task { @MainActor in
self.events.append(event)
self.latestEvent = event
if event is StreamEventNotification {
if let event = event as? StreamEventNotification, event.notification.status?.visibility != .direct {
self.unreadNotificationsCount += 1
} else if event is StreamEventConversation {
self.unreadMessagesCount += 1
}
}
}
@ -95,9 +101,7 @@ public class StreamWatcher: ObservableObject {
guard let self = self else { return }
self.stopWatching()
self.connect()
if let watchedStream = self.watchedStream {
self.watch(stream: watchedStream)
}
self.watch(streams: self.watchedStreams)
}
}
})
@ -120,6 +124,9 @@ public class StreamWatcher: ObservableObject {
case "notification":
let notification = try decoder.decode(Notification.self, from: payloadData)
return StreamEventNotification(notification: notification)
case "conversation":
let conversation = try decoder.decode(Conversation.self, from: payloadData)
return StreamEventConversation(conversation: conversation)
default:
return nil
}

View file

@ -0,0 +1,17 @@
import Foundation
public struct Conversation: Identifiable, Decodable {
public let id: String
public let unread: Bool
public let lastStatus: Status
public let accounts: [Account]
public static func placeholder() -> Conversation {
.init(id: UUID().uuidString, unread: false, lastStatus: .placeholder(), accounts: [.placeholder()])
}
public static func placeholders() -> [Conversation] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(),
.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
}
}

View file

@ -46,3 +46,12 @@ public struct StreamEventNotification: StreamEvent {
self.notification = notification
}
}
public struct StreamEventConversation: StreamEvent {
public let date = Date()
public var id: String { conversation.id }
public let conversation: Conversation
public init(conversation: Conversation) {
self.conversation = conversation
}
}

View file

@ -0,0 +1,22 @@
import Foundation
public enum Conversations: Endpoint {
case conversations
case delete(id: String)
case read(id: String)
public func path() -> String {
switch self {
case .conversations:
return "conversations"
case let .delete(id):
return "conversations/\(id)"
case let .read(id):
return "conversations/\(id)/read"
}
}
public func queryItems() -> [URLQueryItem]? {
return nil
}
}

View file

@ -71,10 +71,16 @@ public struct NotificationsListView: View {
}
case let .display(notifications, nextPageState):
ForEach(notifications) { notification in
NotificationRowView(notification: notification)
Divider()
.padding(.vertical, .dividerPadding)
if notifications.isEmpty {
EmptyView(iconName: "bell.slash",
title: "No notifications",
message: "Notifications? What notifications? Your notification inbox is looking so empty. Keep on being awesome! 📱😎")
} else {
ForEach(notifications) { notification in
NotificationRowView(notification: notification)
Divider()
.padding(.vertical, .dividerPadding)
}
}
switch nextPageState {

View file

@ -107,8 +107,13 @@ public class StatusEditorViewModel: ObservableObject {
func prepareStatusText() {
switch mode {
case let .new(visibility):
self.visibility = visibility
case let .replyTo(status):
var mentionString = "@\(status.reblog?.account.acct ?? status.account.acct)"
var mentionString = ""
if (status.reblog?.account.acct ?? status.account.acct) != currentAccount?.acct {
mentionString = "@\(status.reblog?.account.acct ?? status.account.acct)"
}
for mention in status.mentions where mention.acct != currentAccount?.acct {
mentionString += " @\(mention.acct)"
}
@ -193,7 +198,7 @@ public class StatusEditorViewModel: ObservableObject {
if let url = embededStatusURL,
!statusText.string.contains(url.absoluteString) {
self.embededStatus = nil
self.mode = .new
self.mode = .new(vivibilty: visibility)
}
}

View file

@ -3,7 +3,7 @@ import Models
extension StatusEditorViewModel {
public enum Mode {
case replyTo(status: Status)
case new
case new(vivibilty: Visibility)
case edit(status: Status)
case quote(status: Status)
case mention(account: Account, visibility: Visibility)

View file

@ -78,6 +78,7 @@ struct StatusActionsView: View {
}
}
.buttonStyle(.borderless)
.disabled(action == .boost && viewModel.status.visibility == .direct)
Spacer()
}
}