mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-29 03:31:02 +00:00
Direct message + empty screen for notifications and messages
This commit is contained in:
parent
88b56fe016
commit
e1ad5efd80
25 changed files with 432 additions and 54 deletions
|
@ -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" */;
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -18,7 +18,7 @@ struct ExploreTab: View {
|
|||
.withAppRouteur()
|
||||
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
|
||||
.toolbar {
|
||||
statusEditorToolbarItem(routeurPath: routeurPath)
|
||||
statusEditorToolbarItem(routeurPath: routeurPath, visibility: .pub)
|
||||
}
|
||||
}
|
||||
.environmentObject(routeurPath)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
9
Packages/Conversations/.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
33
Packages/Conversations/Package.swift
Normal file
33
Packages/Conversations/Package.swift
Normal 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"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
3
Packages/Conversations/README.md
Normal file
3
Packages/Conversations/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Conversations
|
||||
|
||||
A description of this package.
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
17
Packages/Models/Sources/Models/Conversation.swift
Normal file
17
Packages/Models/Sources/Models/Conversation.swift
Normal 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()]
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -78,6 +78,7 @@ struct StatusActionsView: View {
|
|||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(action == .boost && viewModel.status.visibility == .direct)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue