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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -32,7 +32,7 @@ struct TimelineTab: View {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
accountButton accountButton
} }
statusEditorToolbarItem(routeurPath: routeurPath) statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub)
} else { } else {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
addAccountButton 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 SwiftUI
import Env import Env
import Models
@MainActor @MainActor
extension View { extension View {
public func statusEditorToolbarItem(routeurPath: RouterPath) -> some ToolbarContent { public func statusEditorToolbarItem(routeurPath: RouterPath, visibility: Models.Visibility) -> some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
routeurPath.presentedSheet = .newStatusEditor routeurPath.presentedSheet = .newStatusEditor(visibility: visibility)
} label: { } label: {
Image(systemName: "square.and.pencil") Image(systemName: "square.and.pencil")
} }
@ -16,13 +17,16 @@ extension View {
public struct StatusEditorToolbarItem: ToolbarContent { public struct StatusEditorToolbarItem: ToolbarContent {
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
let visibility: Models.Visibility
public init() { } public init(visibility: Models.Visibility) {
self.visibility = visibility
}
public var body: some ToolbarContent { public var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
routerPath.presentedSheet = .newStatusEditor routerPath.presentedSheet = .newStatusEditor(visibility: visibility)
} label: { } label: {
Image(systemName: "square.and.pencil") Image(systemName: "square.and.pencil")
} }

View file

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

View file

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

View file

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

View file

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

View file

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