Files
Morgan Pretty ac9816b118 Massive refactors of the SessionThreadViewModel and MessageViewModel
The `SessionThreadViewModel` has been renamed to `ConversationInfoViewModel` and is now a simple struct (as opposed to a FetchableRecord with a handful of behemoth database queries)

The `ConversationInfoViewModel` and `MessageViewModel` now get initialised with a `ConversationDataCache` which is an object that holds a bunch of [ID -> Data] stores for various types that are needed to construct the aforementioned view models. The `ConversationDataCache` will generally be populated via the `ConversationDataHelper` which, when combined with our `ObservableEvent` system, is where the majority of the work for this refactoring has gone. This helper essentially has 4 parts:
- `determineFetchRequirements`: Reviews any events which have come through and determines whether anything needs to be fetched from the database
- `applyNonDatabaseEvents`: Updates the cached data directly based on delta information included in the `ObservableEvent` instances
- `fetchFromDatabase`: Fetches any data from the database based on the info returned from `determineFetchRequirements`, this function will also resolve any dependant data that newly fetched objects need if they aren't already in the cache
- `fetchFromLibSession`: Retrieves requested data from libSession

Using the above setup all screens which contain conversation or message data now use these two view models, and can share the state of the view models between each other to reduce the need to refetch data when entering a new screen

Some additional changes included due to the above are:
• No longer need to pass `displayNameRetriever` throughout
• Removed deprecated display name retrieval functions
• Removed some duplicate code
• Removed some synchronous database query hacks we needed until refactoring had been completed
• Refactored mentions to be handled by tags
• Refactored and optimised the searching logic (also added some caching)
• Refactored the ThreadSettingsViewModel to use the new ObservationBuilder system
• Refactored the ThreadPickerViewModel to use the new ObservationBuilder system
• Refactored the HomeViewModel to retrieve data using the `ConversationDataHelper`
• Refactored the ConversationViewModel to retrieve data using the `ConversationDataHelper`
• Fixed an issue where communities weren't getting the correct "joined" timestamp
2025-12-11 16:47:37 +11:00

322 lines
12 KiB
Swift

// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import SwiftUI
import Combine
import SessionUIKit
import SignalUtilitiesKit
import SessionUtilitiesKit
struct LandingScreen: View {
public class ViewModel {
fileprivate let dependencies: Dependencies
private let onOnboardingComplete: () -> ()
private var disposables: Set<AnyCancellable> = Set()
init(onOnboardingComplete: @escaping () -> Void, using dependencies: Dependencies) {
self.dependencies = dependencies
self.onOnboardingComplete = onOnboardingComplete
}
fileprivate func register(setupComplete: () -> ()) {
// Reset the Onboarding cache to create a new user (in case the user previously went back)
dependencies.set(cache: .onboarding, to: Onboarding.Cache(flow: .register, using: dependencies))
/// Once the onboarding process is complete we need to call `onOnboardingComplete`
dependencies[cache: .onboarding].onboardingCompletePublisher
.subscribe(on: DispatchQueue.main, using: dependencies)
.receive(on: DispatchQueue.main, using: dependencies)
.sink(receiveValue: { [weak self] _ in self?.onOnboardingComplete() })
.store(in: &disposables)
setupComplete()
}
fileprivate func restore(setupComplete: () -> ()) {
// Reset the Onboarding cache to create a new user (in case the user previously went back)
dependencies.set(cache: .onboarding, to: Onboarding.Cache(flow: .restore, using: dependencies))
/// Once the onboarding process is complete we need to call `onOnboardingComplete`
dependencies[cache: .onboarding].onboardingCompletePublisher
.subscribe(on: DispatchQueue.main, using: dependencies)
.receive(on: DispatchQueue.main, using: dependencies)
.sink(receiveValue: { [weak self] _ in self?.onOnboardingComplete() })
.store(in: &disposables)
setupComplete()
}
}
@EnvironmentObject var host: HostWrapper
private let viewModel: ViewModel
public init(using dependencies: Dependencies, onOnboardingComplete: @escaping () -> ()) {
self.viewModel = ViewModel(
onOnboardingComplete: onOnboardingComplete,
using: dependencies
)
}
var body: some View {
ZStack(alignment: .center) {
ThemeColor(.backgroundPrimary).ignoresSafeArea()
VStack(
alignment: .center,
spacing: 0
) {
Spacer(minLength: 0)
Text("onboardingBubblePrivacyInYourPocket".localized())
.bold()
.font(.system(size: Values.veryLargeFontSize))
.foregroundColor(themeColor: .textPrimary)
Spacer(minLength: 0)
.frame(maxHeight: 2 * Values.mediumSpacing)
FakeChat(using: viewModel.dependencies)
Spacer(minLength: 0)
.frame(maxHeight: Values.massiveSpacing)
Spacer(minLength: 0)
}
.padding(.vertical, Values.mediumSpacing)
.padding(.bottom, 3 * Values.largeButtonHeight)
VStack(
alignment: .center,
spacing: Values.mediumSpacing
) {
Spacer()
Button {
register()
} label: {
Text("onboardingAccountCreate".localized())
.bold()
.font(.system(size: Values.smallFontSize))
.foregroundColor(themeColor: .sessionButton_primaryFilledText)
.frame(
maxWidth: .infinity,
maxHeight: Values.largeButtonHeight,
alignment: .center
)
.backgroundColor(themeColor: .sessionButton_primaryFilledBackground)
.cornerRadius(Values.largeButtonHeight / 2)
}
.accessibility(
Accessibility(
identifier: "Create account button",
label: "Create account button"
)
)
.padding(.horizontal, Values.massiveSpacing)
Button {
restore()
} label: {
Text("onboardingAccountExists".localized())
.bold()
.font(.system(size: Values.smallFontSize))
.foregroundColor(themeColor: .sessionButton_text)
.frame(
maxWidth: .infinity,
maxHeight: Values.largeButtonHeight,
alignment: .center
)
.overlay(
Capsule()
.stroke(themeColor: .sessionButton_border)
)
}
.accessibility(
Accessibility(
identifier: "Restore your session button",
label: "Restore your session button"
)
)
.padding(.horizontal, Values.massiveSpacing)
Button {
openLegalUrl()
} label: {
let attributedText: ThemedAttributedString = "onboardingTosPrivacy"
.localizedFormatted(baseFont: .systemFont(ofSize: Values.verySmallFontSize))
AttributedText(attributedText)
.font(.system(size: Values.verySmallFontSize))
.foregroundColor(themeColor: .textPrimary)
}
.accessibility(
Accessibility(
identifier: "Open URL"
)
)
.padding(.horizontal, Values.massiveSpacing)
}
}
}
private func register() {
viewModel.register {
let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: DisplayNameScreen(using: viewModel.dependencies)
)
viewController.setUpNavBarSessionIcon()
self.host.controller?.navigationController?.pushViewController(viewController, animated: true)
}
}
private func restore() {
viewModel.restore {
let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: LoadAccountScreen(using: viewModel.dependencies)
)
viewController.setNavBarTitle("loadAccount".localized())
self.host.controller?.navigationController?.pushViewController(viewController, animated: true)
}
}
private func openLegalUrl() {
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "urlOpen".localized(),
body: .text("urlOpenBrowser".localized()),
confirmTitle: "onboardingTos".localized(),
confirmStyle: .textPrimary,
cancelTitle: "onboardingPrivacy".localized(),
cancelStyle: .textPrimary,
hasCloseButton: true,
onConfirm: { _ in
// TODO: [PRO] Update this to use the double url modal
if let url: URL = URL(string: Constants.urls.termsOfService) {
UIApplication.shared.open(url)
}
},
onCancel: { modal in
if let url: URL = URL(string: Constants.urls.privacyPolicy) {
UIApplication.shared.open(url)
}
modal.close()
}
)
)
self.host.controller?.present(modal, animated: true)
}
}
struct ChatBubble: View {
let text: String
let outgoing: Bool
var body: some View {
Text(text)
.foregroundColor(themeColor: (outgoing ? .messageBubble_outgoingText : .messageBubble_incomingText))
.font(.system(size: 16))
.padding(.all, 12)
.backgroundColor(themeColor: (outgoing ?
.messageBubble_outgoingBackground :
.messageBubble_incomingBackground)
)
.cornerRadius(13)
.frame(
maxWidth: 230,
alignment: (outgoing ? .trailing : .leading)
)
}
}
struct FakeChat: View {
@State var numberOfBubblesShown: Int = 0
private let dependencies: Dependencies
public init(using dependencies: Dependencies) {
self.dependencies = dependencies
}
let chatBubbles: [ChatBubble] = [
ChatBubble(
text: "onboardingBubbleWelcomeToSession"
.put(key: "app_name", value: Constants.app_name)
.put(key: "emoji", value: "👋")
.localized(),
outgoing: false
),
ChatBubble(
text: "onboardingBubbleSessionIsEngineered"
.put(key: "app_name", value: Constants.app_name)
.localized(),
outgoing: true
),
ChatBubble(
text: "onboardingBubbleNoPhoneNumber".localized(),
outgoing: false
),
ChatBubble(
text: "onboardingBubbleCreatingAnAccountIsEasy"
.put(key: "emoji", value: "👇")
.localized(),
outgoing: true
)
]
var body: some View {
ScrollView(showsIndicators: false) {
VStack(
alignment: .leading,
spacing: 18
) {
ForEach(
0...(chatBubbles.count - 1),
id: \.self
) { index in
let chatBubble: ChatBubble = chatBubbles[index]
let bubble = chatBubble
.frame(
maxWidth: .infinity,
alignment: chatBubble.outgoing ? .trailing : .leading
)
if index < numberOfBubblesShown {
bubble
.transition(
AnyTransition
.move(edge: .bottom)
.combined(with:.opacity.animation(.easeIn(duration: 0.68)))
)
}
}
}
.frame(maxWidth: .infinity)
.frame(
height: 320,
alignment: .bottom
)
.padding(.horizontal, 36)
}
.fixedSize(horizontal: false, vertical: true)
.onAppear {
guard numberOfBubblesShown < 4 else { return }
Timer.scheduledTimerOnMainThread(withTimeInterval: 0.2, repeats: false, using: dependencies) { [dependencies] _ in
withAnimation(.spring().speed(0.68)) {
numberOfBubblesShown = 1
}
Timer.scheduledTimerOnMainThread(withTimeInterval: 1.5, repeats: true, using: dependencies) { timer in
withAnimation(.spring().speed(0.68)) {
numberOfBubblesShown += 1
if numberOfBubblesShown >= 4 {
timer.invalidate()
}
}
}
}
}
}
}
struct LandingView_Previews: PreviewProvider {
static var previews: some View {
LandingScreen(using: Dependencies.createEmpty(), onOnboardingComplete: {})
}
}