diff --git a/src/ReactiveUI.AndroidX/ReactiveUIBuilderAndroidXExtensions.cs b/src/ReactiveUI.AndroidX/ReactiveUIBuilderAndroidXExtensions.cs new file mode 100644 index 000000000..4793d1938 --- /dev/null +++ b/src/ReactiveUI.AndroidX/ReactiveUIBuilderAndroidXExtensions.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.AndroidX; + +/// +/// AndroidX-specific extensions for ReactiveUIBuilder. +/// +public static class ReactiveUIBuilderAndroidXExtensions +{ + /// + /// Registers AndroidX-specific services. + /// + /// The builder instance. + /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("WithAndroidX uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("WithAndroidX uses methods that may require unreferenced code")] +#endif + public static Builder.ReactiveUIBuilder WithAndroidX(this Builder.ReactiveUIBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.WithPlatformModule(); + } +} diff --git a/src/ReactiveUI.AndroidX/Registrations.cs b/src/ReactiveUI.AndroidX/Registrations.cs new file mode 100644 index 000000000..938762679 --- /dev/null +++ b/src/ReactiveUI.AndroidX/Registrations.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Android.OS; +using Android.Runtime; + +namespace ReactiveUI.AndroidX; + +/// +/// AndroidX platform registrations. +/// +/// +public class Registrations : IWantsToRegisterStuff +{ + /// +#if NET6_0_OR_GREATER + [RequiresDynamicCode("Register uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("Register uses methods that may require unreferenced code")] +#endif + public void Register(Action, Type> registerFunction) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(registerFunction); +#else + if (registerFunction is null) + { + throw new ArgumentNullException(nameof(registerFunction)); + } +#endif + + // Leverage core Android platform registrations already present in ReactiveUI.Platforms android. + // This ensures IPlatformOperations, binding converters, and schedulers are configured. + new PlatformRegistrations().Register(registerFunction); + + // AndroidX specific registrations could be added here if needed in the future. + + // Ensure a SynchronizationContext exists on Android when not in unit tests. + if (!ModeDetector.InUnitTestRunner() && Looper.MyLooper() is null) + { + Looper.Prepare(); + } + } +} diff --git a/src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs b/src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs new file mode 100644 index 000000000..2ef3b38ce --- /dev/null +++ b/src/ReactiveUI.Blazor/ReactiveUIBuilderBlazorExtensions.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Blazor; + +/// +/// Blazor-specific extensions for ReactiveUIBuilder. +/// +public static class ReactiveUIBuilderBlazorExtensions +{ + /// + /// Registers Blazor-specific services. + /// + /// The builder instance. + /// The builder instance for method chaining. +#if NET6_0_OR_GREATER + [RequiresDynamicCode("WithBlazor uses methods that require dynamic code generation")] + [RequiresUnreferencedCode("WithBlazor uses methods that may require unreferenced code")] +#endif + public static Builder.ReactiveUIBuilder WithBlazor(this Builder.ReactiveUIBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.WithPlatformModule(); + } +} diff --git a/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUI.Builder.AndroidX.Tests.csproj b/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUI.Builder.AndroidX.Tests.csproj new file mode 100644 index 000000000..78892545b --- /dev/null +++ b/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUI.Builder.AndroidX.Tests.csproj @@ -0,0 +1,23 @@ + + + net8.0-android + $(NoWarn);CS1591;SA1600 + 34.0 + false + + + + + false + + + + + + + + + + + diff --git a/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUIBuilderAndroidXTests.cs b/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUIBuilderAndroidXTests.cs new file mode 100644 index 000000000..8973c4dcb --- /dev/null +++ b/src/ReactiveUI.Builder.AndroidX.Tests/ReactiveUIBuilderAndroidXTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.AndroidX; + +namespace ReactiveUI.Builder.AndroidX.Tests; + +public class ReactiveUIBuilderAndroidXTests +{ + [Fact] + public void WithAndroidX_Should_Register_Services() + { + using var locator = new ModernDependencyResolver(); + + locator.CreateBuilder() + .WithAndroidX() + .Build(); + + var commandBinder = locator.GetService(); + Assert.NotNull(commandBinder); + + var observableForProperty = locator.GetService(); + Assert.NotNull(observableForProperty); + } + + [Fact] + public void WithCoreServices_AndAndroidX_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + + locator.CreateBuilder() + .WithCoreServices() + .WithAndroidX() + .Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var commandBinder = locator.GetService(); + Assert.NotNull(commandBinder); + } +} diff --git a/src/ReactiveUI.Builder.Maui.Tests/ReactiveUI.Builder.Maui.Tests.csproj b/src/ReactiveUI.Builder.Maui.Tests/ReactiveUI.Builder.Maui.Tests.csproj new file mode 100644 index 000000000..e823efff5 --- /dev/null +++ b/src/ReactiveUI.Builder.Maui.Tests/ReactiveUI.Builder.Maui.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0;net9.0;net8.0-windows10.0.19041.0;net9.0-windows10.0.19041.0 + true + $(NoWarn);CS1591;SA1600 + false + + + + + + + + + + diff --git a/src/ReactiveUI.Builder.Maui.Tests/ReactiveUIBuilderMauiTests.cs b/src/ReactiveUI.Builder.Maui.Tests/ReactiveUIBuilderMauiTests.cs new file mode 100644 index 000000000..0756c628d --- /dev/null +++ b/src/ReactiveUI.Builder.Maui.Tests/ReactiveUIBuilderMauiTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Maui; + +namespace ReactiveUI.Builder.Maui.Tests; + +public class ReactiveUIBuilderMauiTests +{ + [Fact] + public void WithMaui_Should_Register_Services() + { + using var locator = new ModernDependencyResolver(); + + locator.CreateBuilder() + .WithMaui() + .Build(); + + var typeConverters = locator.GetServices(); + Assert.NotNull(typeConverters); + } + + [Fact] + public void WithCoreServices_AndMaui_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + + locator.CreateBuilder() + .WithCoreServices() + .WithMaui() + .Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var typeConverters = locator.GetServices(); + Assert.NotNull(typeConverters); + } +} diff --git a/src/ReactiveUI.Builder.Tests/Platforms/Blazor/ReactiveUIBuilderBlazorTests.cs b/src/ReactiveUI.Builder.Tests/Platforms/Blazor/ReactiveUIBuilderBlazorTests.cs new file mode 100644 index 000000000..182d58eb3 --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/Platforms/Blazor/ReactiveUIBuilderBlazorTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Blazor; + +namespace ReactiveUI.Builder.Tests.Platforms.Blazor; + +public class ReactiveUIBuilderBlazorTests +{ + [Fact] + public void WithBlazor_Should_Register_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithBlazor().Build(); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + + var typeConverters = locator.GetServices(); + Assert.NotEmpty(typeConverters); + } + + [Fact] + public void WithCoreServices_AndBlazor_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithCoreServices().WithBlazor().Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + } +} diff --git a/src/ReactiveUI.Builder.Tests/Platforms/Drawing/ReactiveUIBuilderDrawingTests.cs b/src/ReactiveUI.Builder.Tests/Platforms/Drawing/ReactiveUIBuilderDrawingTests.cs new file mode 100644 index 000000000..867b76813 --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/Platforms/Drawing/ReactiveUIBuilderDrawingTests.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Drawing; + +namespace ReactiveUI.Builder.Tests.Platforms.Drawing; + +public class ReactiveUIBuilderDrawingTests +{ + [Fact] + public void WithDrawing_Should_Register_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithDrawing().Build(); + + // Drawing registers bitmap loader in non-NETSTANDARD contexts; we can still assert no exception and core services with chaining + locator.CreateBuilder().WithCoreServices().WithDrawing().Build(); + var bindingConverters = locator.GetServices(); + Assert.NotNull(bindingConverters); + } +} diff --git a/src/ReactiveUI.Builder.Tests/Platforms/WinForms/ReactiveUIBuilderWinFormsTests.cs b/src/ReactiveUI.Builder.Tests/Platforms/WinForms/ReactiveUIBuilderWinFormsTests.cs new file mode 100644 index 000000000..8c90b8f44 --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/Platforms/WinForms/ReactiveUIBuilderWinFormsTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Winforms; + +namespace ReactiveUI.Builder.Tests.Platforms.WinForms; + +public class ReactiveUIBuilderWinFormsTests +{ + [Fact] + public void WithWinForms_Should_Register_WinForms_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithWinForms().Build(); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + + var activationFetcher = locator.GetService(); + Assert.NotNull(activationFetcher); + } + + [Fact] + public void WithCoreServices_AndWinForms_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithCoreServices().WithWinForms().Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + } +} diff --git a/src/ReactiveUI.Builder.Tests/Platforms/Wpf/ReactiveUIBuilderWpfTests.cs b/src/ReactiveUI.Builder.Tests/Platforms/Wpf/ReactiveUIBuilderWpfTests.cs new file mode 100644 index 000000000..f058fbd73 --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/Platforms/Wpf/ReactiveUIBuilderWpfTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Wpf; + +namespace ReactiveUI.Builder.Tests.Platforms.Wpf; + +public class ReactiveUIBuilderWpfTests +{ + [Fact] + public void WithWpf_Should_Register_Wpf_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithWpf().Build(); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + + var activationFetcher = locator.GetService(); + Assert.NotNull(activationFetcher); + } + + [Fact] + public void WithCoreServices_AndWpf_Should_Register_All_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithCoreServices().WithWpf().Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var platformOperations = locator.GetService(); + Assert.NotNull(platformOperations); + } +} diff --git a/src/ReactiveUI.Builder.Tests/ReactiveUI.Builder.Tests.csproj b/src/ReactiveUI.Builder.Tests/ReactiveUI.Builder.Tests.csproj new file mode 100644 index 000000000..f17d6c292 --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/ReactiveUI.Builder.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0;net9.0 + ;net8.0-windows10.0.17763.0;net9.0-windows10.0.17763.0 + $(NoWarn);CS1591 + enable + enable + false + $(NoWarn);SA1600 + + + true + true + + + + + + + + + + + + + + + + + diff --git a/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs b/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs new file mode 100644 index 000000000..7554f401a --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderBlockingTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.Tests; + +/// +/// Tests ensuring the builder blocks reflection-based initialization. +/// +public class ReactiveUIBuilderBlockingTests +{ + [Fact] + public void Build_SetsFlag_AndBlocks_InitializeReactiveUI() + { + using var locator = new ModernDependencyResolver(); + + RxApp.HasBeenBuiltUsingBuilder = false; + + var builder = locator.CreateBuilder(); + builder.WithCoreServices().Build(); + + Assert.True(RxApp.HasBeenBuiltUsingBuilder); + + locator.InitializeReactiveUI(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + } +} diff --git a/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderCoreTests.cs b/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderCoreTests.cs new file mode 100644 index 000000000..dad8b769c --- /dev/null +++ b/src/ReactiveUI.Builder.Tests/ReactiveUIBuilderCoreTests.cs @@ -0,0 +1,141 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.Tests; + +/// +/// Tests for the ReactiveUIBuilder core functionality. +/// +public class ReactiveUIBuilderCoreTests +{ + [Fact] + public void CreateBuilder_Should_Return_Builder_Instance() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + Assert.NotNull(builder); + Assert.IsType(builder); + } + + [Fact] + public void WithCoreServices_Should_Register_Core_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + builder.WithCoreServices().Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + + var typeConverter = locator.GetService(); + Assert.NotNull(typeConverter); + } + + [Fact] + public void WithPlatformServices_Should_Register_Platform_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + builder.WithPlatformServices().Build(); + + var services = locator.GetServices(); + Assert.NotNull(services); + Assert.True(services.Any()); + } + + [Fact] + public void WithCustomRegistration_Should_Execute_Custom_Action() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + var customServiceRegistered = false; + + builder.WithCustomRegistration(r => + { + r.RegisterConstant("TestValue", typeof(string)); + customServiceRegistered = true; + }).Build(); + + Assert.True(customServiceRegistered); + var service = locator.GetService(); + Assert.Equal("TestValue", service); + } + + [Fact] + public void Build_Should_Always_Register_Core_Services() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.Build(); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + } + + [Fact] + public void WithCustomRegistration_With_Null_Action_Should_Throw() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + Assert.Throws(() => builder.WithCustomRegistration(null!)); + } + + [Fact] + public void WithViewsFromAssembly_Should_Register_Views() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + var assembly = typeof(ReactiveUIBuilderCoreTests).Assembly; + + builder.WithViewsFromAssembly(assembly).Build(); + Assert.NotNull(builder); + } + + [Fact] + public void WithViewsFromAssembly_With_Null_Assembly_Should_Throw() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + Assert.Throws(() => builder.WithViewsFromAssembly(null!)); + } + + [Fact] + public void WithCoreServices_Called_Multiple_Times_Should_Not_Register_Twice() + { + using var locator = new ModernDependencyResolver(); + var builder = locator.CreateBuilder(); + + builder.WithCoreServices().WithCoreServices().Build(); + + var services = locator.GetServices(); + Assert.NotNull(services); + Assert.True(services.Any()); + } + + [Fact] + public void Builder_Should_Support_Fluent_Chaining() + { + using var locator = new ModernDependencyResolver(); + var customServiceRegistered = false; + + locator.CreateBuilder() + .WithCoreServices() + .WithPlatformServices() + .WithCustomRegistration(r => + { + r.RegisterConstant("Test", typeof(string)); + customServiceRegistered = true; + }) + .Build(); + + Assert.True(customServiceRegistered); + var service = locator.GetService(); + Assert.Equal("Test", service); + + var observableProperty = locator.GetService(); + Assert.NotNull(observableProperty); + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/App.xaml b/src/ReactiveUI.Builder.WpfApp/App.xaml new file mode 100644 index 000000000..79a2684d9 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/App.xaml @@ -0,0 +1,7 @@ + + + diff --git a/src/ReactiveUI.Builder.WpfApp/App.xaml.cs b/src/ReactiveUI.Builder.WpfApp/App.xaml.cs new file mode 100644 index 000000000..49e1d113e --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/App.xaml.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Reactive.Linq; +using System.Windows; +using ReactiveUI.Wpf; +using Splat; + +namespace ReactiveUI.Builder.WpfApp; + +/// +/// Interaction logic for App.xaml. +/// +[SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposed on application exit in OnExit")] +public partial class App : Application +{ + private Services.WpfAutoSuspendHelper? _autoSuspend; + private Services.FileJsonSuspensionDriver? _driver; + private Services.ChatNetworkService? _networkService; + + /// + /// Raises the event. + /// + /// A that contains the event data. + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + // Initialize ReactiveUI via the Builder + var locator = Locator.CurrentMutable; + var builder = locator.CreateBuilder(); + builder + .WithCoreServices() + .WithWpf() + .WithViewsFromAssembly(typeof(App).Assembly) + .WithCustomRegistration(r => + { + // Register IScreen implementation as a factory so creation happens after state is loaded + r.Register(() => new ViewModels.AppBootstrapper()); + + // Register MessageBus as a singleton if not already + if (Locator.Current.GetService() is null) + { + r.RegisterConstant(MessageBus.Current); + } + }) + .Build(); + + // Setup Suspension + RxApp.SuspensionHost.CreateNewAppState = () => new ViewModels.ChatState(); + + var statePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ReactiveUI.Builder.WpfApp", + "state.json"); + Directory.CreateDirectory(Path.GetDirectoryName(statePath)!); + + _driver = new Services.FileJsonSuspensionDriver(statePath); + _autoSuspend = new Services.WpfAutoSuspendHelper(this, _driver); + _autoSuspend.OnStartup(); + + // Load state from disk (or create new) + var loaded = _driver.LoadState().Wait(); + RxApp.SuspensionHost.AppState = loaded; + + // Start network service + _networkService = new Services.ChatNetworkService(); + _networkService.Start(); + + // Create and show the shell + var mainWindow = new MainWindow(); + MainWindow = mainWindow; + mainWindow.Show(); + } + + /// + /// Raises the event. + /// + /// An that contains the event data. + protected override void OnExit(ExitEventArgs e) + { + _networkService?.Dispose(); + if (_driver is not null && RxApp.SuspensionHost.AppState is not null) + { + _driver.SaveState(RxApp.SuspensionHost.AppState).Wait(); + } + + _autoSuspend?.OnExit(); + base.OnExit(e); + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/AssemblyInfo.cs b/src/ReactiveUI.Builder.WpfApp/AssemblyInfo.cs new file mode 100644 index 000000000..d6bbbfdbe --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Windows; + +[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)] diff --git a/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml b/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml new file mode 100644 index 000000000..ce51432bb --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml @@ -0,0 +1,12 @@ + + + + + diff --git a/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml.cs b/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml.cs new file mode 100644 index 000000000..47e2b54eb --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/MainWindow.xaml.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Windows; +using Splat; + +namespace ReactiveUI.Builder.WpfApp; + +/// +/// Interaction logic for MainWindow.xaml. +/// +public partial class MainWindow : Window, IViewFor +{ + /// + /// The view model property. + /// + public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( + nameof(ViewModel), typeof(ViewModels.AppBootstrapper), typeof(MainWindow), new PropertyMetadata(null)); + + /// + /// Initializes a new instance of the class. + /// + public MainWindow() + { + InitializeComponent(); + + // Set up content host with routing + var host = new RoutedViewHost + { + Router = Locator.Current.GetService()!.Router, + DefaultContent = new System.Windows.Controls.TextBlock { Text = "Loading...", HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }, + }; + + Content = host; + ViewModel = (ViewModels.AppBootstrapper)Locator.Current.GetService()!; + } + + /// + /// Gets or sets the ViewModel corresponding to this specific View. This should be + /// a DependencyProperty if you're using XAML. + /// + public ViewModels.AppBootstrapper? ViewModel + { + get => (ViewModels.AppBootstrapper?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + /// + /// Gets or sets the ViewModel corresponding to this specific View. This should be + /// a DependencyProperty if you're using XAML. + /// + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = (ViewModels.AppBootstrapper?)value; + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/ReactiveUI.Builder.WpfApp.csproj b/src/ReactiveUI.Builder.WpfApp/ReactiveUI.Builder.WpfApp.csproj new file mode 100644 index 000000000..c755cf719 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ReactiveUI.Builder.WpfApp.csproj @@ -0,0 +1,17 @@ + + + + WinExe + net9.0-windows10.0.19041.0 + enable + enable + true + false + A sample WPF application using ReactiveUI. + + + + + + + diff --git a/src/ReactiveUI.Builder.WpfApp/Services/AppInstance.cs b/src/ReactiveUI.Builder.WpfApp/Services/AppInstance.cs new file mode 100644 index 000000000..0af0f7ad4 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/AppInstance.cs @@ -0,0 +1,11 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.Services; + +internal static class AppInstance +{ + public static readonly Guid Id = Guid.NewGuid(); +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkMessage.cs b/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkMessage.cs new file mode 100644 index 000000000..6900759b6 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkMessage.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// Network message payload used to broadcast chat messages. +/// +public sealed class ChatNetworkMessage +{ + /// + /// Initializes a new instance of the class. + /// + public ChatNetworkMessage() + { + } + + /// + /// Initializes a new instance of the class with values. + /// + /// The unique identifier for the room. + /// The human-readable room name used as the MessageBus contract. + /// The sender name. + /// The message text. + /// The message timestamp. + public ChatNetworkMessage(string roomId, string roomName, string sender, string text, DateTimeOffset timestamp) + { + RoomId = roomId; + RoomName = roomName; + Sender = sender; + Text = text; + Timestamp = timestamp; + } + + /// + /// Gets or sets the room ID. + /// + public string RoomId { get; set; } = string.Empty; + + /// + /// Gets or sets the room name. + /// + public string RoomName { get; set; } = string.Empty; + + /// + /// Gets or sets the sender. + /// + public string Sender { get; set; } = string.Empty; + + /// + /// Gets or sets the message text. + /// + public string Text { get; set; } = string.Empty; + + /// + /// Gets or sets the timestamp. + /// + public DateTimeOffset Timestamp { get; set; } + + /// + /// Gets or sets the originating app instance id. + /// + public Guid InstanceId { get; set; } +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkService.cs b/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkService.cs new file mode 100644 index 000000000..eb4bd221d --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/ChatNetworkService.cs @@ -0,0 +1,139 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Net; +using System.Net.Sockets; +using System.Text.Json; + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// A simple UDP-based network relay to share chat messages and room events between app instances. +/// +public sealed class ChatNetworkService : IDisposable +{ + private const string RoomsContract = "__rooms__"; + private const int Port = 54545; + + // IPv4 local multicast address + private static readonly IPAddress MulticastAddress = IPAddress.Parse("239.255.0.1"); + + private readonly UdpClient _udp; // sender + private readonly IPEndPoint _sendEndpoint; + private readonly CancellationTokenSource _cts = new(); + + /// + /// Initializes a new instance of the class. + /// + public ChatNetworkService() + { + _sendEndpoint = new IPEndPoint(MulticastAddress, Port); + _udp = new UdpClient(AddressFamily.InterNetwork); + + try + { + // Enable multicast loopback so we can also receive our messages (we filter locally using InstanceId) + _udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1); + _udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); + } + catch + { + // ignore + } + + // Outgoing chat messages (default contract) + MessageBus.Current.Listen() + .Subscribe(Send); + + // Outgoing room events + MessageBus.Current.Listen(contract: RoomsContract) + .Subscribe(Send); + } + + /// + /// Starts the background receive loop. + /// + public void Start() => Task.Run(ReceiveLoop, _cts.Token); + + /// + public void Dispose() + { + _cts.Cancel(); + _udp.Dispose(); + _cts.Dispose(); + } + + private async Task ReceiveLoop() + { + using var listener = new UdpClient(AddressFamily.InterNetwork); + try + { + // Allow multiple processes to bind the same UDP port + listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + listener.ExclusiveAddressUse = false; + listener.Client.Bind(new IPEndPoint(IPAddress.Any, Port)); + + // Join multicast group on default interface + listener.JoinMulticastGroup(MulticastAddress); + } + catch + { + return; + } + + while (!_cts.IsCancellationRequested) + { + try + { + var result = await listener.ReceiveAsync(_cts.Token).ConfigureAwait(false); + var buffer = result.Buffer; + + // Inspect JSON for known properties to determine message type + using var doc = JsonDocument.Parse(buffer); + var root = doc.RootElement; + var isRoomEvent = root.TryGetProperty("Kind", out _) || root.TryGetProperty("Snapshot", out _); + + if (isRoomEvent) + { + var evt = JsonSerializer.Deserialize(buffer); + if (evt is not null) + { + MessageBus.Current.SendMessage(evt, contract: RoomsContract); + } + + continue; + } + + // Otherwise treat as chat message + var chat = JsonSerializer.Deserialize(buffer); + if (chat is not null) + { + MessageBus.Current.SendMessage(chat, contract: chat.RoomName); + } + } + catch (OperationCanceledException) + { + break; + } + catch + { + // ignore malformed input + } + } + } + + private void Send(object message) + { + try + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(message, message.GetType()); + _udp.Send(bytes, bytes.Length, _sendEndpoint); + } + catch + { + // ignore + } + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/FileJsonSuspensionDriver.cs b/src/ReactiveUI.Builder.WpfApp/Services/FileJsonSuspensionDriver.cs new file mode 100644 index 000000000..656fa6326 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/FileJsonSuspensionDriver.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.IO; +using System.Reactive; +using System.Reactive.Linq; +using System.Text.Json; + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// FileJsonSuspensionDriver. +/// +/// +/// +/// Initializes a new instance of the class. +/// +/// The path. +public sealed class FileJsonSuspensionDriver(string path) : ISuspensionDriver +{ + private readonly JsonSerializerOptions _options = new() { WriteIndented = true }; + + /// + /// Invalidates the application state (i.e. deletes it from disk). + /// + /// + /// A completed observable. + /// + public IObservable InvalidateState() => Observable.Start( + () => + { + if (File.Exists(path)) + { + File.Delete(path); + } + }, + RxApp.TaskpoolScheduler); + + /// + /// Loads the application state from persistent storage. + /// + /// + /// An object observable. + /// + public IObservable LoadState() => Observable.Start( + () => + { + if (!File.Exists(path)) + { + return new ViewModels.ChatState(); + } + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json) ?? new ViewModels.ChatState(); + }, + RxApp.TaskpoolScheduler); + + /// + /// Saves the application state to disk. + /// + /// The application state. + /// + /// A completed observable. + /// + public IObservable SaveState(object state) => Observable.Start( + () => + { + var json = JsonSerializer.Serialize(state, _options); + File.WriteAllText(path, json); + }, + RxApp.TaskpoolScheduler); +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/RoomEventKind.cs b/src/ReactiveUI.Builder.WpfApp/Services/RoomEventKind.cs new file mode 100644 index 000000000..ecf799f90 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/RoomEventKind.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// The type of room event. +/// +public enum RoomEventKind +{ + /// + /// A new room was created. + /// + Add, + + /// + /// A room was removed. + /// + Remove, + + /// + /// Request others to broadcast their current rooms. + /// + SyncRequest, +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/RoomEventMessage.cs b/src/ReactiveUI.Builder.WpfApp/Services/RoomEventMessage.cs new file mode 100644 index 000000000..eb3236073 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/RoomEventMessage.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// Network event describing a change in the rooms list. +/// +public sealed class RoomEventMessage +{ + /// + /// Initializes a new instance of the class. + /// + public RoomEventMessage() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The event kind. + /// The room name. + public RoomEventMessage(RoomEventKind kind, string roomName) + { + Kind = kind; + RoomName = roomName; + } + + /// + /// Gets or sets the event kind. + /// + public RoomEventKind Kind { get; set; } + + /// + /// Gets or sets the room name for this event. + /// + public string RoomName { get; set; } = string.Empty; + + /// + /// Gets or sets the originating instance id. + /// + public Guid InstanceId { get; set; } + + /// + /// Gets or sets the current snapshot of room names. Used in response to SyncRequest. + /// + public List? Snapshot { get; set; } +} diff --git a/src/ReactiveUI.Builder.WpfApp/Services/WpfAutoSuspendHelper.cs b/src/ReactiveUI.Builder.WpfApp/Services/WpfAutoSuspendHelper.cs new file mode 100644 index 000000000..0fbcdc8c9 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Services/WpfAutoSuspendHelper.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Linq; +using System.Windows; + +namespace ReactiveUI.Builder.WpfApp.Services; + +/// +/// WpfAutoSuspendHelper. +/// +/// +/// Initializes a new instance of the class. +/// +/// The application. +/// The driver. +public sealed class WpfAutoSuspendHelper(Application app, ISuspensionDriver driver) +{ + /// + /// Called on application exit to allow any cleanup. + /// + public void OnExit() + { + var d = driver; + } + + /// + /// Called on application startup to configure suspension. + /// + public void OnStartup() + { + RxApp.SuspensionHost.IsLaunchingNew = Observable.Return(Unit.Default); + RxApp.SuspensionHost.IsResuming = Observable.Never(); + RxApp.SuspensionHost.IsUnpausing = Observable.Never(); + RxApp.SuspensionHost.ShouldPersistState = Observable.Never(); + + RxApp.SuspensionHost.SetupDefaultSuspendResume(driver); + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/AppBootstrapper.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/AppBootstrapper.cs new file mode 100644 index 000000000..c9c118a8b --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/AppBootstrapper.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// AppBootstrapper. +/// +/// +/// +public class AppBootstrapper : ReactiveObject, IScreen +{ + /// + /// Initializes a new instance of the class. + /// + public AppBootstrapper() + { + Router = new RoutingState(); + + // Navigate to Lobby on start + Router.Navigate.Execute(new LobbyViewModel(this)).Subscribe(); + } + + /// + /// Gets the Router associated with this Screen. + /// + public RoutingState Router { get; } +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatMessage.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatMessage.cs new file mode 100644 index 000000000..7bf7ac27e --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatMessage.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// A single chat message. +/// +public class ChatMessage +{ + /// + /// Gets or sets the sender name. + /// + public string Sender { get; set; } = string.Empty; + + /// + /// Gets or sets the message text. + /// + public string Text { get; set; } = string.Empty; + + /// + /// Gets or sets the timestamp. + /// + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoom.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoom.cs new file mode 100644 index 000000000..2ca4a045a --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoom.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.ObjectModel; + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// Represents a chat room with messages and members. +/// +public class ChatRoom +{ + /// + /// Gets or sets the room id. + /// + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + + /// + /// Gets or sets the room name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the messages in the room. + /// + public ObservableCollection Messages { get; set; } = new(); + + /// + /// Gets or sets the members in the room. + /// + public List Members { get; set; } = new(); +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoomViewModel.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoomViewModel.cs new file mode 100644 index 000000000..06aaeacc6 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatRoomViewModel.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Linq; + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// View model for a single chat room. +/// +public class ChatRoomViewModel : ReactiveObject, IRoutableViewModel +{ + private readonly IScreen _hostScreen; + private readonly ChatRoom _room; + private readonly string _user; + private string _messageText = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The host screen. + /// The room. + /// The user. + public ChatRoomViewModel(IScreen hostScreen, ChatRoom room, string user) + { + ArgumentNullException.ThrowIfNull(room); + _hostScreen = hostScreen; + HostScreen = hostScreen; + UrlPathSegment = $"room/{room.Name}"; + _room = room; + _user = user; + + var canSend = this.WhenAnyValue(x => x.MessageText, txt => !string.IsNullOrWhiteSpace(txt)); + SendMessage = ReactiveCommand.Create(SendMessageImpl, canSend); + + // Observe new incoming messages via MessageBus using the room name as the contract across instances + MessageBus.Current.Listen(contract: room.Name) + .Where(msg => msg.InstanceId != Services.AppInstance.Id) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(msg => + { + _room.Messages.Add(new ChatMessage { Sender = msg.Sender, Text = msg.Text, Timestamp = msg.Timestamp }); + }); + } + + /// + public string UrlPathSegment { get; } + + /// + public IScreen HostScreen { get; } + + /// + /// Gets the room name. + /// + public string RoomName => _room.Name; + + /// + /// Gets the messages. + /// + public IReadOnlyList Messages => _room.Messages; + + /// + /// Gets or sets the message text. + /// + public string MessageText + { + get => _messageText; + set => this.RaiseAndSetIfChanged(ref _messageText, value); + } + + /// + /// Gets command to send a message. + /// + public ReactiveCommand SendMessage { get; } + + private void SendMessageImpl() + { + var msg = new ChatMessage { Sender = _user, Text = MessageText, Timestamp = DateTimeOffset.Now }; + _room.Messages.Add(msg); + var networkMessage = new Services.ChatNetworkMessage(_room.Id, _room.Name, msg.Sender, msg.Text, msg.Timestamp) + { + InstanceId = Services.AppInstance.Id + }; + + // Post on null contract so the network service can broadcast to other instances. + MessageBus.Current.SendMessage(networkMessage); + + MessageText = string.Empty; + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatState.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatState.cs new file mode 100644 index 000000000..5cabada33 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatState.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// The persisted chat application state. +/// +public class ChatState +{ + /// + /// Gets or sets the available rooms. + /// + public List Rooms { get; set; } = new(); + + /// + /// Gets or sets the local user's display name. + /// + public string? DisplayName { get; set; } +} diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatStateChanged.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatStateChanged.cs new file mode 100644 index 000000000..a109fc812 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/ChatStateChanged.cs @@ -0,0 +1,11 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// Notification that the chat state has changed and observers should refresh. +/// +public sealed class ChatStateChanged; diff --git a/src/ReactiveUI.Builder.WpfApp/ViewModels/LobbyViewModel.cs b/src/ReactiveUI.Builder.WpfApp/ViewModels/LobbyViewModel.cs new file mode 100644 index 000000000..4c51cba1d --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/ViewModels/LobbyViewModel.cs @@ -0,0 +1,201 @@ +// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved. +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; + +namespace ReactiveUI.Builder.WpfApp.ViewModels; + +/// +/// The lobby view model which lists rooms and allows creating/joining rooms. +/// +public class LobbyViewModel : ReactiveObject, IRoutableViewModel +{ + private readonly ObservableAsPropertyHelper> _rooms; + private readonly IScreen _hostScreen; + private string _roomName = string.Empty; + private string _displayName = Environment.MachineName; + + /// + /// Initializes a new instance of the class. + /// + /// The host screen. + public LobbyViewModel(IScreen hostScreen) + { + _hostScreen = hostScreen; + HostScreen = hostScreen; + UrlPathSegment = "lobby"; + + var canCreate = this.WhenAnyValue(x => x.RoomName, rn => !string.IsNullOrWhiteSpace(rn)); + CreateRoom = ReactiveCommand.Create(CreateRoomImpl, canCreate); + + DeleteRoom = ReactiveCommand.Create(DeleteRoomImpl); + + JoinRoom = ReactiveCommand.CreateFromTask(async room => + { + ArgumentNullException.ThrowIfNull(room); + await HostScreen.Router.Navigate.Execute(new ChatRoomViewModel(HostScreen, room, DisplayName)); + }); + + // Local changes + var localRoomsChanged = MessageBus.Current.Listen().Select(_ => Unit.Default); + + // Remote changes and sync + var remoteRoomsChanged = MessageBus.Current + .Listen(contract: "__rooms__") + .Where(m => m.InstanceId != Services.AppInstance.Id) + .Do(evt => + { + switch (evt.Kind) + { + case Services.RoomEventKind.SyncRequest: + // Respond with our snapshot of room names + var snapshot = GetState().Rooms.ConvertAll(r => r.Name); + var response = new Services.RoomEventMessage(Services.RoomEventKind.Add, string.Empty) + { + Snapshot = snapshot, + InstanceId = Services.AppInstance.Id, + }; + MessageBus.Current.SendMessage(response, contract: "__rooms__"); + break; + default: + ApplyRoomEvent(evt); + break; + } + }) + .Select(_ => Unit.Default); + + RoomsChanged = localRoomsChanged.Merge(remoteRoomsChanged); + + this.WhenAnyObservable(x => x.RoomsChanged) + .StartWith(Unit.Default) + .Select(_ => (IReadOnlyList)[.. GetState().Rooms]) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, nameof(Rooms), out _rooms); + + // Request a snapshot from peers shortly after activation + RxApp.MainThreadScheduler.Schedule(Unit.Default, TimeSpan.FromMilliseconds(500), (s, __) => + { + var req = new Services.RoomEventMessage(Services.RoomEventKind.SyncRequest, string.Empty) { InstanceId = Services.AppInstance.Id }; + MessageBus.Current.SendMessage(req, contract: "__rooms__"); + return Disposable.Empty; + }); + } + + /// + public string UrlPathSegment { get; } + + /// + public IScreen HostScreen { get; } + + /// + /// Gets or sets the display name for the current user. + /// + public string DisplayName + { + get => _displayName; + set => this.RaiseAndSetIfChanged(ref _displayName, value); + } + + /// + /// Gets or sets the new room name. + /// + public string RoomName + { + get => _roomName; + set => this.RaiseAndSetIfChanged(ref _roomName, value); + } + + /// + /// Gets the current list of rooms. + /// + public IReadOnlyList Rooms => _rooms.Value; + + /// + /// Gets an observable signaling when the rooms change. + /// + public IObservable RoomsChanged { get; } + + /// + /// Gets the command which creates a new room. + /// + public ReactiveCommand CreateRoom { get; } + + /// + /// Gets the command which deletes a room. + /// + public ReactiveCommand DeleteRoom { get; } + + /// + /// Gets the command which joins an existing room. + /// + public ReactiveCommand JoinRoom { get; } + + private static ChatState GetState() => RxApp.SuspensionHost.GetAppState(); + + private static void ApplyRoomEvent(Services.RoomEventMessage evt) + { + var state = GetState(); + + if (evt.Snapshot is not null) + { + // Apply snapshot + foreach (var name in evt.Snapshot) + { + if (!state.Rooms.Any(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase))) + { + state.Rooms.Add(new ChatRoom { Name = name }); + } + } + + return; + } + + switch (evt.Kind) + { + case Services.RoomEventKind.Add: + if (!state.Rooms.Any(r => string.Equals(r.Name, evt.RoomName, StringComparison.OrdinalIgnoreCase))) + { + state.Rooms.Add(new ChatRoom { Name = evt.RoomName }); + } + + break; + case Services.RoomEventKind.Remove: + state.Rooms.RemoveAll(r => string.Equals(r.Name, evt.RoomName, StringComparison.OrdinalIgnoreCase)); + break; + } + } + + private void CreateRoomImpl() + { + var name = RoomName.Trim(); + var state = GetState(); + var existing = state.Rooms.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase)); + if (existing is null) + { + var room = new ChatRoom { Name = name }; + state.Rooms.Add(room); + + // Broadcast room add to peers + var evt = new Services.RoomEventMessage(Services.RoomEventKind.Add, room.Name) { InstanceId = Services.AppInstance.Id }; + MessageBus.Current.SendMessage(evt, contract: "__rooms__"); + } + + MessageBus.Current.SendMessage(new ChatStateChanged()); + RoomName = string.Empty; + } + + private void DeleteRoomImpl(ChatRoom room) + { + var state = GetState(); + if (state.Rooms.Remove(room)) + { + var evt = new Services.RoomEventMessage(Services.RoomEventKind.Remove, room.Name) { InstanceId = Services.AppInstance.Id }; + MessageBus.Current.SendMessage(evt, contract: "__rooms__"); + MessageBus.Current.SendMessage(new ChatStateChanged()); + } + } +} diff --git a/src/ReactiveUI.Builder.WpfApp/Views/ChatRoomView.xaml b/src/ReactiveUI.Builder.WpfApp/Views/ChatRoomView.xaml new file mode 100644 index 000000000..8d4eb4f76 --- /dev/null +++ b/src/ReactiveUI.Builder.WpfApp/Views/ChatRoomView.xaml @@ -0,0 +1,36 @@ + + + + + + + + +