Добавьте файлы проекта.
This commit is contained in:
14
src/Massenger.Client/App.xaml
Normal file
14
src/Massenger.Client/App.xaml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version = "1.0" encoding = "UTF-8" ?>
|
||||
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:local="clr-namespace:Massenger.Client"
|
||||
x:Class="Massenger.Client.App">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
|
||||
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
114
src/Massenger.Client/App.xaml.cs
Normal file
114
src/Massenger.Client/App.xaml.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using Massenger.Client.Pages;
|
||||
using Massenger.Client.Services;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly SessionService _sessionService;
|
||||
private readonly RealtimeService _realtimeService;
|
||||
private readonly IPushRegistrationService _pushRegistrationService;
|
||||
private readonly NotificationActivationService _notificationActivationService;
|
||||
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_sessionService = ServiceHelper.GetRequiredService<SessionService>();
|
||||
_realtimeService = ServiceHelper.GetRequiredService<RealtimeService>();
|
||||
_pushRegistrationService = ServiceHelper.GetRequiredService<IPushRegistrationService>();
|
||||
_notificationActivationService = ServiceHelper.GetRequiredService<NotificationActivationService>();
|
||||
_sessionService.SessionChanged += OnSessionChanged;
|
||||
_notificationActivationService.ChatRequested += OnChatRequested;
|
||||
}
|
||||
|
||||
protected override Window CreateWindow(IActivationState? activationState)
|
||||
{
|
||||
var window = new Window(new ContentPage
|
||||
{
|
||||
Content = new Grid
|
||||
{
|
||||
Children =
|
||||
{
|
||||
new ActivityIndicator
|
||||
{
|
||||
IsRunning = true,
|
||||
HorizontalOptions = LayoutOptions.Center,
|
||||
VerticalOptions = LayoutOptions.Center
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_ = InitializeAsync(window);
|
||||
return window;
|
||||
}
|
||||
|
||||
private async Task InitializeAsync(Window window)
|
||||
{
|
||||
await _sessionService.RestoreAsync();
|
||||
await _pushRegistrationService.InitializeAsync();
|
||||
if (_sessionService.IsAuthenticated)
|
||||
{
|
||||
await _pushRegistrationService.RegisterCurrentDeviceAsync();
|
||||
await _realtimeService.ConnectAsync();
|
||||
await MainThread.InvokeOnMainThreadAsync(() => window.Page = new AppShell());
|
||||
await NavigateToPendingChatAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await MainThread.InvokeOnMainThreadAsync(() => window.Page = new NavigationPage(new LoginPage()));
|
||||
}
|
||||
|
||||
private async void OnSessionChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (Windows.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var window = Windows[0];
|
||||
if (_sessionService.IsAuthenticated)
|
||||
{
|
||||
await _pushRegistrationService.RegisterCurrentDeviceAsync();
|
||||
await _realtimeService.ConnectAsync();
|
||||
await MainThread.InvokeOnMainThreadAsync(() => window.Page = new AppShell());
|
||||
await NavigateToPendingChatAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await _realtimeService.DisconnectAsync();
|
||||
await MainThread.InvokeOnMainThreadAsync(() => window.Page = new NavigationPage(new LoginPage()));
|
||||
}
|
||||
|
||||
private void OnChatRequested(object? sender, Guid chatId)
|
||||
{
|
||||
_ = NavigateToPendingChatAsync();
|
||||
}
|
||||
|
||||
private Task NavigateToPendingChatAsync()
|
||||
{
|
||||
if (!_sessionService.IsAuthenticated)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var chatId = _notificationActivationService.ConsumePendingChat();
|
||||
if (chatId is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return MainThread.InvokeOnMainThreadAsync(async () =>
|
||||
{
|
||||
if (Shell.Current is null)
|
||||
{
|
||||
_notificationActivationService.RestorePendingChat(chatId.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
await Shell.Current.GoToAsync("//ChatsPage");
|
||||
await Shell.Current.GoToAsync($"{nameof(ChatPage)}?chatId={chatId.Value}");
|
||||
});
|
||||
}
|
||||
}
|
||||
26
src/Massenger.Client/AppShell.xaml
Normal file
26
src/Massenger.Client/AppShell.xaml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Shell
|
||||
x:Class="Massenger.Client.AppShell"
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:pages="clr-namespace:Massenger.Client.Pages"
|
||||
FlyoutBehavior="Disabled"
|
||||
Shell.NavBarIsVisible="False"
|
||||
Title="Massenger">
|
||||
|
||||
<TabBar>
|
||||
<ShellContent
|
||||
Title="Chats"
|
||||
ContentTemplate="{DataTemplate pages:ChatsPage}"
|
||||
Route="ChatsPage" />
|
||||
<ShellContent
|
||||
Title="Discover"
|
||||
ContentTemplate="{DataTemplate pages:DiscoverPage}"
|
||||
Route="DiscoverPage" />
|
||||
<ShellContent
|
||||
Title="Profile"
|
||||
ContentTemplate="{DataTemplate pages:ProfilePage}"
|
||||
Route="ProfilePage" />
|
||||
</TabBar>
|
||||
|
||||
</Shell>
|
||||
12
src/Massenger.Client/AppShell.xaml.cs
Normal file
12
src/Massenger.Client/AppShell.xaml.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Massenger.Client.Pages;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
public partial class AppShell : Shell
|
||||
{
|
||||
public AppShell()
|
||||
{
|
||||
InitializeComponent();
|
||||
Routing.RegisterRoute(nameof(ChatPage), typeof(ChatPage));
|
||||
}
|
||||
}
|
||||
73
src/Massenger.Client/Massenger.Client.csproj
Normal file
73
src/Massenger.Client/Massenger.Client.csproj
Normal file
@@ -0,0 +1,73 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>Massenger.Client</RootNamespace>
|
||||
<UseMaui>true</UseMaui>
|
||||
<SingleProject>true</SingleProject>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<!-- Enable XAML source generation for faster build times and improved performance.
|
||||
This generates C# code from XAML at compile time instead of runtime inflation.
|
||||
To disable, remove this line.
|
||||
For individual files, you can override by setting Inflator metadata:
|
||||
<MauiXaml Update="MyPage.xaml" Inflator="Default" /> (reverts to defaults: Runtime for Debug, XamlC for Release)
|
||||
<MauiXaml Update="MyPage.xaml" Inflator="Runtime" /> (force runtime inflation) -->
|
||||
<MauiXamlInflator>SourceGen</MauiXamlInflator>
|
||||
|
||||
<!-- Display name -->
|
||||
<ApplicationTitle>Massenger</ApplicationTitle>
|
||||
|
||||
<!-- App Identifier -->
|
||||
<ApplicationId>com.seven.massenger</ApplicationId>
|
||||
|
||||
<!-- Versions -->
|
||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
|
||||
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- App Icon -->
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#0F766E" />
|
||||
|
||||
<!-- Splash Screen -->
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#0F766E" BaseSize="128,128" />
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
|
||||
|
||||
<!-- Custom Fonts -->
|
||||
<MauiFont Include="Resources\Fonts\*" />
|
||||
|
||||
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
|
||||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
|
||||
<PackageReference Include="Xamarin.Firebase.Messaging" Version="124.0.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Massenger.Shared\Massenger.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
46
src/Massenger.Client/MauiProgram.cs
Normal file
46
src/Massenger.Client/MauiProgram.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Massenger.Client.Services;
|
||||
using Massenger.Client.ViewModels;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
public static class MauiProgram
|
||||
{
|
||||
public static MauiApp CreateMauiApp()
|
||||
{
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder
|
||||
.UseMauiApp<App>()
|
||||
.ConfigureFonts(fonts =>
|
||||
{
|
||||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
|
||||
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<ServerEndpointStore>();
|
||||
builder.Services.AddSingleton<SessionService>();
|
||||
builder.Services.AddSingleton<ApiClient>();
|
||||
builder.Services.AddSingleton<RealtimeService>();
|
||||
builder.Services.AddSingleton<NotificationActivationService>();
|
||||
#if ANDROID
|
||||
builder.Services.AddSingleton<AndroidPushRegistrationService>();
|
||||
builder.Services.AddSingleton<IPushRegistrationService>(sp => sp.GetRequiredService<AndroidPushRegistrationService>());
|
||||
#else
|
||||
builder.Services.AddSingleton<IPushRegistrationService, NoOpPushRegistrationService>();
|
||||
#endif
|
||||
|
||||
builder.Services.AddTransient<LoginViewModel>();
|
||||
builder.Services.AddTransient<ChatsViewModel>();
|
||||
builder.Services.AddTransient<DiscoverViewModel>();
|
||||
builder.Services.AddTransient<ChatViewModel>();
|
||||
builder.Services.AddTransient<ProfileViewModel>();
|
||||
|
||||
#if DEBUG
|
||||
builder.Logging.AddDebug();
|
||||
#endif
|
||||
|
||||
var app = builder.Build();
|
||||
ServiceHelper.Initialize(app.Services);
|
||||
return app;
|
||||
}
|
||||
}
|
||||
46
src/Massenger.Client/Models/ChatAttachmentItem.cs
Normal file
46
src/Massenger.Client/Models/ChatAttachmentItem.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.Models;
|
||||
|
||||
public sealed class ChatAttachmentItem(AttachmentDto attachment)
|
||||
{
|
||||
public Guid Id { get; } = attachment.Id;
|
||||
|
||||
public string FileName { get; } = attachment.FileName;
|
||||
|
||||
public string ContentType { get; } = attachment.ContentType;
|
||||
|
||||
public long FileSizeBytes { get; } = attachment.FileSizeBytes;
|
||||
|
||||
public string DownloadPath { get; } = attachment.DownloadPath;
|
||||
|
||||
public AttachmentKind Kind { get; } = attachment.Kind;
|
||||
|
||||
public bool IsVoiceNote => Kind == AttachmentKind.VoiceNote;
|
||||
|
||||
public string DisplayText => IsVoiceNote
|
||||
? $"Voice note ({FormatFileSize(FileSizeBytes)})"
|
||||
: $"{FileName} ({FormatFileSize(FileSizeBytes)})";
|
||||
|
||||
public string SecondaryText => IsVoiceNote ? FileName : ContentType;
|
||||
|
||||
public string ActionText => IsVoiceNote ? "Play" : "Open";
|
||||
|
||||
private static string FormatFileSize(long bytes)
|
||||
{
|
||||
const double kb = 1024;
|
||||
const double mb = 1024 * 1024;
|
||||
|
||||
if (bytes >= mb)
|
||||
{
|
||||
return $"{bytes / mb:0.0} MB";
|
||||
}
|
||||
|
||||
if (bytes >= kb)
|
||||
{
|
||||
return $"{bytes / kb:0.0} KB";
|
||||
}
|
||||
|
||||
return $"{bytes} B";
|
||||
}
|
||||
}
|
||||
49
src/Massenger.Client/Models/ChatMessageItem.cs
Normal file
49
src/Massenger.Client/Models/ChatMessageItem.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.Models;
|
||||
|
||||
public partial class ChatMessageItem(MessageDto message, Guid currentUserId) : ObservableObject
|
||||
{
|
||||
public Guid Id { get; } = message.Id;
|
||||
|
||||
public Guid ChatId { get; } = message.ChatId;
|
||||
|
||||
public DateTimeOffset SentAt { get; } = message.SentAt;
|
||||
|
||||
public string Author { get; } = message.Sender.DisplayName;
|
||||
|
||||
public string RawText { get; } = message.Text;
|
||||
|
||||
public bool IsDeleted { get; } = message.DeletedAt is not null;
|
||||
|
||||
public bool IsEdited { get; } = message.EditedAt is not null && message.DeletedAt is null;
|
||||
|
||||
public string Text => IsDeleted ? "Message removed." : RawText;
|
||||
|
||||
public bool HasText => IsDeleted || !string.IsNullOrWhiteSpace(Text);
|
||||
|
||||
public string Timestamp { get; } = message.SentAt.LocalDateTime.ToString("HH:mm");
|
||||
|
||||
public string StatusText => IsDeleted
|
||||
? $"{Timestamp} removed"
|
||||
: IsEdited
|
||||
? $"{Timestamp} edited"
|
||||
: Timestamp;
|
||||
|
||||
public bool IsMine { get; } = message.Sender.Id == currentUserId;
|
||||
|
||||
public string BubbleColor => IsMine ? "#0F766E" : "#E2E8F0";
|
||||
|
||||
public string TextColor => IsMine ? "#FFFFFF" : "#0F172A";
|
||||
|
||||
public LayoutOptions RowAlignment => IsMine ? LayoutOptions.End : LayoutOptions.Start;
|
||||
|
||||
public IReadOnlyList<ChatAttachmentItem> Attachments { get; } = message.Attachments
|
||||
.Select(x => new ChatAttachmentItem(x))
|
||||
.ToList();
|
||||
|
||||
public bool HasAttachments => !IsDeleted && Attachments.Count > 0;
|
||||
|
||||
public bool CanManage => IsMine && !IsDeleted;
|
||||
}
|
||||
9
src/Massenger.Client/Models/ClientSession.cs
Normal file
9
src/Massenger.Client/Models/ClientSession.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.Models;
|
||||
|
||||
public sealed record ClientSession(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
DateTimeOffset AccessTokenExpiresAt,
|
||||
UserSummaryDto User);
|
||||
20
src/Massenger.Client/Models/DiscoverUserItem.cs
Normal file
20
src/Massenger.Client/Models/DiscoverUserItem.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.Models;
|
||||
|
||||
public partial class DiscoverUserItem(UserSummaryDto user) : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private bool isSelected;
|
||||
|
||||
public UserSummaryDto User { get; } = user;
|
||||
|
||||
public Guid Id => User.Id;
|
||||
|
||||
public string DisplayName => User.DisplayName;
|
||||
|
||||
public string Username => $"@{User.Username}";
|
||||
|
||||
public string Presence => User.IsOnline ? "online" : "offline";
|
||||
}
|
||||
58
src/Massenger.Client/Models/PendingAttachmentItem.cs
Normal file
58
src/Massenger.Client/Models/PendingAttachmentItem.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Maui.Storage;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.Models;
|
||||
|
||||
public sealed class PendingAttachmentItem
|
||||
{
|
||||
private static readonly HashSet<string> AudioExtensions =
|
||||
[
|
||||
".aac",
|
||||
".flac",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".oga",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
".webm"
|
||||
];
|
||||
|
||||
public PendingAttachmentItem(FileResult file, AttachmentKind kind)
|
||||
{
|
||||
File = file;
|
||||
FileName = file.FileName;
|
||||
ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType;
|
||||
Kind = kind;
|
||||
}
|
||||
|
||||
public FileResult File { get; }
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public string ContentType { get; }
|
||||
|
||||
public AttachmentKind Kind { get; }
|
||||
|
||||
public string DisplayName => Kind == AttachmentKind.VoiceNote ? $"Voice note: {FileName}" : FileName;
|
||||
|
||||
public static PendingAttachmentItem Create(FileResult file) =>
|
||||
new(file, ResolveKind(file.FileName, file.ContentType));
|
||||
|
||||
public static bool IsVoiceFile(string? fileName, string? contentType) =>
|
||||
ResolveKind(fileName, contentType) == AttachmentKind.VoiceNote;
|
||||
|
||||
private static AttachmentKind ResolveKind(string? fileName, string? contentType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(contentType) &&
|
||||
contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AttachmentKind.VoiceNote;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(fileName ?? string.Empty);
|
||||
return AudioExtensions.Contains(extension)
|
||||
? AttachmentKind.VoiceNote
|
||||
: AttachmentKind.File;
|
||||
}
|
||||
}
|
||||
116
src/Massenger.Client/Pages/ChatPage.xaml
Normal file
116
src/Massenger.Client/Pages/ChatPage.xaml
Normal file
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage
|
||||
x:Class="Massenger.Client.Pages.ChatPage"
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:models="clr-namespace:Massenger.Client.Models"
|
||||
xmlns:viewModels="clr-namespace:Massenger.Client.ViewModels"
|
||||
x:Name="ChatPageRoot"
|
||||
Shell.NavBarIsVisible="True"
|
||||
Title="{Binding Title}"
|
||||
x:DataType="viewModels:ChatViewModel">
|
||||
|
||||
<Grid Padding="16" RowDefinitions="Auto,*,Auto" RowSpacing="14">
|
||||
<Border Padding="16" StrokeShape="RoundRectangle 18">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label FontSize="24" FontAttributes="Bold" Text="{Binding Title}" />
|
||||
<Label Text="{Binding Subtitle}" TextColor="#64748B" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<CollectionView Grid.Row="1" ItemsSource="{Binding Messages}">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:ChatMessageItem">
|
||||
<VerticalStackLayout Padding="0,0,0,12">
|
||||
<Border
|
||||
x:Name="MessageBubble"
|
||||
BackgroundColor="{Binding BubbleColor}"
|
||||
HorizontalOptions="{Binding RowAlignment}"
|
||||
Padding="14"
|
||||
StrokeShape="RoundRectangle 20">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label FontAttributes="Bold" Text="{Binding Author}" TextColor="{Binding TextColor}" />
|
||||
<Label IsVisible="{Binding HasText}" Text="{Binding Text}" TextColor="{Binding TextColor}" />
|
||||
<VerticalStackLayout
|
||||
BindableLayout.ItemsSource="{Binding Attachments}"
|
||||
IsVisible="{Binding HasAttachments}"
|
||||
Spacing="8">
|
||||
<BindableLayout.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:ChatAttachmentItem">
|
||||
<Border BackgroundColor="#FFFFFF20" Padding="10" StrokeShape="RoundRectangle 12">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<VerticalStackLayout Spacing="2">
|
||||
<Label FontAttributes="Bold" Text="{Binding DisplayText}" TextColor="{Binding Source={x:Reference MessageBubble}, Path=BindingContext.TextColor}" />
|
||||
<Label FontSize="12" Text="{Binding SecondaryText}" TextColor="{Binding Source={x:Reference MessageBubble}, Path=BindingContext.TextColor}" />
|
||||
</VerticalStackLayout>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
BackgroundColor="#00000020"
|
||||
Command="{Binding Source={x:Reference ChatPageRoot}, Path=BindingContext.OpenAttachmentCommand}"
|
||||
CommandParameter="{Binding .}"
|
||||
Text="{Binding ActionText}"
|
||||
TextColor="{Binding Source={x:Reference MessageBubble}, Path=BindingContext.TextColor}"
|
||||
/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</BindableLayout.ItemTemplate>
|
||||
</VerticalStackLayout>
|
||||
<HorizontalStackLayout IsVisible="{Binding CanManage}" HorizontalOptions="End" Spacing="8">
|
||||
<Button
|
||||
BackgroundColor="#00000020"
|
||||
Command="{Binding Source={x:Reference ChatPageRoot}, Path=BindingContext.BeginEditMessageCommand}"
|
||||
CommandParameter="{Binding .}"
|
||||
Text="Edit"
|
||||
TextColor="{Binding TextColor}" />
|
||||
<Button
|
||||
BackgroundColor="#7F1D1D"
|
||||
Command="{Binding Source={x:Reference ChatPageRoot}, Path=BindingContext.DeleteMessageCommand}"
|
||||
CommandParameter="{Binding .}"
|
||||
Text="Delete"
|
||||
TextColor="#FFFFFF" />
|
||||
</HorizontalStackLayout>
|
||||
<Label FontSize="12" HorizontalTextAlignment="End" Text="{Binding StatusText}" TextColor="{Binding TextColor}" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
|
||||
<VerticalStackLayout Grid.Row="2" Spacing="10">
|
||||
<Border IsVisible="{Binding IsEditingMessage}" Padding="12" StrokeShape="RoundRectangle 14">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<VerticalStackLayout Spacing="2">
|
||||
<Label FontAttributes="Bold" Text="Editing message" />
|
||||
<Label Text="{Binding EditingMessageBanner}" TextColor="#64748B" />
|
||||
</VerticalStackLayout>
|
||||
<Button Grid.Column="1" Command="{Binding CancelEditMessageCommand}" Text="Cancel" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border IsVisible="{Binding HasPendingAttachment}" Padding="12" StrokeShape="RoundRectangle 14">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<VerticalStackLayout Spacing="2">
|
||||
<Label FontAttributes="Bold" Text="Pending attachment" />
|
||||
<Label Text="{Binding PendingAttachmentName}" TextColor="#64748B" />
|
||||
</VerticalStackLayout>
|
||||
<Button Grid.Column="1" Command="{Binding RemovePendingAttachmentCommand}" Text="Remove" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border IsVisible="{Binding ShowReadOnlyBanner}" BackgroundColor="#FFFBEB" Padding="12" StrokeShape="RoundRectangle 14">
|
||||
<Label Text="This channel is read-only for subscribers. Only the owner can publish." TextColor="#92400E" />
|
||||
</Border>
|
||||
|
||||
<Grid IsVisible="{Binding ShowComposer}" ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="12">
|
||||
<Button Command="{Binding PickAttachmentCommand}" Text="Attach" />
|
||||
<Button Grid.Column="1" Command="{Binding PickVoiceNoteCommand}" Text="Voice" />
|
||||
<Editor Grid.Column="2" AutoSize="TextChanges" Placeholder="Write a message or add a file..." Text="{Binding DraftText}" />
|
||||
<Button Grid.Column="3" Command="{Binding SendMessageCommand}" Text="Send" />
|
||||
</Grid>
|
||||
|
||||
<Label IsVisible="{Binding HasError}" Text="{Binding ErrorMessage}" TextColor="#B91C1C" />
|
||||
</VerticalStackLayout>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
44
src/Massenger.Client/Pages/ChatPage.xaml.cs
Normal file
44
src/Massenger.Client/Pages/ChatPage.xaml.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Massenger.Client.Services;
|
||||
using Massenger.Client.ViewModels;
|
||||
|
||||
namespace Massenger.Client.Pages;
|
||||
|
||||
[QueryProperty(nameof(ChatId), "chatId")]
|
||||
public partial class ChatPage : ContentPage
|
||||
{
|
||||
private readonly ChatViewModel _viewModel;
|
||||
|
||||
private string? _chatId;
|
||||
|
||||
public string? ChatId
|
||||
{
|
||||
get => _chatId;
|
||||
set
|
||||
{
|
||||
_chatId = value;
|
||||
_ = LoadChatAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
public ChatPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = ServiceHelper.GetRequiredService<ChatViewModel>();
|
||||
_viewModel.AttachRealtime();
|
||||
BindingContext = _viewModel;
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
base.OnDisappearing();
|
||||
_viewModel.DetachRealtime();
|
||||
}
|
||||
|
||||
private async Task LoadChatAsync(string? chatId)
|
||||
{
|
||||
if (Guid.TryParse(chatId, out var parsed))
|
||||
{
|
||||
await _viewModel.LoadAsync(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/Massenger.Client/Pages/ChatsPage.xaml
Normal file
47
src/Massenger.Client/Pages/ChatsPage.xaml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage
|
||||
x:Class="Massenger.Client.Pages.ChatsPage"
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:shared="clr-namespace:Massenger.Shared;assembly=Massenger.Shared"
|
||||
xmlns:viewModels="clr-namespace:Massenger.Client.ViewModels"
|
||||
x:DataType="viewModels:ChatsViewModel"
|
||||
x:Name="RootPage">
|
||||
|
||||
<Grid Padding="20" RowDefinitions="Auto,*,Auto" RowSpacing="16">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label FontSize="28" FontAttributes="Bold" Text="Chats" />
|
||||
<Label Text="Recent conversations are ordered by last activity." TextColor="#64748B" />
|
||||
</VerticalStackLayout>
|
||||
<Button Grid.Column="1" Command="{Binding LoadCommand}" Text="Refresh" />
|
||||
</Grid>
|
||||
|
||||
<RefreshView Grid.Row="1" Command="{Binding LoadCommand}" IsRefreshing="{Binding IsBusy}">
|
||||
<CollectionView
|
||||
ItemsSource="{Binding Chats}"
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
SelectionMode="Single">
|
||||
<CollectionView.EmptyView>
|
||||
<VerticalStackLayout Padding="24" Spacing="6">
|
||||
<Label HorizontalTextAlignment="Center" Text="{Binding EmptyState}" TextColor="#64748B" />
|
||||
</VerticalStackLayout>
|
||||
</CollectionView.EmptyView>
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="shared:ChatSummaryDto">
|
||||
<Border Margin="0,0,0,12" Padding="16" StrokeShape="RoundRectangle 18">
|
||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto,Auto" RowSpacing="6">
|
||||
<Label FontAttributes="Bold" Text="{Binding Title}" />
|
||||
<Label Grid.Column="1" FontSize="12" Text="{Binding LastActivityAt, StringFormat='{}{0:HH:mm}'}" TextColor="#64748B" />
|
||||
<Label Grid.Row="1" Grid.ColumnSpan="2" Text="{Binding SecondaryText}" TextColor="#0F766E" />
|
||||
<Label Grid.Row="2" Grid.ColumnSpan="2" LineBreakMode="TailTruncation" Text="{Binding LastMessagePreview}" TextColor="#475569" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
</RefreshView>
|
||||
|
||||
<Label Grid.Row="2" FontSize="12" Text="Use Discover to search users and create direct chats, groups or channels." TextColor="#94A3B8" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
32
src/Massenger.Client/Pages/ChatsPage.xaml.cs
Normal file
32
src/Massenger.Client/Pages/ChatsPage.xaml.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Massenger.Client.Services;
|
||||
using Massenger.Client.ViewModels;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.Pages;
|
||||
|
||||
public partial class ChatsPage : ContentPage
|
||||
{
|
||||
private ChatsViewModel ViewModel => (ChatsViewModel)BindingContext;
|
||||
|
||||
public ChatsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = ServiceHelper.GetRequiredService<ChatsViewModel>();
|
||||
ViewModel.AttachRealtime();
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await ViewModel.LoadAsync();
|
||||
}
|
||||
|
||||
private async void OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (e.CurrentSelection.FirstOrDefault() is ChatSummaryDto chat)
|
||||
{
|
||||
await ViewModel.OpenChatAsync(chat);
|
||||
((CollectionView)sender!).SelectedItem = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/Massenger.Client/Pages/DiscoverPage.xaml
Normal file
75
src/Massenger.Client/Pages/DiscoverPage.xaml
Normal file
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage
|
||||
x:Class="Massenger.Client.Pages.DiscoverPage"
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:models="clr-namespace:Massenger.Client.Models"
|
||||
xmlns:viewModels="clr-namespace:Massenger.Client.ViewModels"
|
||||
x:DataType="viewModels:DiscoverViewModel"
|
||||
x:Name="RootPage">
|
||||
|
||||
<ScrollView>
|
||||
<VerticalStackLayout Padding="20" Spacing="18">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label FontSize="28" FontAttributes="Bold" Text="Discover" />
|
||||
<Label Text="Search teammates, open direct chats, assemble groups or create broadcast channels." TextColor="#64748B" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
|
||||
<Entry Placeholder="Find by username or display name" Text="{Binding Query}" />
|
||||
<Button Grid.Column="1" Command="{Binding SearchCommand}" Text="Search" />
|
||||
</Grid>
|
||||
|
||||
<Border Padding="18" StrokeShape="RoundRectangle 20">
|
||||
<VerticalStackLayout Spacing="12">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<Label FontAttributes="Bold" Text="Users" />
|
||||
<Label Grid.Column="1" Text="{Binding SelectedCount, StringFormat='Selected: {0}'}" TextColor="#64748B" />
|
||||
</Grid>
|
||||
|
||||
<CollectionView ItemsSource="{Binding Results}">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:DiscoverUserItem">
|
||||
<Border Margin="0,0,0,10" Padding="14" StrokeShape="RoundRectangle 18">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
|
||||
<CheckBox IsChecked="{Binding IsSelected}" VerticalOptions="Center" />
|
||||
<VerticalStackLayout Grid.Column="1" Spacing="2">
|
||||
<Label FontAttributes="Bold" Text="{Binding DisplayName}" />
|
||||
<Label FontSize="12" Text="{Binding Username}" TextColor="#64748B" />
|
||||
<Label FontSize="12" Text="{Binding Presence}" TextColor="#0F766E" />
|
||||
</VerticalStackLayout>
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Command="{Binding Source={x:Reference RootPage}, Path=BindingContext.StartDirectChatCommand}"
|
||||
CommandParameter="{Binding .}"
|
||||
Text="Direct" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Border BackgroundColor="#F8FAFC" Padding="18" StrokeShape="RoundRectangle 20">
|
||||
<VerticalStackLayout Spacing="12">
|
||||
<Label FontAttributes="Bold" Text="Create group chat" />
|
||||
<Entry Placeholder="Group title" Text="{Binding GroupTitle}" />
|
||||
<Button Command="{Binding CreateGroupCommand}" Text="Create group" />
|
||||
<Label FontSize="12" Text="Select at least two users above, then create the room." TextColor="#64748B" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Border BackgroundColor="#ECFEFF" Padding="18" StrokeShape="RoundRectangle 20">
|
||||
<VerticalStackLayout Spacing="12">
|
||||
<Label FontAttributes="Bold" Text="Create channel" />
|
||||
<Entry Placeholder="Channel title" Text="{Binding ChannelTitle}" />
|
||||
<Button Command="{Binding CreateChannelCommand}" Text="Create channel" />
|
||||
<Label FontSize="12" Text="Selected users become subscribers. The channel creator is the only publisher." TextColor="#0F766E" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Label IsVisible="{Binding HasError}" Text="{Binding ErrorMessage}" TextColor="#B91C1C" />
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
</ContentPage>
|
||||
24
src/Massenger.Client/Pages/DiscoverPage.xaml.cs
Normal file
24
src/Massenger.Client/Pages/DiscoverPage.xaml.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Massenger.Client.Services;
|
||||
using Massenger.Client.ViewModels;
|
||||
|
||||
namespace Massenger.Client.Pages;
|
||||
|
||||
public partial class DiscoverPage : ContentPage
|
||||
{
|
||||
private DiscoverViewModel ViewModel => (DiscoverViewModel)BindingContext;
|
||||
|
||||
public DiscoverPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = ServiceHelper.GetRequiredService<DiscoverViewModel>();
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
if (ViewModel.Results.Count == 0)
|
||||
{
|
||||
await ViewModel.SearchAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/Massenger.Client/Pages/LoginPage.xaml
Normal file
58
src/Massenger.Client/Pages/LoginPage.xaml
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage
|
||||
x:Class="Massenger.Client.Pages.LoginPage"
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:viewModels="clr-namespace:Massenger.Client.ViewModels"
|
||||
x:DataType="viewModels:LoginViewModel"
|
||||
Title="Massenger">
|
||||
|
||||
<ContentPage.Background>
|
||||
<LinearGradientBrush EndPoint="1,1">
|
||||
<GradientStop Color="#F8FAFC" Offset="0" />
|
||||
<GradientStop Color="#D1FAE5" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</ContentPage.Background>
|
||||
|
||||
<ScrollView>
|
||||
<VerticalStackLayout Padding="24,48,24,24" Spacing="24">
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label FontSize="40" FontAttributes="Bold" Text="Massenger" TextColor="#0F172A" />
|
||||
<Label Text="{Binding PageTitle}" TextColor="#334155" />
|
||||
<Label Text="Core Telegram-like flow: auth, direct chats, groups, real-time delivery and shared history." TextColor="#475569" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<Border BackgroundColor="{AppThemeBinding Light=White, Dark=#0F172A}" Padding="20" StrokeShape="RoundRectangle 24">
|
||||
<VerticalStackLayout Spacing="16">
|
||||
<Label FontAttributes="Bold" Text="Server endpoint" />
|
||||
<Entry Placeholder="http://localhost:5099" Text="{Binding ServerUrl}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
|
||||
<VerticalStackLayout Grid.Column="0" Spacing="4">
|
||||
<Label FontAttributes="Bold" Text="Create account mode" />
|
||||
<Label FontSize="12" Text="Turn on only when you want to register a new user." TextColor="#64748B" />
|
||||
</VerticalStackLayout>
|
||||
<Switch Grid.Column="1" IsToggled="{Binding IsRegisterMode}" VerticalOptions="Center" />
|
||||
</Grid>
|
||||
|
||||
<Entry Placeholder="Username" Text="{Binding Username}" />
|
||||
<Entry IsVisible="{Binding IsRegisterMode}" Placeholder="Display name" Text="{Binding DisplayName}" />
|
||||
<Entry IsPassword="True" Placeholder="Password" Text="{Binding Password}" />
|
||||
|
||||
<Button Command="{Binding AuthenticateCommand}" Text="{Binding PrimaryActionText}" />
|
||||
|
||||
<Label IsVisible="{Binding HasError}" Text="{Binding ErrorMessage}" TextColor="#B91C1C" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Border BackgroundColor="#ECFEFF" Padding="20" StrokeShape="RoundRectangle 20">
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label FontAttributes="Bold" Text="Seeded demo users" TextColor="#0F172A" />
|
||||
<Label Text="alice / demo123" TextColor="#155E75" />
|
||||
<Label Text="bob / demo123" TextColor="#155E75" />
|
||||
<Label Text="carol / demo123" TextColor="#155E75" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
</ContentPage>
|
||||
13
src/Massenger.Client/Pages/LoginPage.xaml.cs
Normal file
13
src/Massenger.Client/Pages/LoginPage.xaml.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Massenger.Client.Services;
|
||||
using Massenger.Client.ViewModels;
|
||||
|
||||
namespace Massenger.Client.Pages;
|
||||
|
||||
public partial class LoginPage : ContentPage
|
||||
{
|
||||
public LoginPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = ServiceHelper.GetRequiredService<LoginViewModel>();
|
||||
}
|
||||
}
|
||||
39
src/Massenger.Client/Pages/ProfilePage.xaml
Normal file
39
src/Massenger.Client/Pages/ProfilePage.xaml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage
|
||||
x:Class="Massenger.Client.Pages.ProfilePage"
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:viewModels="clr-namespace:Massenger.Client.ViewModels"
|
||||
x:DataType="viewModels:ProfileViewModel">
|
||||
|
||||
<ScrollView>
|
||||
<VerticalStackLayout Padding="20" Spacing="18">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label FontSize="28" FontAttributes="Bold" Text="Profile" />
|
||||
<Label Text="Account and connectivity settings." TextColor="#64748B" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<Border BackgroundColor="#ECFEFF" Padding="18" StrokeShape="RoundRectangle 20">
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label FontSize="24" FontAttributes="Bold" Text="{Binding DisplayName}" />
|
||||
<Label Text="{Binding Username}" TextColor="#155E75" />
|
||||
<Label Text="{Binding ConnectionStatus, StringFormat='Realtime: {0}'}" TextColor="#0F766E" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Border Padding="18" StrokeShape="RoundRectangle 20">
|
||||
<VerticalStackLayout Spacing="12">
|
||||
<Label FontAttributes="Bold" Text="Server URL" />
|
||||
<Entry Placeholder="http://localhost:5099" Text="{Binding ServerUrl}" />
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<Button Command="{Binding SaveServerCommand}" Text="Save and reconnect" />
|
||||
<Button Grid.Column="1" Command="{Binding RefreshProfileCommand}" Text="Refresh profile" />
|
||||
</Grid>
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Button Command="{Binding LogoutCommand}" Text="Sign out" />
|
||||
<Label IsVisible="{Binding HasError}" Text="{Binding ErrorMessage}" TextColor="#B91C1C" />
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
</ContentPage>
|
||||
21
src/Massenger.Client/Pages/ProfilePage.xaml.cs
Normal file
21
src/Massenger.Client/Pages/ProfilePage.xaml.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Massenger.Client.Services;
|
||||
using Massenger.Client.ViewModels;
|
||||
|
||||
namespace Massenger.Client.Pages;
|
||||
|
||||
public partial class ProfilePage : ContentPage
|
||||
{
|
||||
private ProfileViewModel ViewModel => (ProfileViewModel)BindingContext;
|
||||
|
||||
public ProfilePage()
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = ServiceHelper.GetRequiredService<ProfileViewModel>();
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await ViewModel.LoadAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
using Android.Content;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
internal static class AndroidFirebaseOptionsLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static AndroidFirebaseOptions? _cachedOptions;
|
||||
|
||||
public static AndroidFirebaseOptions? TryLoad(Context context)
|
||||
{
|
||||
if (_cachedOptions is not null)
|
||||
{
|
||||
return _cachedOptions;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = context.Assets!.Open("firebase.android.json");
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
var options = JsonSerializer.Deserialize<AndroidFirebaseOptions>(json, JsonOptions);
|
||||
if (options is null ||
|
||||
string.IsNullOrWhiteSpace(options.ApplicationId) ||
|
||||
string.IsNullOrWhiteSpace(options.ProjectId) ||
|
||||
string.IsNullOrWhiteSpace(options.ApiKey) ||
|
||||
string.IsNullOrWhiteSpace(options.SenderId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_cachedOptions = options;
|
||||
return _cachedOptions;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AndroidFirebaseOptions(
|
||||
string ApplicationId,
|
||||
string ProjectId,
|
||||
string ApiKey,
|
||||
string SenderId);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true" android:usesCleartextTraffic="true"></application>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,59 @@
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using AndroidX.Core.App;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
internal static class AndroidNotificationDisplayService
|
||||
{
|
||||
public static void Show(Context context, IDictionary<string, string> data)
|
||||
{
|
||||
if (!AndroidNotificationPermissionHelper.AreNotificationsEnabled(context))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var title = data.TryGetValue(PushDataKeys.Title, out var rawTitle) && !string.IsNullOrWhiteSpace(rawTitle)
|
||||
? rawTitle
|
||||
: "Massenger";
|
||||
var body = data.TryGetValue(PushDataKeys.Body, out var rawBody) && !string.IsNullOrWhiteSpace(rawBody)
|
||||
? rawBody
|
||||
: "New message";
|
||||
|
||||
var intent = new Intent(context, typeof(MainActivity))
|
||||
.SetAction(AndroidPushBootstrapper.NotificationAction)
|
||||
.AddFlags(ActivityFlags.SingleTop | ActivityFlags.ClearTop);
|
||||
|
||||
if (data.TryGetValue(PushDataKeys.ChatId, out var chatId))
|
||||
{
|
||||
intent.PutExtra(PushDataKeys.ChatId, chatId);
|
||||
}
|
||||
|
||||
var requestCode = data.TryGetValue(PushDataKeys.MessageId, out var messageId)
|
||||
? messageId.GetHashCode(StringComparison.Ordinal)
|
||||
: Environment.TickCount;
|
||||
|
||||
var pendingIntentFlags = OperatingSystem.IsAndroidVersionAtLeast(23)
|
||||
? PendingIntentFlags.UpdateCurrent | PendingIntentFlags.Immutable
|
||||
: PendingIntentFlags.UpdateCurrent;
|
||||
|
||||
using var pendingIntent = PendingIntent.GetActivity(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
pendingIntentFlags);
|
||||
|
||||
var builder = new NotificationCompat.Builder(context, AndroidPushBootstrapper.NotificationChannelId);
|
||||
builder.SetContentTitle(title);
|
||||
builder.SetContentText(body);
|
||||
builder.SetSmallIcon(Resource.Mipmap.appicon);
|
||||
builder.SetAutoCancel(true);
|
||||
builder.SetPriority((int)NotificationPriority.High);
|
||||
builder.SetContentIntent(pendingIntent);
|
||||
|
||||
var notification = builder.Build();
|
||||
|
||||
NotificationManagerCompat.From(context)?.Notify(requestCode, notification);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Android;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using AndroidX.Core.App;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
internal static class AndroidNotificationPermissionHelper
|
||||
{
|
||||
private const string PostNotificationsPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
|
||||
public const int RequestCode = 5101;
|
||||
|
||||
public static bool AreNotificationsEnabled(Context context)
|
||||
{
|
||||
if (OperatingSystem.IsAndroidVersionAtLeast(33) &&
|
||||
context.CheckSelfPermission(PostNotificationsPermission) != Permission.Granted)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return NotificationManagerCompat.From(context)?.AreNotificationsEnabled() ?? false;
|
||||
}
|
||||
|
||||
public static void RequestIfNeeded(Activity activity)
|
||||
{
|
||||
if (!OperatingSystem.IsAndroidVersionAtLeast(33))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (activity.CheckSelfPermission(PostNotificationsPermission) == Permission.Granted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.RequestPermissions([PostNotificationsPermission], RequestCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.OS;
|
||||
using Firebase;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
internal static class AndroidPushBootstrapper
|
||||
{
|
||||
public const string NotificationAction = "com.seven.massenger.OPEN_CHAT";
|
||||
public const string NotificationChannelId = "massenger_messages";
|
||||
|
||||
private static bool _attemptedInitialization;
|
||||
|
||||
public static bool TryInitialize(Context context)
|
||||
{
|
||||
if (_attemptedInitialization)
|
||||
{
|
||||
EnsureNotificationChannel(context);
|
||||
return FirebaseApp.InitializeApp(context) is not null || TryGetExistingApp() is not null;
|
||||
}
|
||||
|
||||
_attemptedInitialization = true;
|
||||
|
||||
var existingApp = TryGetExistingApp();
|
||||
if (existingApp is not null)
|
||||
{
|
||||
EnsureNotificationChannel(context);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (FirebaseApp.InitializeApp(context) is not null)
|
||||
{
|
||||
EnsureNotificationChannel(context);
|
||||
return true;
|
||||
}
|
||||
|
||||
var options = AndroidFirebaseOptionsLoader.TryLoad(context);
|
||||
if (options is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var firebaseOptions = new FirebaseOptions.Builder()
|
||||
.SetApplicationId(options.ApplicationId)
|
||||
.SetProjectId(options.ProjectId)
|
||||
.SetApiKey(options.ApiKey)
|
||||
.SetGcmSenderId(options.SenderId)
|
||||
.Build();
|
||||
|
||||
var createdApp = FirebaseApp.InitializeApp(context, firebaseOptions);
|
||||
if (createdApp is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureNotificationChannel(context);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void EnsureNotificationChannel(Context context)
|
||||
{
|
||||
if (!OperatingSystem.IsAndroidVersionAtLeast(26))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var manager = (NotificationManager?)context.GetSystemService(Context.NotificationService);
|
||||
if (manager?.GetNotificationChannel(NotificationChannelId) is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var channel = new NotificationChannel(
|
||||
NotificationChannelId,
|
||||
"Messages",
|
||||
NotificationImportance.High)
|
||||
{
|
||||
Description = "Chat message notifications"
|
||||
};
|
||||
|
||||
manager?.CreateNotificationChannel(channel);
|
||||
}
|
||||
|
||||
private static FirebaseApp? TryGetExistingApp()
|
||||
{
|
||||
try
|
||||
{
|
||||
return FirebaseApp.Instance;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
internal static class AndroidPushSettings
|
||||
{
|
||||
private const string PreferencesName = "massenger_push";
|
||||
private const string InstallationIdKey = "installation_id";
|
||||
private const string DeviceTokenKey = "device_token";
|
||||
|
||||
public static string GetInstallationId()
|
||||
{
|
||||
var preferences = GetPreferences();
|
||||
var installationId = preferences.GetString(InstallationIdKey, null);
|
||||
if (!string.IsNullOrWhiteSpace(installationId))
|
||||
{
|
||||
return installationId;
|
||||
}
|
||||
|
||||
installationId = Guid.NewGuid().ToString("N");
|
||||
using var editor = preferences.Edit() ?? throw new InvalidOperationException("Android preferences editor is unavailable.");
|
||||
editor.PutString(InstallationIdKey, installationId);
|
||||
editor.Apply();
|
||||
return installationId;
|
||||
}
|
||||
|
||||
public static string? GetDeviceToken() =>
|
||||
GetPreferences().GetString(DeviceTokenKey, null);
|
||||
|
||||
public static void SetDeviceToken(string token)
|
||||
{
|
||||
using var editor = GetPreferences().Edit() ?? throw new InvalidOperationException("Android preferences editor is unavailable.");
|
||||
editor.PutString(DeviceTokenKey, token);
|
||||
editor.Apply();
|
||||
}
|
||||
|
||||
private static ISharedPreferences GetPreferences() =>
|
||||
Android.App.Application.Context.GetSharedPreferences(PreferencesName, FileCreationMode.Private)!;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Android.Gms.Tasks;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
internal static class AndroidTaskExtensions
|
||||
{
|
||||
public static Task<string?> AsStringAsync(this Android.Gms.Tasks.Task task, System.Threading.CancellationToken cancellationToken = default)
|
||||
{
|
||||
var taskCompletionSource = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (cancellationToken.CanBeCanceled)
|
||||
{
|
||||
cancellationToken.Register(() => taskCompletionSource.TrySetCanceled(cancellationToken));
|
||||
}
|
||||
|
||||
task.AddOnCompleteListener(new TaskCompletionListener(taskCompletionSource));
|
||||
return taskCompletionSource.Task;
|
||||
}
|
||||
|
||||
private sealed class TaskCompletionListener(TaskCompletionSource<string?> taskCompletionSource)
|
||||
: Java.Lang.Object,
|
||||
IOnCompleteListener
|
||||
{
|
||||
public void OnComplete(Android.Gms.Tasks.Task task)
|
||||
{
|
||||
if (task.IsCanceled)
|
||||
{
|
||||
taskCompletionSource.TrySetCanceled();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!task.IsSuccessful)
|
||||
{
|
||||
taskCompletionSource.TrySetException(new InvalidOperationException(task.Exception?.Message ?? "Android task failed."));
|
||||
return;
|
||||
}
|
||||
|
||||
taskCompletionSource.TrySetResult(task.Result?.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Android.App;
|
||||
using Firebase.Messaging;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
internal static class FirebaseTokenProvider
|
||||
{
|
||||
public static async Task<string?> TryGetTokenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!AndroidPushBootstrapper.TryInitialize(Android.App.Application.Context))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await FirebaseMessaging.Instance.GetToken().AsStringAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
59
src/Massenger.Client/Platforms/Android/MainActivity.cs
Normal file
59
src/Massenger.Client/Platforms/Android/MainActivity.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using System.Runtime.Versioning;
|
||||
using Massenger.Client.Services;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||
public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
AndroidNotificationPermissionHelper.RequestIfNeeded(this);
|
||||
HandleNotificationIntent(Intent);
|
||||
}
|
||||
|
||||
protected override void OnNewIntent(Intent? intent)
|
||||
{
|
||||
base.OnNewIntent(intent);
|
||||
if (intent is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Intent = intent;
|
||||
HandleNotificationIntent(intent);
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("android23.0")]
|
||||
public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults)
|
||||
{
|
||||
base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
|
||||
if (requestCode != AndroidNotificationPermissionHelper.RequestCode ||
|
||||
grantResults.Length == 0 ||
|
||||
grantResults[0] != Permission.Granted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = ServiceHelper.GetService<AndroidPushRegistrationService>()?.RegisterCurrentDeviceAsync() ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void HandleNotificationIntent(Intent? intent)
|
||||
{
|
||||
var chatIdRaw = intent?.GetStringExtra(PushDataKeys.ChatId);
|
||||
if (!Guid.TryParse(chatIdRaw, out var chatId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceHelper.GetService<NotificationActivationService>()?.SetPendingChat(chatId);
|
||||
intent?.RemoveExtra(PushDataKeys.ChatId);
|
||||
}
|
||||
}
|
||||
21
src/Massenger.Client/Platforms/Android/MainApplication.cs
Normal file
21
src/Massenger.Client/Platforms/Android/MainApplication.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Android.App;
|
||||
using Android.Runtime;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
[Application]
|
||||
public class MainApplication : MauiApplication
|
||||
{
|
||||
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
|
||||
: base(handle, ownership)
|
||||
{
|
||||
}
|
||||
|
||||
public override void OnCreate()
|
||||
{
|
||||
base.OnCreate();
|
||||
AndroidPushBootstrapper.TryInitialize(this);
|
||||
}
|
||||
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Firebase.Messaging;
|
||||
using Massenger.Client.Services;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
[Service(Exported = false, Name = "com.seven.massenger.MassengerFirebaseMessagingService")]
|
||||
[IntentFilter(new[] { "com.google.firebase.MESSAGING_EVENT" })]
|
||||
public sealed class MassengerFirebaseMessagingService : FirebaseMessagingService
|
||||
{
|
||||
public override void OnNewToken(string token)
|
||||
{
|
||||
base.OnNewToken(token);
|
||||
AndroidPushSettings.SetDeviceToken(token);
|
||||
_ = ServiceHelper.GetService<AndroidPushRegistrationService>()?.OnTokenRefreshedAsync(token) ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override void OnMessageReceived(RemoteMessage message)
|
||||
{
|
||||
base.OnMessageReceived(message);
|
||||
AndroidPushBootstrapper.EnsureNotificationChannel(this);
|
||||
|
||||
if (message.Data?.Count > 0)
|
||||
{
|
||||
AndroidNotificationDisplayService.Show(this, message.Data);
|
||||
return;
|
||||
}
|
||||
|
||||
var title = message.GetNotification()?.Title;
|
||||
var body = message.GetNotification()?.Body;
|
||||
AndroidNotificationDisplayService.Show(
|
||||
this,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["title"] = title ?? "Massenger",
|
||||
["body"] = body ?? "New message"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#0F766E</color>
|
||||
<color name="colorPrimaryDark">#115E59</color>
|
||||
<color name="colorAccent">#115E59</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,102 @@
|
||||
using Android.App;
|
||||
using Android.OS;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.Services;
|
||||
|
||||
public sealed class AndroidPushRegistrationService(
|
||||
ApiClient apiClient,
|
||||
SessionService sessionService,
|
||||
ILogger<AndroidPushRegistrationService> logger) : IPushRegistrationService
|
||||
{
|
||||
public Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
AndroidPushBootstrapper.TryInitialize(Android.App.Application.Context);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task RegisterCurrentDeviceAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!sessionService.IsAuthenticated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AndroidPushBootstrapper.TryInitialize(Android.App.Application.Context))
|
||||
{
|
||||
logger.LogDebug("Android push configuration was not found. Device registration skipped.");
|
||||
return;
|
||||
}
|
||||
|
||||
var token = AndroidPushSettings.GetDeviceToken();
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
try
|
||||
{
|
||||
token = await FirebaseTokenProvider.TryGetTokenAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to obtain Android FCM device token.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AndroidPushSettings.SetDeviceToken(token);
|
||||
|
||||
var request = new RegisterPushDeviceRequest(
|
||||
PushPlatform.Android,
|
||||
AndroidPushSettings.GetInstallationId(),
|
||||
token,
|
||||
BuildDeviceName(),
|
||||
AndroidNotificationPermissionHelper.AreNotificationsEnabled(Android.App.Application.Context));
|
||||
|
||||
await apiClient.RegisterPushDeviceAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UnregisterCurrentDeviceAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!sessionService.IsAuthenticated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.UnregisterPushDeviceAsync(AndroidPushSettings.GetInstallationId(), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task OnTokenRefreshedAsync(string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AndroidPushSettings.SetDeviceToken(token);
|
||||
if (!sessionService.IsAuthenticated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await RegisterCurrentDeviceAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to sync refreshed Android FCM token.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildDeviceName()
|
||||
{
|
||||
var manufacturer = Build.Manufacturer ?? "Android";
|
||||
var model = Build.Model ?? "device";
|
||||
return $"{manufacturer} {model}".Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Foundation;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
|
||||
<dict>
|
||||
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
40
src/Massenger.Client/Platforms/MacCatalyst/Info.plist
Normal file
40
src/Massenger.Client/Platforms/MacCatalyst/Info.plist
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- The Mac App Store requires you specify if the app uses encryption. -->
|
||||
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
|
||||
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
|
||||
<!-- Please indicate <true/> or <false/> here. -->
|
||||
|
||||
<!-- Specify the category for your app here. -->
|
||||
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
|
||||
<!-- <key>LSApplicationCategoryType</key> -->
|
||||
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.lifestyle</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>XSAppIconAssets</key>
|
||||
<string>Assets.xcassets/appicon.appiconset</string>
|
||||
</dict>
|
||||
</plist>
|
||||
15
src/Massenger.Client/Platforms/MacCatalyst/Program.cs
Normal file
15
src/Massenger.Client/Platforms/MacCatalyst/Program.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using ObjCRuntime;
|
||||
using UIKit;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
public class Program
|
||||
{
|
||||
// This is the main entry point of the application.
|
||||
static void Main(string[] args)
|
||||
{
|
||||
// if you want to use a different Application Delegate class from "AppDelegate"
|
||||
// you can specify it here.
|
||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||
}
|
||||
}
|
||||
8
src/Massenger.Client/Platforms/Windows/App.xaml
Normal file
8
src/Massenger.Client/Platforms/Windows/App.xaml
Normal file
@@ -0,0 +1,8 @@
|
||||
<maui:MauiWinUIApplication
|
||||
x:Class="Massenger.Client.WinUI.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:maui="using:Microsoft.Maui"
|
||||
xmlns:local="using:Massenger.Client.WinUI">
|
||||
|
||||
</maui:MauiWinUIApplication>
|
||||
24
src/Massenger.Client/Platforms/Windows/App.xaml.cs
Normal file
24
src/Massenger.Client/Platforms/Windows/App.xaml.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace Massenger.Client.WinUI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : MauiWinUIApplication
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the singleton application object. This is the first line of authored code
|
||||
/// executed, and as such is the logical equivalent of main() or WinMain().
|
||||
/// </summary>
|
||||
public App()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
}
|
||||
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
|
||||
46
src/Massenger.Client/Platforms/Windows/Package.appxmanifest
Normal file
46
src/Massenger.Client/Platforms/Windows/Package.appxmanifest
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap rescap">
|
||||
|
||||
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="707E1EB5-AB6F-4E2D-865F-42E17A9AB4F9" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
<Properties>
|
||||
<DisplayName>$placeholder$</DisplayName>
|
||||
<PublisherDisplayName>User Name</PublisherDisplayName>
|
||||
<Logo>$placeholder$.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="x-generate" />
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
|
||||
<uap:VisualElements
|
||||
DisplayName="$placeholder$"
|
||||
Description="$placeholder$"
|
||||
Square150x150Logo="$placeholder$.png"
|
||||
Square44x44Logo="$placeholder$.png"
|
||||
BackgroundColor="transparent">
|
||||
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
|
||||
<uap:SplashScreen Image="$placeholder$.png" />
|
||||
</uap:VisualElements>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
</Capabilities>
|
||||
|
||||
</Package>
|
||||
17
src/Massenger.Client/Platforms/Windows/app.manifest
Normal file
17
src/Massenger.Client/Platforms/Windows/app.manifest
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="Massenger.Client.WinUI.app"/>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<!-- The combination of below two tags have the following effect:
|
||||
1) Per-Monitor for >= Windows 10 Anniversary Update
|
||||
2) System < Windows 10 Anniversary Update
|
||||
-->
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
|
||||
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
9
src/Massenger.Client/Platforms/iOS/AppDelegate.cs
Normal file
9
src/Massenger.Client/Platforms/iOS/AppDelegate.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Foundation;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
32
src/Massenger.Client/Platforms/iOS/Info.plist
Normal file
32
src/Massenger.Client/Platforms/iOS/Info.plist
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>XSAppIconAssets</key>
|
||||
<string>Assets.xcassets/appicon.appiconset</string>
|
||||
</dict>
|
||||
</plist>
|
||||
15
src/Massenger.Client/Platforms/iOS/Program.cs
Normal file
15
src/Massenger.Client/Platforms/iOS/Program.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using ObjCRuntime;
|
||||
using UIKit;
|
||||
|
||||
namespace Massenger.Client;
|
||||
|
||||
public class Program
|
||||
{
|
||||
// This is the main entry point of the application.
|
||||
static void Main(string[] args)
|
||||
{
|
||||
// if you want to use a different Application Delegate class from "AppDelegate"
|
||||
// you can specify it here.
|
||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
|
||||
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
|
||||
|
||||
You are responsible for adding extra entries as needed for your application.
|
||||
|
||||
More information: https://aka.ms/maui-privacy-manifest
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!--
|
||||
The entry below is only needed when you're using the Preferences API in your app.
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict> -->
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
8
src/Massenger.Client/Properties/launchSettings.json
Normal file
8
src/Massenger.Client/Properties/launchSettings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Windows Machine": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": false
|
||||
}
|
||||
}
|
||||
}
|
||||
4
src/Massenger.Client/Resources/AppIcon/appicon.svg
Normal file
4
src/Massenger.Client/Resources/AppIcon/appicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
8
src/Massenger.Client/Resources/AppIcon/appiconfg.svg
Normal file
8
src/Massenger.Client/Resources/AppIcon/appiconfg.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/Massenger.Client/Resources/Fonts/OpenSans-Regular.ttf
Normal file
BIN
src/Massenger.Client/Resources/Fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
src/Massenger.Client/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
BIN
src/Massenger.Client/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
Binary file not shown.
BIN
src/Massenger.Client/Resources/Images/dotnet_bot.png
Normal file
BIN
src/Massenger.Client/Resources/Images/dotnet_bot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
15
src/Massenger.Client/Resources/Raw/AboutAssets.txt
Normal file
15
src/Massenger.Client/Resources/Raw/AboutAssets.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
Any raw assets you want to be deployed with your application can be placed in
|
||||
this directory (and child directories). Deployment of the asset to your application
|
||||
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
|
||||
|
||||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
|
||||
These files will be deployed with your package and will be accessible using Essentials:
|
||||
|
||||
async Task LoadMauiAsset()
|
||||
{
|
||||
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var contents = reader.ReadToEnd();
|
||||
}
|
||||
6
src/Massenger.Client/Resources/Raw/firebase.android.json
Normal file
6
src/Massenger.Client/Resources/Raw/firebase.android.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"applicationId": "",
|
||||
"projectId": "",
|
||||
"apiKey": "",
|
||||
"senderId": ""
|
||||
}
|
||||
8
src/Massenger.Client/Resources/Splash/splash.svg
Normal file
8
src/Massenger.Client/Resources/Splash/splash.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
20
src/Massenger.Client/Resources/Styles/Colors.xaml
Normal file
20
src/Massenger.Client/Resources/Styles/Colors.xaml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<Color x:Key="Accent">#0F766E</Color>
|
||||
<Color x:Key="AccentDeep">#115E59</Color>
|
||||
<Color x:Key="AccentSoft">#D1FAE5</Color>
|
||||
<Color x:Key="Surface">#FFFFFF</Color>
|
||||
<Color x:Key="SurfaceMuted">#F8FAFC</Color>
|
||||
<Color x:Key="Ink">#0F172A</Color>
|
||||
<Color x:Key="InkMuted">#64748B</Color>
|
||||
<Color x:Key="Danger">#B91C1C</Color>
|
||||
<Color x:Key="Border">#CBD5E1</Color>
|
||||
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource Accent}" />
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource Surface}" />
|
||||
<SolidColorBrush x:Key="SurfaceMutedBrush" Color="{StaticResource SurfaceMuted}" />
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource Border}" />
|
||||
</ResourceDictionary>
|
||||
65
src/Massenger.Client/Resources/Styles/Styles.xaml
Normal file
65
src/Massenger.Client/Resources/Styles/Styles.xaml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<Style TargetType="Page" ApplyToDerivedTypes="True">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource SurfaceMuted}, Dark=#020617}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Surface}, Dark=#0F172A}" />
|
||||
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Border}, Dark=#1E293B}" />
|
||||
<Setter Property="StrokeThickness" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Ink}, Dark=White}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource Accent}" />
|
||||
<Setter Property="TextColor" Value="White" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Padding" Value="18,12" />
|
||||
<Setter Property="FontFamily" Value="OpenSansSemibold" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Entry">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light=White, Dark=#0F172A}" />
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Ink}, Dark=White}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource InkMuted}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Editor">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light=White, Dark=#0F172A}" />
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Ink}, Dark=White}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource InkMuted}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Color" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Switch">
|
||||
<Setter Property="OnColor" Value="{StaticResource AccentSoft}" />
|
||||
<Setter Property="ThumbColor" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ActivityIndicator">
|
||||
<Setter Property="Color" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Shell" ApplyToDerivedTypes="True">
|
||||
<Setter Property="Shell.BackgroundColor" Value="{StaticResource Surface}" />
|
||||
<Setter Property="Shell.ForegroundColor" Value="{StaticResource Ink}" />
|
||||
<Setter Property="Shell.TitleColor" Value="{StaticResource Ink}" />
|
||||
<Setter Property="Shell.TabBarBackgroundColor" Value="{StaticResource Surface}" />
|
||||
<Setter Property="Shell.TabBarForegroundColor" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Shell.TabBarTitleColor" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Shell.TabBarUnselectedColor" Value="{StaticResource InkMuted}" />
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
348
src/Massenger.Client/Services/ApiClient.cs
Normal file
348
src/Massenger.Client/Services/ApiClient.cs
Normal file
@@ -0,0 +1,348 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Maui.Storage;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.Services;
|
||||
|
||||
public sealed class ApiClient(ServerEndpointStore endpointStore, SessionService sessionService)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||
|
||||
public Task<AuthSessionDto> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<AuthSessionDto>(HttpMethod.Post, "api/auth/login", request, cancellationToken, requiresAuth: false);
|
||||
|
||||
public Task<AuthSessionDto> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<AuthSessionDto>(HttpMethod.Post, "api/auth/register", request, cancellationToken, requiresAuth: false);
|
||||
|
||||
public Task<AuthSessionDto> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<AuthSessionDto>(
|
||||
HttpMethod.Post,
|
||||
"api/auth/refresh",
|
||||
new RefreshSessionRequest(refreshToken),
|
||||
cancellationToken,
|
||||
requiresAuth: false);
|
||||
|
||||
public Task LogoutAsync(CancellationToken cancellationToken = default) =>
|
||||
SendAsync(HttpMethod.Post, "api/auth/logout", null, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<PushDeviceDto>> GetPushDevicesAsync(CancellationToken cancellationToken = default) =>
|
||||
SendAsync<IReadOnlyList<PushDeviceDto>>(HttpMethod.Get, "api/push/devices", null, cancellationToken);
|
||||
|
||||
public Task<PushDeviceDto> RegisterPushDeviceAsync(RegisterPushDeviceRequest request, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<PushDeviceDto>(HttpMethod.Post, "api/push/devices", request, cancellationToken);
|
||||
|
||||
public Task UnregisterPushDeviceAsync(string installationId, CancellationToken cancellationToken = default) =>
|
||||
SendAsync(HttpMethod.Delete, $"api/push/devices/{Uri.EscapeDataString(installationId)}", null, cancellationToken);
|
||||
|
||||
public Task<UserSummaryDto> GetMeAsync(CancellationToken cancellationToken = default) =>
|
||||
SendAsync<UserSummaryDto>(HttpMethod.Get, "api/users/me", null, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<UserSummaryDto>> SearchUsersAsync(string query, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<IReadOnlyList<UserSummaryDto>>(HttpMethod.Get, $"api/users/search?q={Uri.EscapeDataString(query)}", null, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<ChatSummaryDto>> GetChatsAsync(CancellationToken cancellationToken = default) =>
|
||||
SendAsync<IReadOnlyList<ChatSummaryDto>>(HttpMethod.Get, "api/chats", null, cancellationToken);
|
||||
|
||||
public Task<ChatDetailsDto> GetChatAsync(Guid chatId, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<ChatDetailsDto>(HttpMethod.Get, $"api/chats/{chatId}", null, cancellationToken);
|
||||
|
||||
public Task<ChatDetailsDto> CreateDirectChatAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<ChatDetailsDto>(HttpMethod.Post, "api/chats/direct", new CreateDirectChatRequest(userId), cancellationToken);
|
||||
|
||||
public Task<ChatDetailsDto> CreateGroupChatAsync(string title, IReadOnlyList<Guid> memberIds, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<ChatDetailsDto>(HttpMethod.Post, "api/chats/group", new CreateGroupChatRequest(title, memberIds), cancellationToken);
|
||||
|
||||
public Task<ChatDetailsDto> CreateChannelAsync(string title, IReadOnlyList<Guid> memberIds, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<ChatDetailsDto>(HttpMethod.Post, "api/chats/channel", new CreateChannelRequest(title, memberIds), cancellationToken);
|
||||
|
||||
public Task<MessageDto> SendMessageAsync(Guid chatId, string text, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<MessageDto>(HttpMethod.Post, $"api/chats/{chatId}/messages", new SendMessageRequest(text), cancellationToken);
|
||||
|
||||
public Task<MessageDto> UpdateMessageAsync(Guid chatId, Guid messageId, string text, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<MessageDto>(HttpMethod.Put, $"api/chats/{chatId}/messages/{messageId}", new UpdateMessageRequest(text), cancellationToken);
|
||||
|
||||
public Task<MessageDto> DeleteMessageAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken = default) =>
|
||||
SendAsync<MessageDto>(HttpMethod.Delete, $"api/chats/{chatId}/messages/{messageId}", null, cancellationToken);
|
||||
|
||||
public async Task<MessageDto> UploadAttachmentAsync(
|
||||
Guid chatId,
|
||||
string? text,
|
||||
FileResult fileResult,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetValidAccessTokenAsync(cancellationToken);
|
||||
using var client = CreateClient();
|
||||
using var response = await SendAttachmentInternalAsync(client, chatId, text, fileResult, token, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized && sessionService.CurrentSession is not null)
|
||||
{
|
||||
response.Dispose();
|
||||
token = await ForceRefreshAsync(cancellationToken);
|
||||
using var retriedResponse = await SendAttachmentInternalAsync(client, chatId, text, fileResult, token, cancellationToken);
|
||||
if (!retriedResponse.IsSuccessStatusCode)
|
||||
{
|
||||
throw await CreateApiExceptionAsync(retriedResponse, cancellationToken);
|
||||
}
|
||||
|
||||
var retriedPayload = await retriedResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions, cancellationToken);
|
||||
return retriedPayload ?? throw new InvalidOperationException("Server returned an empty response.");
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw await CreateApiExceptionAsync(response, cancellationToken);
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<MessageDto>(JsonOptions, cancellationToken);
|
||||
return payload ?? throw new InvalidOperationException("Server returned an empty response.");
|
||||
}
|
||||
|
||||
public async Task<string> DownloadAttachmentAsync(AttachmentDto attachment, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetValidAccessTokenAsync(cancellationToken);
|
||||
using var client = CreateClient();
|
||||
using var response = await SendDownloadInternalAsync(client, attachment.DownloadPath, token, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized && sessionService.CurrentSession is not null)
|
||||
{
|
||||
response.Dispose();
|
||||
token = await ForceRefreshAsync(cancellationToken);
|
||||
using var retriedResponse = await SendDownloadInternalAsync(client, attachment.DownloadPath, token, cancellationToken);
|
||||
if (!retriedResponse.IsSuccessStatusCode)
|
||||
{
|
||||
throw await CreateApiExceptionAsync(retriedResponse, cancellationToken);
|
||||
}
|
||||
|
||||
return await SaveAttachmentToCacheAsync(retriedResponse, attachment, cancellationToken);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw await CreateApiExceptionAsync(response, cancellationToken);
|
||||
}
|
||||
|
||||
return await SaveAttachmentToCacheAsync(response, attachment, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<string> SaveAttachmentToCacheAsync(
|
||||
HttpResponseMessage response,
|
||||
AttachmentDto attachment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var safeName = $"{attachment.Id:N}_{Path.GetFileName(attachment.FileName)}";
|
||||
var localPath = Path.Combine(FileSystem.CacheDirectory, safeName);
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var fileStream = File.Create(localPath);
|
||||
await responseStream.CopyToAsync(fileStream, cancellationToken);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
public async Task<string?> GetValidAccessTokenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = sessionService.CurrentSession;
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.AccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
|
||||
{
|
||||
return session.AccessToken;
|
||||
}
|
||||
|
||||
await _refreshLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
session = sessionService.CurrentSession;
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.AccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
|
||||
{
|
||||
return session.AccessToken;
|
||||
}
|
||||
|
||||
var refreshed = await RefreshAsync(session.RefreshToken, cancellationToken);
|
||||
await sessionService.RefreshAsync(refreshed);
|
||||
return refreshed.AccessToken;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await sessionService.SignOutAsync();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendAsync(HttpMethod method, string relativeUrl, object? body, CancellationToken cancellationToken, bool requiresAuth = true)
|
||||
{
|
||||
using var response = await SendWithRetryAsync(method, relativeUrl, body, cancellationToken, requiresAuth);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw await CreateApiExceptionAsync(response, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T> SendAsync<T>(HttpMethod method, string relativeUrl, object? body, CancellationToken cancellationToken, bool requiresAuth = true)
|
||||
{
|
||||
using var response = await SendWithRetryAsync(method, relativeUrl, body, cancellationToken, requiresAuth);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw await CreateApiExceptionAsync(response, cancellationToken);
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken);
|
||||
return payload ?? throw new InvalidOperationException("Server returned an empty response.");
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendWithRetryAsync(
|
||||
HttpMethod method,
|
||||
string relativeUrl,
|
||||
object? body,
|
||||
CancellationToken cancellationToken,
|
||||
bool requiresAuth)
|
||||
{
|
||||
using var client = CreateClient();
|
||||
var token = requiresAuth ? await GetValidAccessTokenAsync(cancellationToken) : null;
|
||||
var response = await SendOnceAsync(client, method, relativeUrl, body, token, cancellationToken);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.Unauthorized || !requiresAuth || sessionService.CurrentSession is null)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
response.Dispose();
|
||||
var refreshedToken = await ForceRefreshAsync(cancellationToken);
|
||||
return await SendOnceAsync(client, method, relativeUrl, body, refreshedToken, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> ForceRefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _refreshLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var session = sessionService.CurrentSession;
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var refreshed = await RefreshAsync(session.RefreshToken, cancellationToken);
|
||||
await sessionService.RefreshAsync(refreshed);
|
||||
return refreshed.AccessToken;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await sessionService.SignOutAsync();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> SendOnceAsync(
|
||||
HttpClient client,
|
||||
HttpMethod method,
|
||||
string relativeUrl,
|
||||
object? body,
|
||||
string? accessToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = CreateRequest(method, relativeUrl, body, accessToken);
|
||||
return await client.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> SendAttachmentInternalAsync(
|
||||
HttpClient client,
|
||||
Guid chatId,
|
||||
string? text,
|
||||
FileResult fileResult,
|
||||
string? accessToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var fileStream = await fileResult.OpenReadAsync();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"api/chats/{chatId}/attachments");
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
content.Add(new StringContent(text), "text");
|
||||
}
|
||||
|
||||
var streamContent = new StreamContent(fileStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(fileResult.ContentType) ? "application/octet-stream" : fileResult.ContentType);
|
||||
content.Add(streamContent, "file", fileResult.FileName);
|
||||
request.Content = content;
|
||||
|
||||
return await client.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> SendDownloadInternalAsync(
|
||||
HttpClient client,
|
||||
string downloadPath,
|
||||
string? accessToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadPath.TrimStart('/'));
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
|
||||
return await client.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient() =>
|
||||
new()
|
||||
{
|
||||
BaseAddress = new Uri($"{endpointStore.GetBaseUrl().TrimEnd('/')}/")
|
||||
};
|
||||
|
||||
private static HttpRequestMessage CreateRequest(HttpMethod method, string relativeUrl, object? body, string? accessToken)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, relativeUrl);
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return request;
|
||||
}
|
||||
|
||||
request.Content = new StringContent(
|
||||
JsonSerializer.Serialize(body, JsonOptions),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static async Task<Exception> CreateApiExceptionAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new InvalidOperationException(
|
||||
string.IsNullOrWhiteSpace(content)
|
||||
? $"Request failed with status {(int)response.StatusCode}."
|
||||
: content);
|
||||
}
|
||||
}
|
||||
10
src/Massenger.Client/Services/IPushRegistrationService.cs
Normal file
10
src/Massenger.Client/Services/IPushRegistrationService.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Massenger.Client.Services;
|
||||
|
||||
public interface IPushRegistrationService
|
||||
{
|
||||
Task InitializeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task RegisterCurrentDeviceAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task UnregisterCurrentDeviceAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
10
src/Massenger.Client/Services/NoOpPushRegistrationService.cs
Normal file
10
src/Massenger.Client/Services/NoOpPushRegistrationService.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Massenger.Client.Services;
|
||||
|
||||
public sealed class NoOpPushRegistrationService : IPushRegistrationService
|
||||
{
|
||||
public Task InitializeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task RegisterCurrentDeviceAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task UnregisterCurrentDeviceAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Massenger.Client.Services;
|
||||
|
||||
public sealed class NotificationActivationService
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private Guid? _pendingChatId;
|
||||
|
||||
public event EventHandler<Guid>? ChatRequested;
|
||||
|
||||
public void SetPendingChat(Guid chatId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_pendingChatId = chatId;
|
||||
}
|
||||
|
||||
ChatRequested?.Invoke(this, chatId);
|
||||
}
|
||||
|
||||
public Guid? ConsumePendingChat()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var chatId = _pendingChatId;
|
||||
_pendingChatId = null;
|
||||
return chatId;
|
||||
}
|
||||
}
|
||||
|
||||
public void RestorePendingChat(Guid chatId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_pendingChatId = chatId;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/Massenger.Client/Services/RealtimeService.cs
Normal file
100
src/Massenger.Client/Services/RealtimeService.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.Services;
|
||||
|
||||
public sealed class RealtimeService(ServerEndpointStore endpointStore, SessionService sessionService, ApiClient apiClient)
|
||||
{
|
||||
private HubConnection? _connection;
|
||||
|
||||
public event EventHandler<MessageDto>? MessageReceived;
|
||||
|
||||
public event EventHandler<MessageDto>? MessageUpdated;
|
||||
|
||||
public event EventHandler<ChatSummaryDto>? ChatCreated;
|
||||
|
||||
public event EventHandler? ChatsChanged;
|
||||
|
||||
public event EventHandler<string>? ConnectionStateChanged;
|
||||
|
||||
public string StatusText { get; private set; } = "offline";
|
||||
|
||||
public async Task ConnectAsync()
|
||||
{
|
||||
if (!sessionService.IsAuthenticated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_connection is not null && _connection.State is HubConnectionState.Connected or HubConnectionState.Connecting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_connection is null)
|
||||
{
|
||||
_connection = new HubConnectionBuilder()
|
||||
.WithUrl(endpointStore.GetHubUrl(), options =>
|
||||
{
|
||||
options.AccessTokenProvider = () => apiClient.GetValidAccessTokenAsync();
|
||||
})
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_connection.On<MessageDto>("MessageCreated", message => MessageReceived?.Invoke(this, message));
|
||||
_connection.On<MessageDto>("MessageUpdated", message => MessageUpdated?.Invoke(this, message));
|
||||
_connection.On<ChatSummaryDto>("ChatCreated", chat => ChatCreated?.Invoke(this, chat));
|
||||
_connection.On("ChatsChanged", () => ChatsChanged?.Invoke(this, EventArgs.Empty));
|
||||
|
||||
_connection.Reconnecting += error =>
|
||||
{
|
||||
UpdateStatus("reconnecting");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_connection.Reconnected += connectionId =>
|
||||
{
|
||||
UpdateStatus("online");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_connection.Closed += error =>
|
||||
{
|
||||
UpdateStatus("offline");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
await _connection.StartAsync();
|
||||
UpdateStatus("online");
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
if (_connection is null)
|
||||
{
|
||||
UpdateStatus("offline");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_connection.State != HubConnectionState.Disconnected)
|
||||
{
|
||||
await _connection.StopAsync();
|
||||
}
|
||||
|
||||
UpdateStatus("offline");
|
||||
}
|
||||
|
||||
public async Task ReconnectAsync()
|
||||
{
|
||||
await DisconnectAsync();
|
||||
_connection = null;
|
||||
await ConnectAsync();
|
||||
}
|
||||
|
||||
private void UpdateStatus(string status)
|
||||
{
|
||||
StatusText = status;
|
||||
ConnectionStateChanged?.Invoke(this, status);
|
||||
}
|
||||
}
|
||||
41
src/Massenger.Client/Services/ServerEndpointStore.cs
Normal file
41
src/Massenger.Client/Services/ServerEndpointStore.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace Massenger.Client.Services;
|
||||
|
||||
public sealed class ServerEndpointStore
|
||||
{
|
||||
private const string PreferenceKey = "server_url";
|
||||
|
||||
public string GetBaseUrl()
|
||||
{
|
||||
var stored = Preferences.Default.Get(PreferenceKey, string.Empty);
|
||||
return string.IsNullOrWhiteSpace(stored) ? GetDefaultUrl() : stored;
|
||||
}
|
||||
|
||||
public void SetBaseUrl(string url)
|
||||
{
|
||||
Preferences.Default.Set(PreferenceKey, Normalize(url));
|
||||
}
|
||||
|
||||
public string GetHubUrl() => $"{GetBaseUrl().TrimEnd('/')}/hubs/messenger";
|
||||
|
||||
public string Normalize(string url)
|
||||
{
|
||||
var trimmed = url.Trim().TrimEnd('/');
|
||||
if (!trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trimmed = $"http://{trimmed}";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string GetDefaultUrl()
|
||||
{
|
||||
if (DeviceInfo.Platform == DevicePlatform.Android)
|
||||
{
|
||||
return "http://10.0.2.2:5099";
|
||||
}
|
||||
|
||||
return "http://localhost:5099";
|
||||
}
|
||||
}
|
||||
19
src/Massenger.Client/Services/ServiceHelper.cs
Normal file
19
src/Massenger.Client/Services/ServiceHelper.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Massenger.Client.Services;
|
||||
|
||||
public static class ServiceHelper
|
||||
{
|
||||
public static IServiceProvider Services { get; private set; } = null!;
|
||||
|
||||
public static void Initialize(IServiceProvider services)
|
||||
{
|
||||
Services = services;
|
||||
}
|
||||
|
||||
public static T GetRequiredService<T>() where T : notnull =>
|
||||
Services.GetRequiredService<T>();
|
||||
|
||||
public static T? GetService<T>() where T : class =>
|
||||
Services.GetService<T>();
|
||||
}
|
||||
88
src/Massenger.Client/Services/SessionService.cs
Normal file
88
src/Massenger.Client/Services/SessionService.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
using Massenger.Client.Models;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.Services;
|
||||
|
||||
public sealed class SessionService
|
||||
{
|
||||
private const string SecureSessionKey = "auth_session";
|
||||
|
||||
public event EventHandler? SessionChanged;
|
||||
|
||||
public ClientSession? CurrentSession { get; private set; }
|
||||
|
||||
public bool IsAuthenticated => CurrentSession is not null;
|
||||
|
||||
public async Task RestoreAsync()
|
||||
{
|
||||
if (CurrentSession is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sessionJson = await SecureStorage.Default.GetAsync(SecureSessionKey);
|
||||
if (string.IsNullOrWhiteSpace(sessionJson))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentSession = JsonSerializer.Deserialize<ClientSession>(sessionJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
CurrentSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
public Task SignInAsync(AuthSessionDto session) => SetSessionAsync(session, notify: true);
|
||||
|
||||
public Task RefreshAsync(AuthSessionDto session) => SetSessionAsync(session, notify: false);
|
||||
|
||||
public async Task UpdateCurrentUserAsync(UserSummaryDto user)
|
||||
{
|
||||
if (CurrentSession is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentSession = CurrentSession with { User = user };
|
||||
await PersistAsync();
|
||||
}
|
||||
|
||||
public async Task SignOutAsync()
|
||||
{
|
||||
CurrentSession = null;
|
||||
SecureStorage.Default.Remove(SecureSessionKey);
|
||||
SessionChanged?.Invoke(this, EventArgs.Empty);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SetSessionAsync(AuthSessionDto session, bool notify)
|
||||
{
|
||||
CurrentSession = new ClientSession(
|
||||
session.AccessToken,
|
||||
session.RefreshToken,
|
||||
session.AccessTokenExpiresAt,
|
||||
session.User);
|
||||
|
||||
await PersistAsync();
|
||||
if (notify)
|
||||
{
|
||||
SessionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private Task PersistAsync()
|
||||
{
|
||||
if (CurrentSession is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(CurrentSession);
|
||||
return SecureStorage.Default.SetAsync(SecureSessionKey, payload);
|
||||
}
|
||||
}
|
||||
42
src/Massenger.Client/ViewModels/BaseViewModel.cs
Normal file
42
src/Massenger.Client/ViewModels/BaseViewModel.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Massenger.Client.ViewModels;
|
||||
|
||||
public partial class BaseViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? errorMessage;
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage);
|
||||
|
||||
partial void OnErrorMessageChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
|
||||
protected async Task RunBusyAsync(Func<Task> action)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ErrorMessage = null;
|
||||
IsBusy = true;
|
||||
await action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
316
src/Massenger.Client/ViewModels/ChatViewModel.cs
Normal file
316
src/Massenger.Client/ViewModels/ChatViewModel.cs
Normal file
@@ -0,0 +1,316 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Massenger.Client.Models;
|
||||
using Massenger.Client.Services;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.ViewModels;
|
||||
|
||||
public partial class ChatViewModel(
|
||||
ApiClient apiClient,
|
||||
RealtimeService realtimeService,
|
||||
SessionService sessionService) : BaseViewModel
|
||||
{
|
||||
private PendingAttachmentItem? _pendingAttachment;
|
||||
private ChatMessageItem? _editingMessage;
|
||||
|
||||
public ObservableCollection<ChatMessageItem> Messages { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private Guid currentChatId;
|
||||
|
||||
[ObservableProperty]
|
||||
private string title = "Conversation";
|
||||
|
||||
[ObservableProperty]
|
||||
private string subtitle = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
|
||||
private string draftText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
|
||||
private bool hasPendingAttachment;
|
||||
|
||||
[ObservableProperty]
|
||||
private string pendingAttachmentName = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
|
||||
private bool canSendMessages = true;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
|
||||
private bool isEditingMessage;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editingMessageBanner = string.Empty;
|
||||
|
||||
public bool ShowComposer => CanSendMessages;
|
||||
|
||||
public bool ShowReadOnlyBanner => !CanSendMessages;
|
||||
|
||||
partial void OnCanSendMessagesChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowComposer));
|
||||
OnPropertyChanged(nameof(ShowReadOnlyBanner));
|
||||
}
|
||||
|
||||
public void AttachRealtime()
|
||||
{
|
||||
realtimeService.MessageReceived -= OnMessageReceived;
|
||||
realtimeService.MessageUpdated -= OnMessageUpdated;
|
||||
realtimeService.MessageReceived += OnMessageReceived;
|
||||
realtimeService.MessageUpdated += OnMessageUpdated;
|
||||
}
|
||||
|
||||
public void DetachRealtime()
|
||||
{
|
||||
realtimeService.MessageReceived -= OnMessageReceived;
|
||||
realtimeService.MessageUpdated -= OnMessageUpdated;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(Guid chatId)
|
||||
{
|
||||
CurrentChatId = chatId;
|
||||
await RunBusyAsync(async () =>
|
||||
{
|
||||
var chat = await apiClient.GetChatAsync(chatId);
|
||||
var currentUserId = sessionService.CurrentSession?.User.Id ?? Guid.Empty;
|
||||
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
Title = chat.Title;
|
||||
Subtitle = BuildSubtitle(chat);
|
||||
CanSendMessages = chat.CanSendMessages;
|
||||
CancelEditMessage();
|
||||
ClearPendingAttachment();
|
||||
Messages.Clear();
|
||||
foreach (var message in chat.Messages)
|
||||
{
|
||||
Messages.Add(new ChatMessageItem(message, currentUserId));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private bool CanSendMessage() =>
|
||||
!IsBusy &&
|
||||
CurrentChatId != Guid.Empty &&
|
||||
CanSendMessages &&
|
||||
(IsEditingMessage
|
||||
? !string.IsNullOrWhiteSpace(DraftText) || _editingMessage?.HasAttachments == true
|
||||
: !string.IsNullOrWhiteSpace(DraftText) || HasPendingAttachment);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSendMessage))]
|
||||
private Task SendMessageAsync() =>
|
||||
RunBusyAsync(async () =>
|
||||
{
|
||||
MessageDto message;
|
||||
if (IsEditingMessage && _editingMessage is not null)
|
||||
{
|
||||
message = await apiClient.UpdateMessageAsync(CurrentChatId, _editingMessage.Id, DraftText);
|
||||
CancelEditMessage();
|
||||
}
|
||||
else if (_pendingAttachment is not null)
|
||||
{
|
||||
message = await apiClient.UploadAttachmentAsync(CurrentChatId, DraftText, _pendingAttachment.File);
|
||||
ClearPendingAttachment();
|
||||
DraftText = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
message = await apiClient.SendMessageAsync(CurrentChatId, DraftText);
|
||||
DraftText = string.Empty;
|
||||
}
|
||||
|
||||
ReplaceOrInsertMessage(message);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task PickAttachmentAsync() =>
|
||||
PickPendingAttachmentAsync(requireVoiceFile: false);
|
||||
|
||||
[RelayCommand]
|
||||
private Task PickVoiceNoteAsync() =>
|
||||
PickPendingAttachmentAsync(requireVoiceFile: true);
|
||||
|
||||
[RelayCommand]
|
||||
private void BeginEditMessage(ChatMessageItem? message)
|
||||
{
|
||||
if (message is null || !message.CanManage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ClearPendingAttachment();
|
||||
_editingMessage = message;
|
||||
IsEditingMessage = true;
|
||||
EditingMessageBanner = $"Editing message from {message.Timestamp}";
|
||||
DraftText = message.RawText;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelEditMessage()
|
||||
{
|
||||
_editingMessage = null;
|
||||
IsEditingMessage = false;
|
||||
EditingMessageBanner = string.Empty;
|
||||
DraftText = string.Empty;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task DeleteMessageAsync(ChatMessageItem? message) =>
|
||||
RunBusyAsync(async () =>
|
||||
{
|
||||
if (message is null || !message.CanManage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await Shell.Current.DisplayAlertAsync(
|
||||
"Delete message",
|
||||
"Delete this message for all channel/chat members?",
|
||||
"Delete",
|
||||
"Cancel");
|
||||
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = await apiClient.DeleteMessageAsync(CurrentChatId, message.Id);
|
||||
if (_editingMessage?.Id == message.Id)
|
||||
{
|
||||
CancelEditMessage();
|
||||
}
|
||||
|
||||
ReplaceOrInsertMessage(updated);
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task PickPendingAttachmentAsync(bool requireVoiceFile) =>
|
||||
RunBusyAsync(async () =>
|
||||
{
|
||||
var picked = await FilePicker.Default.PickAsync();
|
||||
if (picked is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pending = PendingAttachmentItem.Create(picked);
|
||||
if (requireVoiceFile && pending.Kind != AttachmentKind.VoiceNote)
|
||||
{
|
||||
throw new InvalidOperationException("Selected file is not recognized as audio.");
|
||||
}
|
||||
|
||||
CancelEditMessage();
|
||||
_pendingAttachment = requireVoiceFile
|
||||
? new PendingAttachmentItem(picked, AttachmentKind.VoiceNote)
|
||||
: pending;
|
||||
PendingAttachmentName = _pendingAttachment.DisplayName;
|
||||
HasPendingAttachment = true;
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private void RemovePendingAttachment()
|
||||
{
|
||||
ClearPendingAttachment();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task OpenAttachmentAsync(ChatAttachmentItem? attachment) =>
|
||||
RunBusyAsync(async () =>
|
||||
{
|
||||
if (attachment is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var localPath = await apiClient.DownloadAttachmentAsync(
|
||||
new AttachmentDto(
|
||||
attachment.Id,
|
||||
attachment.FileName,
|
||||
attachment.ContentType,
|
||||
attachment.FileSizeBytes,
|
||||
attachment.DownloadPath,
|
||||
attachment.Kind));
|
||||
|
||||
await Launcher.Default.OpenAsync(
|
||||
new OpenFileRequest(
|
||||
attachment.FileName,
|
||||
new ReadOnlyFile(localPath)));
|
||||
});
|
||||
|
||||
private void OnMessageReceived(object? sender, MessageDto message)
|
||||
{
|
||||
if (message.ChatId != CurrentChatId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MainThread.BeginInvokeOnMainThread(() => ReplaceOrInsertMessage(message));
|
||||
}
|
||||
|
||||
private void OnMessageUpdated(object? sender, MessageDto message)
|
||||
{
|
||||
if (message.ChatId != CurrentChatId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
if (_editingMessage?.Id == message.Id && message.DeletedAt is not null)
|
||||
{
|
||||
CancelEditMessage();
|
||||
}
|
||||
|
||||
ReplaceOrInsertMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
private void ReplaceOrInsertMessage(MessageDto message)
|
||||
{
|
||||
var currentUserId = sessionService.CurrentSession?.User.Id ?? Guid.Empty;
|
||||
var item = new ChatMessageItem(message, currentUserId);
|
||||
var index = Messages
|
||||
.Select((value, idx) => new { value, idx })
|
||||
.FirstOrDefault(x => x.value.Id == message.Id)
|
||||
?.idx;
|
||||
|
||||
if (index is int existingIndex)
|
||||
{
|
||||
Messages[existingIndex] = item;
|
||||
}
|
||||
else
|
||||
{
|
||||
Messages.Add(item);
|
||||
}
|
||||
|
||||
var ordered = Messages.OrderBy(x => x.SentAt).ToList();
|
||||
Messages.Clear();
|
||||
foreach (var orderedItem in ordered)
|
||||
{
|
||||
Messages.Add(orderedItem);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSubtitle(ChatDetailsDto chat) =>
|
||||
chat.Type switch
|
||||
{
|
||||
ChatType.Channel => chat.CanSendMessages
|
||||
? $"{chat.Participants.Count} members | you can publish"
|
||||
: $"{chat.Participants.Count} members | read-only channel",
|
||||
_ => $"{chat.Participants.Count} members"
|
||||
};
|
||||
|
||||
private void ClearPendingAttachment()
|
||||
{
|
||||
_pendingAttachment = null;
|
||||
PendingAttachmentName = string.Empty;
|
||||
HasPendingAttachment = false;
|
||||
}
|
||||
}
|
||||
77
src/Massenger.Client/ViewModels/ChatsViewModel.cs
Normal file
77
src/Massenger.Client/ViewModels/ChatsViewModel.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Massenger.Client.Pages;
|
||||
using Massenger.Client.Services;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.ViewModels;
|
||||
|
||||
public partial class ChatsViewModel(ApiClient apiClient, RealtimeService realtimeService) : BaseViewModel
|
||||
{
|
||||
public ObservableCollection<ChatSummaryDto> Chats { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private ChatSummaryDto? selectedChat;
|
||||
|
||||
public string EmptyState => Chats.Count == 0 ? "No chats yet. Open Discover to start a dialog, group or channel." : string.Empty;
|
||||
|
||||
[RelayCommand]
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
await RunBusyAsync(async () =>
|
||||
{
|
||||
var chats = await apiClient.GetChatsAsync();
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
Chats.Clear();
|
||||
foreach (var chat in chats)
|
||||
{
|
||||
Chats.Add(chat);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(EmptyState));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task OpenChatAsync(ChatSummaryDto? chat)
|
||||
{
|
||||
if (chat is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedChat = null;
|
||||
await Shell.Current.GoToAsync($"{nameof(ChatPage)}?chatId={chat.Id}");
|
||||
}
|
||||
|
||||
public void AttachRealtime()
|
||||
{
|
||||
realtimeService.MessageReceived -= OnRealtimeChanged;
|
||||
realtimeService.MessageUpdated -= OnRealtimeChanged;
|
||||
realtimeService.ChatCreated -= OnChatCreated;
|
||||
realtimeService.ChatsChanged -= OnRealtimeChanged;
|
||||
|
||||
realtimeService.MessageReceived += OnRealtimeChanged;
|
||||
realtimeService.MessageUpdated += OnRealtimeChanged;
|
||||
realtimeService.ChatCreated += OnChatCreated;
|
||||
realtimeService.ChatsChanged += OnRealtimeChanged;
|
||||
}
|
||||
|
||||
private void OnChatCreated(object? sender, ChatSummaryDto e)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() => _ = LoadAsync());
|
||||
}
|
||||
|
||||
private void OnRealtimeChanged(object? sender, EventArgs e)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() => _ = LoadAsync());
|
||||
}
|
||||
|
||||
private void OnRealtimeChanged(object? sender, MessageDto e)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() => _ = LoadAsync());
|
||||
}
|
||||
}
|
||||
118
src/Massenger.Client/ViewModels/DiscoverViewModel.cs
Normal file
118
src/Massenger.Client/ViewModels/DiscoverViewModel.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Massenger.Client.Models;
|
||||
using Massenger.Client.Pages;
|
||||
using Massenger.Client.Services;
|
||||
|
||||
namespace Massenger.Client.ViewModels;
|
||||
|
||||
public partial class DiscoverViewModel(ApiClient apiClient) : BaseViewModel
|
||||
{
|
||||
public ObservableCollection<DiscoverUserItem> Results { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string query = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string groupTitle = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string channelTitle = string.Empty;
|
||||
|
||||
public int SelectedCount => Results.Count(x => x.IsSelected);
|
||||
|
||||
[RelayCommand]
|
||||
public Task SearchAsync() =>
|
||||
RunBusyAsync(async () =>
|
||||
{
|
||||
var users = await apiClient.SearchUsersAsync(Query);
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
Results.Clear();
|
||||
foreach (var user in users)
|
||||
{
|
||||
var item = new DiscoverUserItem(user);
|
||||
item.PropertyChanged += (_, args) =>
|
||||
{
|
||||
if (args.PropertyName == nameof(DiscoverUserItem.IsSelected))
|
||||
{
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
}
|
||||
};
|
||||
|
||||
Results.Add(item);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
});
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
public async Task StartDirectChatAsync(DiscoverUserItem? user)
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunBusyAsync(async () =>
|
||||
{
|
||||
var chat = await apiClient.CreateDirectChatAsync(user.Id);
|
||||
await Shell.Current.GoToAsync($"{nameof(ChatPage)}?chatId={chat.Id}");
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public Task CreateGroupAsync() =>
|
||||
RunBusyAsync(async () =>
|
||||
{
|
||||
var members = GetSelectedMembers();
|
||||
|
||||
if (members.Count < 2)
|
||||
{
|
||||
throw new InvalidOperationException("Select at least two users for a group.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(GroupTitle))
|
||||
{
|
||||
throw new InvalidOperationException("Group title is required.");
|
||||
}
|
||||
|
||||
var chat = await apiClient.CreateGroupChatAsync(GroupTitle, members);
|
||||
GroupTitle = string.Empty;
|
||||
ClearSelection();
|
||||
await Shell.Current.GoToAsync($"{nameof(ChatPage)}?chatId={chat.Id}");
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
public Task CreateChannelAsync() =>
|
||||
RunBusyAsync(async () =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ChannelTitle))
|
||||
{
|
||||
throw new InvalidOperationException("Channel title is required.");
|
||||
}
|
||||
|
||||
var chat = await apiClient.CreateChannelAsync(ChannelTitle, GetSelectedMembers());
|
||||
ChannelTitle = string.Empty;
|
||||
ClearSelection();
|
||||
await Shell.Current.GoToAsync($"{nameof(ChatPage)}?chatId={chat.Id}");
|
||||
});
|
||||
|
||||
private List<Guid> GetSelectedMembers() =>
|
||||
Results
|
||||
.Where(x => x.IsSelected)
|
||||
.Select(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
private void ClearSelection()
|
||||
{
|
||||
foreach (var item in Results)
|
||||
{
|
||||
item.IsSelected = false;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
}
|
||||
}
|
||||
58
src/Massenger.Client/ViewModels/LoginViewModel.cs
Normal file
58
src/Massenger.Client/ViewModels/LoginViewModel.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Massenger.Client.Services;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Client.ViewModels;
|
||||
|
||||
public partial class LoginViewModel(
|
||||
ApiClient apiClient,
|
||||
SessionService sessionService,
|
||||
ServerEndpointStore endpointStore) : BaseViewModel
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string username = "alice";
|
||||
|
||||
[ObservableProperty]
|
||||
private string displayName = "Alice Carter";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(AuthenticateCommand))]
|
||||
private string password = "demo123";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(AuthenticateCommand))]
|
||||
private string serverUrl = endpointStore.GetBaseUrl();
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isRegisterMode;
|
||||
|
||||
public string PageTitle => IsRegisterMode ? "Create your account" : "Sign in to Massenger";
|
||||
|
||||
public string PrimaryActionText => IsRegisterMode ? "Create account" : "Sign in";
|
||||
|
||||
partial void OnIsRegisterModeChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(PageTitle));
|
||||
OnPropertyChanged(nameof(PrimaryActionText));
|
||||
}
|
||||
|
||||
private bool CanAuthenticate() =>
|
||||
!IsBusy &&
|
||||
!string.IsNullOrWhiteSpace(Username) &&
|
||||
!string.IsNullOrWhiteSpace(Password) &&
|
||||
!string.IsNullOrWhiteSpace(ServerUrl);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanAuthenticate))]
|
||||
private Task AuthenticateAsync() =>
|
||||
RunBusyAsync(async () =>
|
||||
{
|
||||
endpointStore.SetBaseUrl(ServerUrl);
|
||||
|
||||
AuthSessionDto session = IsRegisterMode
|
||||
? await apiClient.RegisterAsync(new RegisterRequest(Username, DisplayName, Password))
|
||||
: await apiClient.LoginAsync(new LoginRequest(Username, Password));
|
||||
|
||||
await sessionService.SignInAsync(session);
|
||||
});
|
||||
}
|
||||
74
src/Massenger.Client/ViewModels/ProfileViewModel.cs
Normal file
74
src/Massenger.Client/ViewModels/ProfileViewModel.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Massenger.Client.Services;
|
||||
|
||||
namespace Massenger.Client.ViewModels;
|
||||
|
||||
public partial class ProfileViewModel(
|
||||
ApiClient apiClient,
|
||||
SessionService sessionService,
|
||||
ServerEndpointStore endpointStore,
|
||||
RealtimeService realtimeService,
|
||||
IPushRegistrationService pushRegistrationService) : BaseViewModel
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string displayName = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string username = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string serverUrl = endpointStore.GetBaseUrl();
|
||||
|
||||
[ObservableProperty]
|
||||
private string connectionStatus = realtimeService.StatusText;
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
ServerUrl = endpointStore.GetBaseUrl();
|
||||
ConnectionStatus = realtimeService.StatusText;
|
||||
|
||||
var user = sessionService.CurrentSession?.User;
|
||||
if (user is not null)
|
||||
{
|
||||
DisplayName = user.DisplayName;
|
||||
Username = $"@{user.Username}";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task SaveServerAsync() =>
|
||||
RunBusyAsync(async () =>
|
||||
{
|
||||
endpointStore.SetBaseUrl(ServerUrl);
|
||||
await realtimeService.ReconnectAsync();
|
||||
var user = await apiClient.GetMeAsync();
|
||||
await sessionService.UpdateCurrentUserAsync(user);
|
||||
await LoadAsync();
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private Task RefreshProfileAsync() =>
|
||||
RunBusyAsync(async () =>
|
||||
{
|
||||
var user = await apiClient.GetMeAsync();
|
||||
await sessionService.UpdateCurrentUserAsync(user);
|
||||
await LoadAsync();
|
||||
});
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LogoutAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await pushRegistrationService.UnregisterCurrentDeviceAsync();
|
||||
await apiClient.LogoutAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Local sign-out remains valid even if the server is offline.
|
||||
}
|
||||
|
||||
await sessionService.SignOutAsync();
|
||||
}
|
||||
}
|
||||
104
src/Massenger.Server/Controllers/AuthController.cs
Normal file
104
src/Massenger.Server/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Massenger.Server.Data;
|
||||
using Massenger.Server.Data.Entities;
|
||||
using Massenger.Server.Infrastructure;
|
||||
using Massenger.Server.Infrastructure.Auth;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public sealed class AuthController(
|
||||
MassengerDbContext dbContext,
|
||||
TokenService tokenService,
|
||||
PresenceTracker presenceTracker) : ControllerBase
|
||||
{
|
||||
private readonly PasswordHasher<User> _passwordHasher = new();
|
||||
|
||||
[HttpPost("register")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthSessionDto>> Register(RegisterRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var username = request.Username.Trim();
|
||||
var displayName = request.DisplayName.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(displayName) || request.Password.Length < 6)
|
||||
{
|
||||
return ValidationProblem("Username, display name and a password of at least 6 characters are required.");
|
||||
}
|
||||
|
||||
var normalizedUsername = username.ToUpperInvariant();
|
||||
if (await dbContext.Users.AnyAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken))
|
||||
{
|
||||
return Conflict("Username is already taken.");
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Username = username,
|
||||
NormalizedUsername = normalizedUsername,
|
||||
DisplayName = displayName
|
||||
};
|
||||
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, request.Password);
|
||||
dbContext.Users.Add(user);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var session = await tokenService.CreateSessionAsync(user, cancellationToken);
|
||||
return Ok(session with { User = user.ToDto(presenceTracker) });
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthSessionDto>> Login(LoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedUsername = request.Username.Trim().ToUpperInvariant();
|
||||
var user = await dbContext.Users.SingleOrDefaultAsync(
|
||||
x => x.NormalizedUsername == normalizedUsername,
|
||||
cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized("Invalid username or password.");
|
||||
}
|
||||
|
||||
var verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
||||
if (verification == PasswordVerificationResult.Failed)
|
||||
{
|
||||
return Unauthorized("Invalid username or password.");
|
||||
}
|
||||
|
||||
var session = await tokenService.CreateSessionAsync(user, cancellationToken);
|
||||
return Ok(session with { User = user.ToDto(presenceTracker) });
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthSessionDto>> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var session = await tokenService.RefreshSessionAsync(request.RefreshToken, cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(session with { User = session.User with { IsOnline = presenceTracker.IsOnline(session.User.Id) } });
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
||||
{
|
||||
await tokenService.RevokeSessionAsync(User.GetRequiredSessionId(), cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
479
src/Massenger.Server/Controllers/ChatsController.cs
Normal file
479
src/Massenger.Server/Controllers/ChatsController.cs
Normal file
@@ -0,0 +1,479 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Massenger.Server.Data;
|
||||
using Massenger.Server.Data.Entities;
|
||||
using Massenger.Server.Infrastructure;
|
||||
using Massenger.Server.Infrastructure.Hubs;
|
||||
using Massenger.Server.Infrastructure.Push;
|
||||
using Massenger.Server.Infrastructure.Storage;
|
||||
using Massenger.Shared;
|
||||
using System.Net.Mime;
|
||||
|
||||
namespace Massenger.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
public sealed class ChatsController(
|
||||
MassengerDbContext dbContext,
|
||||
PresenceTracker presenceTracker,
|
||||
IHubContext<MessengerHub> hubContext,
|
||||
AttachmentStorageService attachmentStorageService,
|
||||
PushNotificationDispatcher pushNotificationDispatcher) : ControllerBase
|
||||
{
|
||||
private const long MaxAttachmentSizeBytes = 25 * 1024 * 1024;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<ChatSummaryDto>>> GetChats(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
|
||||
var chats = await dbContext.ChatMembers
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(x => x.Chat)
|
||||
.Include(x => x.Members)
|
||||
.ThenInclude(x => x.User)
|
||||
.Include(x => x.Messages)
|
||||
.ThenInclude(x => x.Sender)
|
||||
.Include(x => x.Messages)
|
||||
.ThenInclude(x => x.Attachments)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(chats
|
||||
.OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt)
|
||||
.Select(x => x.ToSummaryDto(userId, presenceTracker))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}")]
|
||||
public async Task<ActionResult<ChatDetailsDto>> GetChat(Guid chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
var chat = await LoadChatWithMessages(chatId, userId, cancellationToken);
|
||||
|
||||
return chat is null
|
||||
? NotFound()
|
||||
: Ok(chat.ToDetailsDto(userId, presenceTracker));
|
||||
}
|
||||
|
||||
[HttpPost("direct")]
|
||||
public async Task<ActionResult<ChatDetailsDto>> CreateDirectChat(CreateDirectChatRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentUserId = User.GetRequiredUserId();
|
||||
if (request.UserId == currentUserId)
|
||||
{
|
||||
return BadRequest("You cannot create a direct chat with yourself.");
|
||||
}
|
||||
|
||||
var otherUserExists = await dbContext.Users.AnyAsync(x => x.Id == request.UserId, cancellationToken);
|
||||
if (!otherUserExists)
|
||||
{
|
||||
return NotFound("User not found.");
|
||||
}
|
||||
|
||||
var existingChat = await dbContext.Chats
|
||||
.Include(x => x.Members)
|
||||
.ThenInclude(x => x.User)
|
||||
.Include(x => x.Messages)
|
||||
.ThenInclude(x => x.Sender)
|
||||
.Include(x => x.Messages)
|
||||
.ThenInclude(x => x.Attachments)
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(x =>
|
||||
x.Type == ChatType.Direct &&
|
||||
x.Members.Count == 2 &&
|
||||
x.Members.Any(m => m.UserId == currentUserId) &&
|
||||
x.Members.Any(m => m.UserId == request.UserId),
|
||||
cancellationToken);
|
||||
|
||||
if (existingChat is not null)
|
||||
{
|
||||
return Ok(existingChat.ToDetailsDto(currentUserId, presenceTracker));
|
||||
}
|
||||
|
||||
var chat = new Chat
|
||||
{
|
||||
Type = ChatType.Direct,
|
||||
CreatedById = currentUserId,
|
||||
LastActivityAt = DateTimeOffset.UtcNow,
|
||||
Members =
|
||||
[
|
||||
new ChatMember { UserId = currentUserId, IsOwner = true },
|
||||
new ChatMember { UserId = request.UserId, IsOwner = false }
|
||||
]
|
||||
};
|
||||
|
||||
dbContext.Chats.Add(chat);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken)
|
||||
?? throw new InvalidOperationException("Newly created chat could not be loaded.");
|
||||
|
||||
await NotifyChatCreatedAsync(loaded, cancellationToken);
|
||||
return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker));
|
||||
}
|
||||
|
||||
[HttpPost("group")]
|
||||
public async Task<ActionResult<ChatDetailsDto>> CreateGroupChat(CreateGroupChatRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentUserId = User.GetRequiredUserId();
|
||||
var memberIds = request.MemberIds
|
||||
.Append(currentUserId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title) || memberIds.Count < 3)
|
||||
{
|
||||
return BadRequest("A title and at least 2 invited members are required for a group chat.");
|
||||
}
|
||||
|
||||
var users = await dbContext.Users
|
||||
.Where(x => memberIds.Contains(x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (users.Count != memberIds.Count)
|
||||
{
|
||||
return BadRequest("Some group members do not exist.");
|
||||
}
|
||||
|
||||
var chat = new Chat
|
||||
{
|
||||
Type = ChatType.Group,
|
||||
Title = request.Title.Trim(),
|
||||
CreatedById = currentUserId,
|
||||
LastActivityAt = DateTimeOffset.UtcNow,
|
||||
Members = memberIds
|
||||
.Select(x => new ChatMember { UserId = x, IsOwner = x == currentUserId })
|
||||
.ToList()
|
||||
};
|
||||
|
||||
dbContext.Chats.Add(chat);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken)
|
||||
?? throw new InvalidOperationException("Newly created group chat could not be loaded.");
|
||||
|
||||
await NotifyChatCreatedAsync(loaded, cancellationToken);
|
||||
return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker));
|
||||
}
|
||||
|
||||
[HttpPost("channel")]
|
||||
public async Task<ActionResult<ChatDetailsDto>> CreateChannel(CreateChannelRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentUserId = User.GetRequiredUserId();
|
||||
var memberIds = request.MemberIds
|
||||
.Append(currentUserId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return BadRequest("Channel title is required.");
|
||||
}
|
||||
|
||||
var users = await dbContext.Users
|
||||
.Where(x => memberIds.Contains(x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (users.Count != memberIds.Count)
|
||||
{
|
||||
return BadRequest("Some channel members do not exist.");
|
||||
}
|
||||
|
||||
var chat = new Chat
|
||||
{
|
||||
Type = ChatType.Channel,
|
||||
Title = request.Title.Trim(),
|
||||
CreatedById = currentUserId,
|
||||
LastActivityAt = DateTimeOffset.UtcNow,
|
||||
Members = memberIds
|
||||
.Select(x => new ChatMember { UserId = x, IsOwner = x == currentUserId })
|
||||
.ToList()
|
||||
};
|
||||
|
||||
dbContext.Chats.Add(chat);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var loaded = await LoadChatWithMessages(chat.Id, currentUserId, cancellationToken)
|
||||
?? throw new InvalidOperationException("Newly created channel could not be loaded.");
|
||||
|
||||
await NotifyChatCreatedAsync(loaded, cancellationToken);
|
||||
return Ok(loaded.ToDetailsDto(currentUserId, presenceTracker));
|
||||
}
|
||||
|
||||
[HttpPost("{chatId:guid}/messages")]
|
||||
public async Task<ActionResult<MessageDto>> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
var text = request.Text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return BadRequest("Message text is required.");
|
||||
}
|
||||
|
||||
var chat = await dbContext.Chats
|
||||
.Include(x => x.Members)
|
||||
.FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);
|
||||
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!CanUserPublish(chat, userId))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish.");
|
||||
}
|
||||
|
||||
var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
|
||||
var message = new Message
|
||||
{
|
||||
ChatId = chatId,
|
||||
SenderId = userId,
|
||||
Sender = sender,
|
||||
Text = text,
|
||||
SentAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
chat.LastActivityAt = message.SentAt;
|
||||
dbContext.Messages.Add(message);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var dto = message.ToDto(presenceTracker);
|
||||
await NotifyMessageCreatedAsync(chat, dto, cancellationToken);
|
||||
await pushNotificationDispatcher.DispatchMessageAsync(chat, message, cancellationToken);
|
||||
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost("{chatId:guid}/attachments")]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = MaxAttachmentSizeBytes)]
|
||||
[RequestSizeLimit(MaxAttachmentSizeBytes)]
|
||||
public async Task<ActionResult<MessageDto>> UploadAttachment(
|
||||
Guid chatId,
|
||||
[FromForm] UploadAttachmentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
if (request.File is null)
|
||||
{
|
||||
return BadRequest("File is required.");
|
||||
}
|
||||
|
||||
if (request.File.Length <= 0)
|
||||
{
|
||||
return BadRequest("File is empty.");
|
||||
}
|
||||
|
||||
if (request.File.Length > MaxAttachmentSizeBytes)
|
||||
{
|
||||
return BadRequest($"File exceeds {MaxAttachmentSizeBytes} bytes.");
|
||||
}
|
||||
|
||||
var chat = await dbContext.Chats
|
||||
.Include(x => x.Members)
|
||||
.FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);
|
||||
|
||||
if (chat is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!CanUserPublish(chat, userId))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish.");
|
||||
}
|
||||
|
||||
var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
|
||||
var text = request.Text?.Trim() ?? string.Empty;
|
||||
var message = new Message
|
||||
{
|
||||
ChatId = chatId,
|
||||
SenderId = userId,
|
||||
Sender = sender,
|
||||
Text = text,
|
||||
SentAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
MessageAttachment? attachment = null;
|
||||
try
|
||||
{
|
||||
attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken);
|
||||
message.Attachments.Add(attachment);
|
||||
chat.LastActivityAt = message.SentAt;
|
||||
dbContext.Messages.Add(message);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (attachment is not null)
|
||||
{
|
||||
attachmentStorageService.DeleteIfExists(attachment.StoragePath);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
var dto = message.ToDto(presenceTracker);
|
||||
await NotifyMessageCreatedAsync(chat, dto, cancellationToken);
|
||||
await pushNotificationDispatcher.DispatchMessageAsync(chat, message, cancellationToken);
|
||||
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPut("{chatId:guid}/messages/{messageId:guid}")]
|
||||
public async Task<ActionResult<MessageDto>> UpdateMessage(
|
||||
Guid chatId,
|
||||
Guid messageId,
|
||||
UpdateMessageRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (message.SenderId != userId)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "Only the message author can edit it.");
|
||||
}
|
||||
|
||||
if (message.DeletedAt is not null)
|
||||
{
|
||||
return BadRequest("Deleted messages cannot be edited.");
|
||||
}
|
||||
|
||||
var text = request.Text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(text) && message.Attachments.Count == 0)
|
||||
{
|
||||
return BadRequest("Message text is required.");
|
||||
}
|
||||
|
||||
message.Text = text;
|
||||
message.EditedAt = DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var dto = message.ToDto(presenceTracker);
|
||||
await NotifyMessageUpdatedAsync(message.Chat, dto, cancellationToken);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpDelete("{chatId:guid}/messages/{messageId:guid}")]
|
||||
public async Task<ActionResult<MessageDto>> DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
var message = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (message.SenderId != userId)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "Only the message author can delete it.");
|
||||
}
|
||||
|
||||
if (message.DeletedAt is not null)
|
||||
{
|
||||
return Ok(message.ToDto(presenceTracker));
|
||||
}
|
||||
|
||||
var attachments = message.Attachments.ToList();
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
attachmentStorageService.DeleteIfExists(attachment.StoragePath);
|
||||
}
|
||||
|
||||
dbContext.MessageAttachments.RemoveRange(attachments);
|
||||
message.Attachments.Clear();
|
||||
message.Text = string.Empty;
|
||||
message.EditedAt = null;
|
||||
message.DeletedAt = DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var dto = message.ToDto(presenceTracker);
|
||||
await NotifyMessageUpdatedAsync(message.Chat, dto, cancellationToken);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")]
|
||||
public async Task<IActionResult> DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
var attachment = await dbContext.MessageAttachments
|
||||
.Include(x => x.Message)
|
||||
.ThenInclude(x => x.Chat)
|
||||
.ThenInclude(x => x.Members)
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.Id == attachmentId &&
|
||||
x.Message.ChatId == chatId &&
|
||||
x.Message.Chat.Members.Any(m => m.UserId == userId),
|
||||
cancellationToken);
|
||||
|
||||
if (attachment is null || !System.IO.File.Exists(attachment.StoragePath))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var stream = System.IO.File.OpenRead(attachment.StoragePath);
|
||||
return File(stream, string.IsNullOrWhiteSpace(attachment.ContentType) ? MediaTypeNames.Application.Octet : attachment.ContentType, attachment.OriginalFileName);
|
||||
}
|
||||
|
||||
private async Task<Chat?> LoadChatWithMessages(Guid chatId, Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.Chats
|
||||
.Include(x => x.Members)
|
||||
.ThenInclude(x => x.User)
|
||||
.Include(x => x.Messages)
|
||||
.ThenInclude(x => x.Sender)
|
||||
.Include(x => x.Messages)
|
||||
.ThenInclude(x => x.Attachments)
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Message?> LoadMessageForMemberAsync(Guid chatId, Guid messageId, Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.Messages
|
||||
.Include(x => x.Chat)
|
||||
.ThenInclude(x => x.Members)
|
||||
.Include(x => x.Sender)
|
||||
.Include(x => x.Attachments)
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.Id == messageId &&
|
||||
x.ChatId == chatId &&
|
||||
x.Chat.Members.Any(m => m.UserId == userId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static bool CanUserPublish(Chat chat, Guid userId) =>
|
||||
chat.Type != ChatType.Channel || chat.Members.Any(x => x.UserId == userId && x.IsOwner);
|
||||
|
||||
private Task NotifyMessageCreatedAsync(Chat chat, MessageDto dto, CancellationToken cancellationToken) =>
|
||||
hubContext.Clients.Users(chat.Members.Select(x => x.UserId.ToString()))
|
||||
.SendAsync("MessageCreated", dto, cancellationToken);
|
||||
|
||||
private Task NotifyMessageUpdatedAsync(Chat chat, MessageDto dto, CancellationToken cancellationToken) =>
|
||||
hubContext.Clients.Users(chat.Members.Select(x => x.UserId.ToString()))
|
||||
.SendAsync("MessageUpdated", dto, cancellationToken);
|
||||
|
||||
private async Task NotifyChatCreatedAsync(Chat chat, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
var summary = chat.ToSummaryDto(member.UserId, presenceTracker);
|
||||
await hubContext.Clients.User(member.UserId.ToString())
|
||||
.SendAsync("ChatCreated", summary, cancellationToken);
|
||||
}
|
||||
|
||||
await hubContext.Clients.Users(chat.Members.Select(x => x.UserId.ToString()))
|
||||
.SendAsync("ChatsChanged", cancellationToken);
|
||||
}
|
||||
}
|
||||
107
src/Massenger.Server/Controllers/PushController.cs
Normal file
107
src/Massenger.Server/Controllers/PushController.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Massenger.Server.Data;
|
||||
using Massenger.Server.Data.Entities;
|
||||
using Massenger.Server.Infrastructure;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
public sealed class PushController(MassengerDbContext dbContext) : ControllerBase
|
||||
{
|
||||
[HttpGet("devices")]
|
||||
public async Task<ActionResult<IReadOnlyList<PushDeviceDto>>> GetDevices(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
var devices = await dbContext.PushDevices
|
||||
.Where(x => x.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(devices
|
||||
.OrderByDescending(x => x.UpdatedAt)
|
||||
.Select(x => x.ToDto())
|
||||
.ToList());
|
||||
}
|
||||
|
||||
[HttpPost("devices")]
|
||||
public async Task<ActionResult<PushDeviceDto>> RegisterDevice(
|
||||
RegisterPushDeviceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
if (request.Platform == PushPlatform.Unknown ||
|
||||
string.IsNullOrWhiteSpace(request.InstallationId) ||
|
||||
string.IsNullOrWhiteSpace(request.DeviceToken))
|
||||
{
|
||||
return ValidationProblem("Platform, installationId and deviceToken are required.");
|
||||
}
|
||||
|
||||
var installationId = request.InstallationId.Trim();
|
||||
var device = await dbContext.PushDevices
|
||||
.SingleOrDefaultAsync(x => x.InstallationId == installationId, cancellationToken);
|
||||
|
||||
if (device is null)
|
||||
{
|
||||
device = new PushDevice
|
||||
{
|
||||
UserId = userId,
|
||||
Platform = request.Platform,
|
||||
InstallationId = installationId,
|
||||
DeviceToken = request.DeviceToken.Trim(),
|
||||
DeviceName = NormalizeDeviceName(request.DeviceName),
|
||||
NotificationsEnabled = request.NotificationsEnabled,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
dbContext.PushDevices.Add(device);
|
||||
}
|
||||
else
|
||||
{
|
||||
device.UserId = userId;
|
||||
device.Platform = request.Platform;
|
||||
device.DeviceToken = request.DeviceToken.Trim();
|
||||
device.DeviceName = NormalizeDeviceName(request.DeviceName);
|
||||
device.NotificationsEnabled = request.NotificationsEnabled;
|
||||
device.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return Ok(device.ToDto());
|
||||
}
|
||||
|
||||
[HttpDelete("devices/{installationId}")]
|
||||
public async Task<IActionResult> UnregisterDevice(string installationId, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
var normalizedInstallationId = installationId.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedInstallationId))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var device = await dbContext.PushDevices
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.InstallationId == normalizedInstallationId &&
|
||||
x.UserId == userId,
|
||||
cancellationToken);
|
||||
|
||||
if (device is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
dbContext.PushDevices.Remove(device);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static string? NormalizeDeviceName(string? deviceName)
|
||||
{
|
||||
var normalized = deviceName?.Trim();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Massenger.Server.Controllers;
|
||||
|
||||
public sealed class UploadAttachmentRequest
|
||||
{
|
||||
public string? Text { get; set; }
|
||||
|
||||
public IFormFile? File { get; set; }
|
||||
}
|
||||
47
src/Massenger.Server/Controllers/UsersController.cs
Normal file
47
src/Massenger.Server/Controllers/UsersController.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Massenger.Server.Data;
|
||||
using Massenger.Server.Infrastructure;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
public sealed class UsersController(
|
||||
MassengerDbContext dbContext,
|
||||
PresenceTracker presenceTracker) : ControllerBase
|
||||
{
|
||||
[HttpGet("me")]
|
||||
public async Task<ActionResult<UserSummaryDto>> GetMe(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
var user = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken);
|
||||
return Ok(user.ToDto(presenceTracker));
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<IReadOnlyList<UserSummaryDto>>> Search([FromQuery] string? q, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = User.GetRequiredUserId();
|
||||
var query = q?.Trim();
|
||||
|
||||
var usersQuery = dbContext.Users.Where(x => x.Id != userId);
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
var normalized = query.ToUpperInvariant();
|
||||
usersQuery = usersQuery.Where(x =>
|
||||
x.NormalizedUsername.Contains(normalized) ||
|
||||
EF.Functions.Like(x.DisplayName, $"%{query}%"));
|
||||
}
|
||||
|
||||
var users = await usersQuery
|
||||
.OrderBy(x => x.DisplayName)
|
||||
.Take(25)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(users.Select(x => x.ToDto(presenceTracker)).ToList());
|
||||
}
|
||||
}
|
||||
16
src/Massenger.Server/Infrastructure/Auth/JwtOptions.cs
Normal file
16
src/Massenger.Server/Infrastructure/Auth/JwtOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Massenger.Server.Infrastructure.Auth;
|
||||
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
public const string SectionName = "Jwt";
|
||||
|
||||
public string Issuer { get; init; } = "Massenger.Server";
|
||||
|
||||
public string Audience { get; init; } = "Massenger.Client";
|
||||
|
||||
public string SigningKey { get; init; } = "Massenger-Replace-This-Development-Key-With-A-Long-Random-Secret-123456789";
|
||||
|
||||
public int AccessTokenLifetimeMinutes { get; init; } = 15;
|
||||
|
||||
public int RefreshTokenLifetimeDays { get; init; } = 30;
|
||||
}
|
||||
120
src/Massenger.Server/Infrastructure/Auth/TokenService.cs
Normal file
120
src/Massenger.Server/Infrastructure/Auth/TokenService.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Massenger.Server.Data;
|
||||
using Massenger.Server.Data.Entities;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Server.Infrastructure.Auth;
|
||||
|
||||
public sealed class TokenService(
|
||||
MassengerDbContext dbContext,
|
||||
IOptions<JwtOptions> jwtOptions)
|
||||
{
|
||||
private readonly JwtOptions _jwtOptions = jwtOptions.Value;
|
||||
|
||||
public async Task<AuthSessionDto> CreateSessionAsync(User user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var session = new UserSession
|
||||
{
|
||||
UserId = user.Id,
|
||||
LastSeenAt = now
|
||||
};
|
||||
|
||||
var refreshToken = CreateOpaqueToken();
|
||||
session.TokenHash = HashToken(refreshToken);
|
||||
dbContext.UserSessions.Add(session);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return CreateAuthSession(user, session.Id, refreshToken, now);
|
||||
}
|
||||
|
||||
public async Task<AuthSessionDto?> RefreshSessionAsync(string rawRefreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var hash = HashToken(rawRefreshToken);
|
||||
var session = await dbContext.UserSessions.SingleOrDefaultAsync(x => x.TokenHash == hash, cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.CreatedAt.AddDays(_jwtOptions.RefreshTokenLifetimeDays) <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
dbContext.UserSessions.Remove(session);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
var user = await dbContext.Users.SingleAsync(x => x.Id == session.UserId, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var refreshToken = CreateOpaqueToken();
|
||||
session.TokenHash = HashToken(refreshToken);
|
||||
session.CreatedAt = now;
|
||||
session.LastSeenAt = now;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return CreateAuthSession(user, session.Id, refreshToken, now);
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await dbContext.UserSessions.SingleOrDefaultAsync(x => x.Id == sessionId, cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
dbContext.UserSessions.Remove(session);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string HashToken(string token)
|
||||
{
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
return Convert.ToHexString(hashBytes);
|
||||
}
|
||||
|
||||
private AuthSessionDto CreateAuthSession(User user, Guid sessionId, string refreshToken, DateTimeOffset now)
|
||||
{
|
||||
var accessTokenExpiresAt = now.AddMinutes(_jwtOptions.AccessTokenLifetimeMinutes);
|
||||
var credentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SigningKey)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(ClaimTypes.Name, user.DisplayName),
|
||||
new("username", user.Username),
|
||||
new("sid", sessionId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
|
||||
};
|
||||
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: _jwtOptions.Issuer,
|
||||
audience: _jwtOptions.Audience,
|
||||
claims: claims,
|
||||
notBefore: now.UtcDateTime,
|
||||
expires: accessTokenExpiresAt.UtcDateTime,
|
||||
signingCredentials: credentials);
|
||||
|
||||
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
|
||||
|
||||
return new AuthSessionDto(accessToken, refreshToken, accessTokenExpiresAt, new(user.Id, user.Username, user.DisplayName, false));
|
||||
}
|
||||
|
||||
private static string CreateOpaqueToken()
|
||||
{
|
||||
var tokenBytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(tokenBytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Massenger.Server.Infrastructure;
|
||||
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
public static Guid GetRequiredUserId(this ClaimsPrincipal principal)
|
||||
{
|
||||
var value = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return Guid.TryParse(value, out var userId)
|
||||
? userId
|
||||
: throw new InvalidOperationException("Authenticated user id claim is missing.");
|
||||
}
|
||||
|
||||
public static Guid GetRequiredSessionId(this ClaimsPrincipal principal)
|
||||
{
|
||||
var value = principal.FindFirstValue("sid");
|
||||
return Guid.TryParse(value, out var sessionId)
|
||||
? sessionId
|
||||
: throw new InvalidOperationException("Authenticated session id claim is missing.");
|
||||
}
|
||||
}
|
||||
116
src/Massenger.Server/Infrastructure/DatabaseSeeder.cs
Normal file
116
src/Massenger.Server/Infrastructure/DatabaseSeeder.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Massenger.Server.Data;
|
||||
using Massenger.Server.Data.Entities;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Server.Infrastructure;
|
||||
|
||||
public sealed class DatabaseSeeder(MassengerDbContext dbContext)
|
||||
{
|
||||
private readonly PasswordHasher<User> _passwordHasher = new();
|
||||
|
||||
public async Task SeedAsync()
|
||||
{
|
||||
if (await dbContext.Users.AnyAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var alice = CreateUser("alice", "Alice Carter", "demo123");
|
||||
var bob = CreateUser("bob", "Bob Miller", "demo123");
|
||||
var carol = CreateUser("carol", "Carol Stone", "demo123");
|
||||
|
||||
var directChat = new Chat
|
||||
{
|
||||
Type = ChatType.Direct,
|
||||
CreatedBy = alice,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
Members =
|
||||
[
|
||||
new ChatMember { User = alice, IsOwner = true, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-30) },
|
||||
new ChatMember { User = bob, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-30) }
|
||||
]
|
||||
};
|
||||
|
||||
var groupChat = new Chat
|
||||
{
|
||||
Type = ChatType.Group,
|
||||
Title = "Massenger Crew",
|
||||
CreatedBy = alice,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20),
|
||||
LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-2),
|
||||
Members =
|
||||
[
|
||||
new ChatMember { User = alice, IsOwner = true, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-20) },
|
||||
new ChatMember { User = bob, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-20) },
|
||||
new ChatMember { User = carol, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-20) }
|
||||
]
|
||||
};
|
||||
|
||||
directChat.Messages.Add(new Message
|
||||
{
|
||||
Chat = directChat,
|
||||
Sender = alice,
|
||||
Text = "Welcome to Massenger. This is a seeded private dialog.",
|
||||
SentAt = DateTimeOffset.UtcNow.AddMinutes(-10)
|
||||
});
|
||||
|
||||
directChat.Messages.Add(new Message
|
||||
{
|
||||
Chat = directChat,
|
||||
Sender = bob,
|
||||
Text = "Messages are stored in SQLite and delivered through SignalR.",
|
||||
SentAt = DateTimeOffset.UtcNow.AddMinutes(-5)
|
||||
});
|
||||
|
||||
groupChat.Messages.Add(new Message
|
||||
{
|
||||
Chat = groupChat,
|
||||
Sender = carol,
|
||||
Text = "Use this group to validate shared conversations.",
|
||||
SentAt = DateTimeOffset.UtcNow.AddMinutes(-2)
|
||||
});
|
||||
|
||||
var channel = new Chat
|
||||
{
|
||||
Type = ChatType.Channel,
|
||||
Title = "Massenger Updates",
|
||||
CreatedBy = alice,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-15),
|
||||
LastActivityAt = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
Members =
|
||||
[
|
||||
new ChatMember { User = alice, IsOwner = true, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-15) },
|
||||
new ChatMember { User = bob, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-15) },
|
||||
new ChatMember { User = carol, IsOwner = false, JoinedAt = DateTimeOffset.UtcNow.AddMinutes(-15) }
|
||||
]
|
||||
};
|
||||
|
||||
channel.Messages.Add(new Message
|
||||
{
|
||||
Chat = channel,
|
||||
Sender = alice,
|
||||
Text = "This channel is broadcast-only. Owners can post, subscribers can read.",
|
||||
SentAt = DateTimeOffset.UtcNow.AddMinutes(-1)
|
||||
});
|
||||
|
||||
dbContext.Users.AddRange(alice, bob, carol);
|
||||
dbContext.Chats.AddRange(directChat, groupChat, channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private User CreateUser(string username, string displayName, string password)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Username = username,
|
||||
NormalizedUsername = username.ToUpperInvariant(),
|
||||
DisplayName = displayName
|
||||
};
|
||||
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, password);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
203
src/Massenger.Server/Infrastructure/DtoMapper.cs
Normal file
203
src/Massenger.Server/Infrastructure/DtoMapper.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using Massenger.Server.Data.Entities;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Server.Infrastructure;
|
||||
|
||||
public static class DtoMapper
|
||||
{
|
||||
private static readonly HashSet<string> AudioExtensions =
|
||||
[
|
||||
".aac",
|
||||
".flac",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".oga",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
".webm"
|
||||
];
|
||||
|
||||
public static UserSummaryDto ToDto(this User user, PresenceTracker presenceTracker) =>
|
||||
new(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
presenceTracker.IsOnline(user.Id));
|
||||
|
||||
public static PushDeviceDto ToDto(this PushDevice device) =>
|
||||
new(
|
||||
device.Id,
|
||||
device.Platform,
|
||||
device.InstallationId,
|
||||
device.DeviceName,
|
||||
device.NotificationsEnabled,
|
||||
device.UpdatedAt);
|
||||
|
||||
public static MessageDto ToDto(this Message message, PresenceTracker presenceTracker) =>
|
||||
new(
|
||||
message.Id,
|
||||
message.ChatId,
|
||||
message.Sender.ToDto(presenceTracker),
|
||||
message.Text,
|
||||
message.Attachments
|
||||
.OrderBy(x => x.OriginalFileName)
|
||||
.Select(x => x.ToDto(message.ChatId))
|
||||
.ToList(),
|
||||
message.SentAt,
|
||||
message.EditedAt,
|
||||
message.DeletedAt);
|
||||
|
||||
public static AttachmentDto ToDto(this MessageAttachment attachment, Guid chatId) =>
|
||||
new(
|
||||
attachment.Id,
|
||||
attachment.OriginalFileName,
|
||||
attachment.ContentType,
|
||||
attachment.FileSizeBytes,
|
||||
$"/api/chats/{chatId}/attachments/{attachment.Id}/content",
|
||||
ResolveAttachmentKind(attachment));
|
||||
|
||||
public static ChatSummaryDto ToSummaryDto(this Chat chat, Guid currentUserId, PresenceTracker presenceTracker)
|
||||
{
|
||||
var orderedMembers = chat.Members
|
||||
.Select(x => x.User)
|
||||
.OrderBy(x => x.DisplayName)
|
||||
.ToList();
|
||||
|
||||
var participants = orderedMembers
|
||||
.Select(user => user.ToDto(presenceTracker))
|
||||
.ToList();
|
||||
|
||||
var counterpart = chat.Type == ChatType.Direct
|
||||
? orderedMembers.FirstOrDefault(x => x.Id != currentUserId)
|
||||
: null;
|
||||
var currentMembership = chat.Members.FirstOrDefault(x => x.UserId == currentUserId);
|
||||
var canSendMessages = chat.Type != ChatType.Channel || currentMembership?.IsOwner == true;
|
||||
|
||||
var title = chat.Type == ChatType.Direct
|
||||
? counterpart?.DisplayName ?? "Dialog"
|
||||
: string.IsNullOrWhiteSpace(chat.Title) ? "New Group" : chat.Title;
|
||||
|
||||
var secondary = chat.Type switch
|
||||
{
|
||||
ChatType.Direct => counterpart is null ? null : $"@{counterpart.Username}",
|
||||
ChatType.Channel => $"{chat.Members.Count(x => !x.IsOwner)} subscribers{(canSendMessages ? " | owner can post" : " | read-only")}",
|
||||
_ => $"{participants.Count} members"
|
||||
};
|
||||
|
||||
var lastMessage = chat.Messages.OrderByDescending(x => x.SentAt).FirstOrDefault();
|
||||
var lastPreview = lastMessage is null ? null : BuildMessagePreview(lastMessage);
|
||||
|
||||
return new ChatSummaryDto(
|
||||
chat.Id,
|
||||
title,
|
||||
chat.Type,
|
||||
secondary,
|
||||
chat.LastActivityAt ?? chat.CreatedAt,
|
||||
lastPreview,
|
||||
participants,
|
||||
canSendMessages);
|
||||
}
|
||||
|
||||
public static ChatDetailsDto ToDetailsDto(this Chat chat, Guid currentUserId, PresenceTracker presenceTracker)
|
||||
{
|
||||
var participants = chat.Members
|
||||
.Select(x => x.User)
|
||||
.OrderBy(x => x.DisplayName)
|
||||
.Select(x => x.ToDto(presenceTracker))
|
||||
.ToList();
|
||||
|
||||
var counterpart = chat.Type == ChatType.Direct
|
||||
? chat.Members.Select(x => x.User).FirstOrDefault(x => x.Id != currentUserId)
|
||||
: null;
|
||||
var currentMembership = chat.Members.FirstOrDefault(x => x.UserId == currentUserId);
|
||||
var canSendMessages = chat.Type != ChatType.Channel || currentMembership?.IsOwner == true;
|
||||
|
||||
var title = chat.Type == ChatType.Direct
|
||||
? counterpart?.DisplayName ?? "Dialog"
|
||||
: string.IsNullOrWhiteSpace(chat.Title) ? "New Group" : chat.Title;
|
||||
|
||||
var messages = chat.Messages
|
||||
.OrderBy(x => x.SentAt)
|
||||
.Select(x => x.ToDto(presenceTracker))
|
||||
.ToList();
|
||||
|
||||
return new ChatDetailsDto(chat.Id, title, chat.Type, participants, canSendMessages, messages);
|
||||
}
|
||||
|
||||
public static string BuildPushTitle(this Chat chat, Message message) =>
|
||||
chat.Type switch
|
||||
{
|
||||
ChatType.Direct => message.Sender.DisplayName,
|
||||
_ => string.IsNullOrWhiteSpace(chat.Title) ? message.Sender.DisplayName : chat.Title!
|
||||
};
|
||||
|
||||
public static string BuildPushBody(this Message message)
|
||||
{
|
||||
if (message.DeletedAt is not null)
|
||||
{
|
||||
return "Message removed";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message.Text))
|
||||
{
|
||||
return message.Attachments.Count == 0
|
||||
? message.Text
|
||||
: $"{message.Text} ({BuildAttachmentPreview(message.Attachments)})";
|
||||
}
|
||||
|
||||
return BuildAttachmentPreview(message.Attachments);
|
||||
}
|
||||
|
||||
private static string BuildMessagePreview(Message lastMessage)
|
||||
{
|
||||
if (lastMessage.DeletedAt is not null)
|
||||
{
|
||||
return $"{lastMessage.Sender.DisplayName}: message removed";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(lastMessage.Text))
|
||||
{
|
||||
return $"{lastMessage.Sender.DisplayName}: {BuildAttachmentPreview(lastMessage.Attachments)}";
|
||||
}
|
||||
|
||||
return lastMessage.Attachments.Count == 0
|
||||
? $"{lastMessage.Sender.DisplayName}: {lastMessage.Text}"
|
||||
: $"{lastMessage.Sender.DisplayName}: {lastMessage.Text} ({BuildAttachmentPreview(lastMessage.Attachments)})";
|
||||
}
|
||||
|
||||
private static string BuildAttachmentPreview(IReadOnlyCollection<MessageAttachment> attachments)
|
||||
{
|
||||
if (attachments.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (attachments.Count == 1)
|
||||
{
|
||||
var attachment = attachments.First();
|
||||
return ResolveAttachmentKind(attachment) == AttachmentKind.VoiceNote
|
||||
? "voice note"
|
||||
: $"file: {attachment.OriginalFileName}";
|
||||
}
|
||||
|
||||
return $"{attachments.Count} files";
|
||||
}
|
||||
|
||||
private static AttachmentKind ResolveAttachmentKind(MessageAttachment attachment) =>
|
||||
IsAudioAttachment(attachment.ContentType, attachment.OriginalFileName)
|
||||
? AttachmentKind.VoiceNote
|
||||
: AttachmentKind.File;
|
||||
|
||||
private static bool IsAudioAttachment(string? contentType, string? fileName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(contentType) &&
|
||||
contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(fileName ?? string.Empty);
|
||||
return AudioExtensions.Contains(extension);
|
||||
}
|
||||
}
|
||||
20
src/Massenger.Server/Infrastructure/Hubs/MessengerHub.cs
Normal file
20
src/Massenger.Server/Infrastructure/Hubs/MessengerHub.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Massenger.Server.Infrastructure.Hubs;
|
||||
|
||||
[Authorize]
|
||||
public sealed class MessengerHub(PresenceTracker presenceTracker) : Hub
|
||||
{
|
||||
public override Task OnConnectedAsync()
|
||||
{
|
||||
presenceTracker.MarkOnline(Context.User!.GetRequiredUserId());
|
||||
return base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
presenceTracker.MarkOffline(Context.User!.GetRequiredUserId());
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
}
|
||||
41
src/Massenger.Server/Infrastructure/PresenceTracker.cs
Normal file
41
src/Massenger.Server/Infrastructure/PresenceTracker.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Massenger.Server.Infrastructure;
|
||||
|
||||
public sealed class PresenceTracker
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, int> _connections = new();
|
||||
|
||||
public void MarkOnline(Guid userId)
|
||||
{
|
||||
_connections.AddOrUpdate(userId, 1, (_, current) => current + 1);
|
||||
}
|
||||
|
||||
public void MarkOffline(Guid userId)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (!_connections.TryGetValue(userId, out var current))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (current <= 1)
|
||||
{
|
||||
if (_connections.TryRemove(userId, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_connections.TryUpdate(userId, current - 1, current))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsOnline(Guid userId) => _connections.ContainsKey(userId);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Massenger.Server.Data;
|
||||
using Massenger.Server.Data.Entities;
|
||||
using Massenger.Shared;
|
||||
|
||||
namespace Massenger.Server.Infrastructure.Push;
|
||||
|
||||
public sealed class FirebasePushNotificationSender(
|
||||
HttpClient httpClient,
|
||||
IOptions<PushOptions> options,
|
||||
IHostEnvironment hostEnvironment,
|
||||
MassengerDbContext dbContext,
|
||||
ILogger<FirebasePushNotificationSender> logger) : IPushNotificationSender
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly PushOptions _options = options.Value;
|
||||
private readonly SemaphoreSlim _tokenLock = new(1, 1);
|
||||
|
||||
private OAuthTokenCache? _cachedToken;
|
||||
private ServiceAccountCredentials? _cachedCredentials;
|
||||
|
||||
public async Task SendMessageAsync(
|
||||
Chat chat,
|
||||
Message message,
|
||||
IReadOnlyCollection<PushDevice> devices,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var androidDevices = devices
|
||||
.Where(x => x.Platform == PushPlatform.Android && !string.IsNullOrWhiteSpace(x.DeviceToken))
|
||||
.ToList();
|
||||
|
||||
if (androidDevices.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var credentials = LoadCredentials();
|
||||
if (credentials is null)
|
||||
{
|
||||
logger.LogDebug("Firebase push is not configured. Skipping {DeviceCount} Android device(s).", androidDevices.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
var projectId = string.IsNullOrWhiteSpace(_options.FirebaseProjectId)
|
||||
? credentials.ProjectId
|
||||
: _options.FirebaseProjectId.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(projectId))
|
||||
{
|
||||
logger.LogWarning("Push:FirebaseProjectId is empty and the service account JSON does not contain project_id.");
|
||||
return;
|
||||
}
|
||||
|
||||
var accessToken = await GetAccessTokenAsync(credentials, cancellationToken);
|
||||
var title = chat.BuildPushTitle(message);
|
||||
var body = message.BuildPushBody();
|
||||
|
||||
foreach (var device in androidDevices)
|
||||
{
|
||||
await SendToAndroidAsync(projectId, accessToken, device, title, body, message, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceAccountCredentials? LoadCredentials()
|
||||
{
|
||||
if (_cachedCredentials is not null)
|
||||
{
|
||||
return _cachedCredentials;
|
||||
}
|
||||
|
||||
var json = _options.ServiceAccountJson;
|
||||
if (string.IsNullOrWhiteSpace(json) && !string.IsNullOrWhiteSpace(_options.ServiceAccountJsonPath))
|
||||
{
|
||||
var path = _options.ServiceAccountJsonPath;
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
path = Path.Combine(hostEnvironment.ContentRootPath, path);
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
logger.LogWarning("Push service account file was not found: {Path}", path);
|
||||
return null;
|
||||
}
|
||||
|
||||
json = File.ReadAllText(path);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var credentials = JsonSerializer.Deserialize<ServiceAccountCredentials>(json, JsonOptions);
|
||||
if (credentials is null ||
|
||||
string.IsNullOrWhiteSpace(credentials.ClientEmail) ||
|
||||
string.IsNullOrWhiteSpace(credentials.PrivateKey))
|
||||
{
|
||||
logger.LogWarning("Push service account JSON is missing required fields.");
|
||||
return null;
|
||||
}
|
||||
|
||||
_cachedCredentials = credentials;
|
||||
return credentials;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Push service account JSON could not be parsed.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetAccessTokenAsync(ServiceAccountCredentials credentials, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (_cachedToken is not null && _cachedToken.ExpiresAt > now.AddMinutes(5))
|
||||
{
|
||||
return _cachedToken.AccessToken;
|
||||
}
|
||||
|
||||
await _tokenLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
now = DateTimeOffset.UtcNow;
|
||||
if (_cachedToken is not null && _cachedToken.ExpiresAt > now.AddMinutes(5))
|
||||
{
|
||||
return _cachedToken.AccessToken;
|
||||
}
|
||||
|
||||
var assertion = CreateJwtAssertion(credentials, now);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, credentials.TokenUri ?? "https://oauth2.googleapis.com/token")
|
||||
{
|
||||
Content = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
|
||||
new KeyValuePair<string, string>("assertion", assertion)
|
||||
])
|
||||
};
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
string.IsNullOrWhiteSpace(payload)
|
||||
? $"Failed to acquire Firebase access token: {(int)response.StatusCode}."
|
||||
: $"Failed to acquire Firebase access token: {payload}");
|
||||
}
|
||||
|
||||
var tokenResponse = JsonSerializer.Deserialize<OAuthTokenResponse>(payload, JsonOptions)
|
||||
?? throw new InvalidOperationException("Firebase access token response was empty.");
|
||||
|
||||
_cachedToken = new OAuthTokenCache(
|
||||
tokenResponse.AccessToken,
|
||||
now.AddSeconds(tokenResponse.ExpiresIn));
|
||||
|
||||
return _cachedToken.AccessToken;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tokenLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendToAndroidAsync(
|
||||
string projectId,
|
||||
string accessToken,
|
||||
PushDevice device,
|
||||
string title,
|
||||
string body,
|
||||
Message message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
message = new
|
||||
{
|
||||
token = device.DeviceToken,
|
||||
data = new Dictionary<string, string>
|
||||
{
|
||||
[PushDataKeys.Kind] = PushDataKinds.ChatMessage,
|
||||
[PushDataKeys.ChatId] = message.ChatId.ToString(),
|
||||
[PushDataKeys.MessageId] = message.Id.ToString(),
|
||||
[PushDataKeys.Title] = title,
|
||||
[PushDataKeys.Body] = body,
|
||||
[PushDataKeys.SenderId] = message.SenderId.ToString(),
|
||||
[PushDataKeys.SenderName] = message.Sender.DisplayName
|
||||
},
|
||||
android = new
|
||||
{
|
||||
priority = "high",
|
||||
ttl = "30s"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"https://fcm.googleapis.com/v1/projects/{projectId}/messages:send");
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
logger.LogWarning(
|
||||
"Push send failed for installation {InstallationId} with status {StatusCode}. Response: {ResponseBody}",
|
||||
device.InstallationId,
|
||||
(int)response.StatusCode,
|
||||
responseBody);
|
||||
|
||||
if (!ShouldDeleteDevice(responseBody))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dbContext.PushDevices.Remove(device);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static bool ShouldDeleteDevice(string? responseBody) =>
|
||||
!string.IsNullOrWhiteSpace(responseBody) &&
|
||||
(responseBody.Contains("UNREGISTERED", StringComparison.OrdinalIgnoreCase) ||
|
||||
responseBody.Contains("registration-token-not-registered", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static string CreateJwtAssertion(ServiceAccountCredentials credentials, DateTimeOffset now)
|
||||
{
|
||||
var header = Base64UrlEncode("""{"alg":"RS256","typ":"JWT"}""");
|
||||
var payload = Base64UrlEncode(JsonSerializer.Serialize(new Dictionary<string, object>
|
||||
{
|
||||
["iss"] = credentials.ClientEmail,
|
||||
["scope"] = "https://www.googleapis.com/auth/firebase.messaging",
|
||||
["aud"] = credentials.TokenUri ?? "https://oauth2.googleapis.com/token",
|
||||
["iat"] = now.ToUnixTimeSeconds(),
|
||||
["exp"] = now.AddHours(1).ToUnixTimeSeconds()
|
||||
}));
|
||||
|
||||
var unsignedToken = $"{header}.{payload}";
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(credentials.PrivateKey);
|
||||
var signatureBytes = rsa.SignData(Encoding.UTF8.GetBytes(unsignedToken), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return $"{unsignedToken}.{Base64UrlEncode(signatureBytes)}";
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(string value) =>
|
||||
Base64UrlEncode(Encoding.UTF8.GetBytes(value));
|
||||
|
||||
private static string Base64UrlEncode(byte[] value) =>
|
||||
Convert.ToBase64String(value)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
private sealed record OAuthTokenCache(string AccessToken, DateTimeOffset ExpiresAt);
|
||||
|
||||
private sealed record OAuthTokenResponse(
|
||||
[property: JsonPropertyName("access_token")] string AccessToken,
|
||||
[property: JsonPropertyName("expires_in")] int ExpiresIn);
|
||||
|
||||
private sealed record ServiceAccountCredentials(
|
||||
[property: JsonPropertyName("project_id")] string ProjectId,
|
||||
[property: JsonPropertyName("client_email")] string ClientEmail,
|
||||
[property: JsonPropertyName("private_key")] string PrivateKey,
|
||||
[property: JsonPropertyName("token_uri")] string? TokenUri);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Massenger.Server.Data.Entities;
|
||||
|
||||
namespace Massenger.Server.Infrastructure.Push;
|
||||
|
||||
public interface IPushNotificationSender
|
||||
{
|
||||
Task SendMessageAsync(
|
||||
Chat chat,
|
||||
Message message,
|
||||
IReadOnlyCollection<PushDevice> devices,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Massenger.Server.Data;
|
||||
using Massenger.Server.Data.Entities;
|
||||
|
||||
namespace Massenger.Server.Infrastructure.Push;
|
||||
|
||||
public sealed class PushNotificationDispatcher(
|
||||
MassengerDbContext dbContext,
|
||||
PresenceTracker presenceTracker,
|
||||
IPushNotificationSender pushNotificationSender,
|
||||
ILogger<PushNotificationDispatcher> logger)
|
||||
{
|
||||
public async Task DispatchMessageAsync(Chat chat, Message message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var offlineRecipientIds = chat.Members
|
||||
.Select(x => x.UserId)
|
||||
.Where(x => x != message.SenderId)
|
||||
.Where(x => !presenceTracker.IsOnline(x))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (offlineRecipientIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var devices = await dbContext.PushDevices
|
||||
.Where(x => offlineRecipientIds.Contains(x.UserId) && x.NotificationsEnabled)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (devices.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await pushNotificationSender.SendMessageAsync(chat, message, devices, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Push dispatch failed for message {MessageId}.", message.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/Massenger.Server/Infrastructure/Push/PushOptions.cs
Normal file
12
src/Massenger.Server/Infrastructure/Push/PushOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Massenger.Server.Infrastructure.Push;
|
||||
|
||||
public sealed class PushOptions
|
||||
{
|
||||
public const string SectionName = "Push";
|
||||
|
||||
public string FirebaseProjectId { get; set; } = string.Empty;
|
||||
|
||||
public string ServiceAccountJsonPath { get; set; } = string.Empty;
|
||||
|
||||
public string ServiceAccountJson { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Massenger.Server.Data.Entities;
|
||||
|
||||
namespace Massenger.Server.Infrastructure.Storage;
|
||||
|
||||
public sealed class AttachmentStorageService(IWebHostEnvironment environment)
|
||||
{
|
||||
private readonly string _rootPath = Path.Combine(environment.ContentRootPath, "Data", "Attachments");
|
||||
|
||||
public async Task<MessageAttachment> SaveAsync(Guid messageId, IFormFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file.Length <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Attachment is empty.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_rootPath);
|
||||
|
||||
var attachmentId = Guid.NewGuid();
|
||||
var extension = Path.GetExtension(file.FileName);
|
||||
var year = DateTime.UtcNow.ToString("yyyy");
|
||||
var month = DateTime.UtcNow.ToString("MM");
|
||||
var targetDirectory = Path.Combine(_rootPath, year, month);
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
|
||||
var storedFileName = $"{attachmentId:N}{extension}";
|
||||
var physicalPath = Path.Combine(targetDirectory, storedFileName);
|
||||
|
||||
await using (var source = file.OpenReadStream())
|
||||
await using (var target = File.Create(physicalPath))
|
||||
{
|
||||
await source.CopyToAsync(target, cancellationToken);
|
||||
}
|
||||
|
||||
return new MessageAttachment
|
||||
{
|
||||
Id = attachmentId,
|
||||
MessageId = messageId,
|
||||
OriginalFileName = string.IsNullOrWhiteSpace(file.FileName) ? "attachment" : Path.GetFileName(file.FileName),
|
||||
ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
||||
FileSizeBytes = file.Length,
|
||||
StoragePath = physicalPath,
|
||||
UploadedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public void DeleteIfExists(string? physicalPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(physicalPath) && File.Exists(physicalPath))
|
||||
{
|
||||
File.Delete(physicalPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Massenger.Server.Data;
|
||||
|
||||
namespace Massenger.Server.Infrastructure.Storage;
|
||||
|
||||
public sealed class DatabaseSchemaBootstrapper(MassengerDbContext dbContext)
|
||||
{
|
||||
public async Task EnsureLatestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureMessageAttachmentsTableAsync(cancellationToken);
|
||||
await EnsurePushDevicesTableAsync(cancellationToken);
|
||||
await EnsureColumnAsync("Messages", "EditedAt", "TEXT NULL", cancellationToken);
|
||||
await EnsureColumnAsync("Messages", "DeletedAt", "TEXT NULL", cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureMessageAttachmentsTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "MessageAttachments" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_MessageAttachments" PRIMARY KEY,
|
||||
"MessageId" TEXT NOT NULL,
|
||||
"OriginalFileName" TEXT NOT NULL,
|
||||
"ContentType" TEXT NOT NULL,
|
||||
"FileSizeBytes" INTEGER NOT NULL,
|
||||
"StoragePath" TEXT NOT NULL,
|
||||
"UploadedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_MessageAttachments_Messages_MessageId" FOREIGN KEY ("MessageId") REFERENCES "Messages" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_MessageAttachments_MessageId" ON "MessageAttachments" ("MessageId");
|
||||
""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsurePushDevicesTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "PushDevices" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_PushDevices" PRIMARY KEY,
|
||||
"UserId" TEXT NOT NULL,
|
||||
"Platform" INTEGER NOT NULL,
|
||||
"InstallationId" TEXT NOT NULL,
|
||||
"DeviceToken" TEXT NOT NULL,
|
||||
"DeviceName" TEXT NULL,
|
||||
"NotificationsEnabled" INTEGER NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_PushDevices_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_PushDevices_InstallationId" ON "PushDevices" ("InstallationId");
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_PushDevices_UserId" ON "PushDevices" ("UserId");
|
||||
""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureColumnAsync(
|
||||
string tableName,
|
||||
string columnName,
|
||||
string definition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = dbContext.Database.GetDbConnection();
|
||||
var shouldClose = connection.State != ConnectionState.Open;
|
||||
if (shouldClose)
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = $"PRAGMA table_info(\"{tableName}\");";
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await reader.DisposeAsync();
|
||||
|
||||
await using var alterCommand = connection.CreateCommand();
|
||||
alterCommand.CommandText = $"ALTER TABLE \"{tableName}\" ADD COLUMN \"{columnName}\" {definition};";
|
||||
await alterCommand.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (shouldClose)
|
||||
{
|
||||
await connection.CloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/Massenger.Server/Massenger.Server.csproj
Normal file
19
src/Massenger.Server/Massenger.Server.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Massenger.Shared\Massenger.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
src/Massenger.Server/Massenger.Server.http
Normal file
6
src/Massenger.Server/Massenger.Server.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@Massenger.Server_HostAddress = http://localhost:5289
|
||||
|
||||
GET {{Massenger.Server_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
119
src/Massenger.Server/Program.cs
Normal file
119
src/Massenger.Server/Program.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Massenger.Server.Data;
|
||||
using Massenger.Server.Infrastructure;
|
||||
using Massenger.Server.Infrastructure.Auth;
|
||||
using Massenger.Server.Infrastructure.Hubs;
|
||||
using Massenger.Server.Infrastructure.Push;
|
||||
using Massenger.Server.Infrastructure.Storage;
|
||||
using System.Text;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.WebHost.UseUrls("http://0.0.0.0:5099");
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
builder.Services.AddDbContext<MassengerDbContext>(options =>
|
||||
options.UseSqlite(builder.Configuration.GetConnectionString("MassengerDb") ??
|
||||
"Data Source=Data/massenger.db"));
|
||||
|
||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
|
||||
builder.Services.Configure<PushOptions>(builder.Configuration.GetSection(PushOptions.SectionName));
|
||||
|
||||
var jwtOptions = builder.Configuration
|
||||
.GetSection(JwtOptions.SectionName)
|
||||
.Get<JwtOptions>() ?? new JwtOptions();
|
||||
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey));
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidateLifetime = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
IssuerSigningKey = signingKey,
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrWhiteSpace(accessToken) &&
|
||||
path.StartsWithSegments("/hubs/messenger"))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnTokenValidated = async context =>
|
||||
{
|
||||
var principal = context.Principal;
|
||||
if (principal is null)
|
||||
{
|
||||
context.Fail("Token principal is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
var sessionId = principal.GetRequiredSessionId();
|
||||
var dbContext = context.HttpContext.RequestServices.GetRequiredService<MassengerDbContext>();
|
||||
var session = await dbContext.UserSessions.SingleOrDefaultAsync(x => x.Id == sessionId, context.HttpContext.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
context.Fail("Session is no longer active.");
|
||||
return;
|
||||
}
|
||||
|
||||
session.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(context.HttpContext.RequestAborted);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddSingleton<PresenceTracker>();
|
||||
builder.Services.AddScoped<TokenService>();
|
||||
builder.Services.AddScoped<DatabaseSeeder>();
|
||||
builder.Services.AddScoped<DatabaseSchemaBootstrapper>();
|
||||
builder.Services.AddScoped<AttachmentStorageService>();
|
||||
builder.Services.AddScoped<PushNotificationDispatcher>();
|
||||
builder.Services.AddHttpClient<IPushNotificationSender, FirebasePushNotificationSender>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(app.Environment.ContentRootPath, "Data"));
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<MassengerDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await scope.ServiceProvider.GetRequiredService<DatabaseSchemaBootstrapper>().EnsureLatestAsync();
|
||||
await scope.ServiceProvider.GetRequiredService<DatabaseSeeder>().SeedAsync();
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapHub<MessengerHub>("/hubs/messenger");
|
||||
|
||||
app.Run();
|
||||
23
src/Massenger.Server/Properties/launchSettings.json
Normal file
23
src/Massenger.Server/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5099",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Massenger.Server": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5099",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/Massenger.Server/appsettings.Development.json
Normal file
8
src/Massenger.Server/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/Massenger.Server/appsettings.json
Normal file
24
src/Massenger.Server/appsettings.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"MassengerDb": "Data Source=Data/massenger.db"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "Massenger.Server",
|
||||
"Audience": "Massenger.Client",
|
||||
"SigningKey": "Massenger-Replace-This-Development-Key-With-A-Long-Random-Secret-123456789",
|
||||
"AccessTokenLifetimeMinutes": 15,
|
||||
"RefreshTokenLifetimeDays": 30
|
||||
},
|
||||
"Push": {
|
||||
"FirebaseProjectId": "",
|
||||
"ServiceAccountJsonPath": "",
|
||||
"ServiceAccountJson": ""
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
7
src/Massenger.Shared/AttachmentKind.cs
Normal file
7
src/Massenger.Shared/AttachmentKind.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Massenger.Shared;
|
||||
|
||||
public enum AttachmentKind
|
||||
{
|
||||
File = 1,
|
||||
VoiceNote = 2
|
||||
}
|
||||
13
src/Massenger.Shared/AuthContracts.cs
Normal file
13
src/Massenger.Shared/AuthContracts.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Massenger.Shared;
|
||||
|
||||
public sealed record RegisterRequest(string Username, string DisplayName, string Password);
|
||||
|
||||
public sealed record LoginRequest(string Username, string Password);
|
||||
|
||||
public sealed record RefreshSessionRequest(string RefreshToken);
|
||||
|
||||
public sealed record AuthSessionDto(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
DateTimeOffset AccessTokenExpiresAt,
|
||||
UserSummaryDto User);
|
||||
25
src/Massenger.Shared/ChatContracts.cs
Normal file
25
src/Massenger.Shared/ChatContracts.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace Massenger.Shared;
|
||||
|
||||
public sealed record ChatSummaryDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
ChatType Type,
|
||||
string? SecondaryText,
|
||||
DateTimeOffset? LastActivityAt,
|
||||
string? LastMessagePreview,
|
||||
IReadOnlyList<UserSummaryDto> Participants,
|
||||
bool CanSendMessages);
|
||||
|
||||
public sealed record ChatDetailsDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
ChatType Type,
|
||||
IReadOnlyList<UserSummaryDto> Participants,
|
||||
bool CanSendMessages,
|
||||
IReadOnlyList<MessageDto> Messages);
|
||||
|
||||
public sealed record CreateDirectChatRequest(Guid UserId);
|
||||
|
||||
public sealed record CreateGroupChatRequest(string Title, IReadOnlyList<Guid> MemberIds);
|
||||
|
||||
public sealed record CreateChannelRequest(string Title, IReadOnlyList<Guid> MemberIds);
|
||||
8
src/Massenger.Shared/ChatType.cs
Normal file
8
src/Massenger.Shared/ChatType.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Massenger.Shared;
|
||||
|
||||
public enum ChatType
|
||||
{
|
||||
Direct = 1,
|
||||
Group = 2,
|
||||
Channel = 3
|
||||
}
|
||||
9
src/Massenger.Shared/Massenger.Shared.csproj
Normal file
9
src/Massenger.Shared/Massenger.Shared.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user