diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index 444fd160..df6f70a2 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -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 = ""; }; 9F35DB4629506F6600B3281A /* NotificationTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTab.swift; sourceTree = ""; }; 9F35DB4829506F7F00B3281A /* Notifications */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Notifications; path = Packages/Notifications; sourceTree = ""; }; - 9F35DB4B2952005C00B3281A /* AccountTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTab.swift; sourceTree = ""; }; + 9F35DB4B2952005C00B3281A /* MessagesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesTab.swift; sourceTree = ""; }; 9F398AA32935F90100A889F2 /* Models */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Models; path = Packages/Models; sourceTree = ""; }; 9F398AA52935FE8A00A889F2 /* AppRouteur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteur.swift; sourceTree = ""; }; 9F398AAC2936005300A889F2 /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Account; path = Packages/Account; sourceTree = ""; }; @@ -57,6 +58,7 @@ 9F55C68C2955968700F94077 /* ExploreTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreTab.swift; sourceTree = ""; }; 9F55C68E295598F900F94077 /* Explore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Explore; path = Packages/Explore; sourceTree = ""; }; 9F5E581729545B5500A53960 /* Env */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Env; path = Packages/Env; sourceTree = ""; }; + 9F7335E82966B3DC00AFF0BA /* Conversations */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Conversations; path = Packages/Conversations; sourceTree = ""; }; 9FAE4AC8293774FF00772766 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 9FAE4ACA293783B000772766 /* SettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTab.swift; sourceTree = ""; }; 9FAE4AD029379AD600772766 /* AppAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccount.swift; sourceTree = ""; }; @@ -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" */; diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index 9ebbdfcc..3e82df54 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -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): diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index 1be9d803..41396a82 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -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: diff --git a/IceCubesApp/App/Tabs/ExploreTab.swift b/IceCubesApp/App/Tabs/ExploreTab.swift index a127d204..8c96b69b 100644 --- a/IceCubesApp/App/Tabs/ExploreTab.swift +++ b/IceCubesApp/App/Tabs/ExploreTab.swift @@ -18,7 +18,7 @@ struct ExploreTab: View { .withAppRouteur() .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) .toolbar { - statusEditorToolbarItem(routeurPath: routeurPath) + statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub) } } .environmentObject(routeurPath) diff --git a/IceCubesApp/App/Tabs/AccountTab.swift b/IceCubesApp/App/Tabs/MessagesTab.swift similarity index 55% rename from IceCubesApp/App/Tabs/AccountTab.swift rename to IceCubesApp/App/Tabs/MessagesTab.swift index 729229c6..d1e4c589 100644 --- a/IceCubesApp/App/Tabs/AccountTab.swift +++ b/IceCubesApp/App/Tabs/MessagesTab.swift @@ -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 } } } diff --git a/IceCubesApp/App/Tabs/NotificationTab.swift b/IceCubesApp/App/Tabs/NotificationTab.swift index 1459adae..c96e5c8b 100644 --- a/IceCubesApp/App/Tabs/NotificationTab.swift +++ b/IceCubesApp/App/Tabs/NotificationTab.swift @@ -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) } diff --git a/IceCubesApp/App/Tabs/Tabs.swift b/IceCubesApp/App/Tabs/Tabs.swift index 37d6bef0..1184e55a 100644 --- a/IceCubesApp/App/Tabs/Tabs.swift +++ b/IceCubesApp/App/Tabs/Tabs.swift @@ -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: diff --git a/IceCubesApp/App/Tabs/TimelineTab.swift b/IceCubesApp/App/Tabs/TimelineTab.swift index 98ef24dc..a9df962f 100644 --- a/IceCubesApp/App/Tabs/TimelineTab.swift +++ b/IceCubesApp/App/Tabs/TimelineTab.swift @@ -32,7 +32,7 @@ struct TimelineTab: View { ToolbarItem(placement: .navigationBarLeading) { accountButton } - statusEditorToolbarItem(routeurPath: routeurPath) + statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub) } else { ToolbarItem(placement: .navigationBarTrailing) { addAccountButton diff --git a/Packages/Conversations/.gitignore b/Packages/Conversations/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Packages/Conversations/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/Conversations/Package.swift b/Packages/Conversations/Package.swift new file mode 100644 index 00000000..7f34838b --- /dev/null +++ b/Packages/Conversations/Package.swift @@ -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"), + ]), + ] +) + diff --git a/Packages/Conversations/README.md b/Packages/Conversations/README.md new file mode 100644 index 00000000..713360c1 --- /dev/null +++ b/Packages/Conversations/README.md @@ -0,0 +1,3 @@ +# Conversations + +A description of this package. diff --git a/Packages/Conversations/Sources/Conversations/List/ConversationsListRow.swift b/Packages/Conversations/Sources/Conversations/List/ConversationsListRow.swift new file mode 100644 index 00000000..5b37e219 --- /dev/null +++ b/Packages/Conversations/Sources/Conversations/List/ConversationsListRow.swift @@ -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") + } + } +} diff --git a/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift b/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift new file mode 100644 index 00000000..8f7bf8e9 --- /dev/null +++ b/Packages/Conversations/Sources/Conversations/List/ConversationsListView.swift @@ -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() + } + } + } + } +} diff --git a/Packages/Conversations/Sources/Conversations/List/ConversationsListViewModel.swift b/Packages/Conversations/Sources/Conversations/List/ConversationsListViewModel.swift new file mode 100644 index 00000000..3a4c8eca --- /dev/null +++ b/Packages/Conversations/Sources/Conversations/List/ConversationsListViewModel.swift @@ -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) + } + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/EmptyView.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/EmptyView.swift new file mode 100644 index 00000000..6a1c1bb8 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/EmptyView.swift @@ -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) + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift index 0fda4e00..b01517f7 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift @@ -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") } diff --git a/Packages/Env/Sources/Env/Routeur.swift b/Packages/Env/Sources/Env/Routeur.swift index 26109145..786a4ed0 100644 --- a/Packages/Env/Sources/Env/Routeur.swift +++ b/Packages/Env/Sources/Env/Routeur.swift @@ -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) diff --git a/Packages/Env/Sources/Env/StreamWatcher.swift b/Packages/Env/Sources/Env/StreamWatcher.swift index 883de418..8dfcb1e5 100644 --- a/Packages/Env/Sources/Env/StreamWatcher.swift +++ b/Packages/Env/Sources/Env/StreamWatcher.swift @@ -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 } diff --git a/Packages/Models/Sources/Models/Conversation.swift b/Packages/Models/Sources/Models/Conversation.swift new file mode 100644 index 00000000..cd75130f --- /dev/null +++ b/Packages/Models/Sources/Models/Conversation.swift @@ -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()] + } +} diff --git a/Packages/Models/Sources/Models/Stream/StreamEvent.swift b/Packages/Models/Sources/Models/Stream/StreamEvent.swift index 21367199..e2a42cb3 100644 --- a/Packages/Models/Sources/Models/Stream/StreamEvent.swift +++ b/Packages/Models/Sources/Models/Stream/StreamEvent.swift @@ -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 + } +} diff --git a/Packages/Network/Sources/Network/Endpoint/Conversations.swift b/Packages/Network/Sources/Network/Endpoint/Conversations.swift new file mode 100644 index 00000000..bc7cc0c9 --- /dev/null +++ b/Packages/Network/Sources/Network/Endpoint/Conversations.swift @@ -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 + } +} diff --git a/Packages/Notifications/Sources/Notifications/NotificationsListView.swift b/Packages/Notifications/Sources/Notifications/NotificationsListView.swift index bf40f5b8..8daa6bd2 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationsListView.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationsListView.swift @@ -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 { diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index 48084068..04eca031 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -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) } } diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModelMode.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModelMode.swift index 11f182d7..fd5a61bd 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModelMode.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModelMode.swift @@ -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) diff --git a/Packages/Status/Sources/Status/Row/StatusActionsView.swift b/Packages/Status/Sources/Status/Row/StatusActionsView.swift index 1551fb6d..60700b69 100644 --- a/Packages/Status/Sources/Status/Row/StatusActionsView.swift +++ b/Packages/Status/Sources/Status/Row/StatusActionsView.swift @@ -78,6 +78,7 @@ struct StatusActionsView: View { } } .buttonStyle(.borderless) + .disabled(action == .boost && viewModel.status.visibility == .direct) Spacer() } }