Добавьте файлы проекта.

This commit is contained in:
Курнат Андрей
2026-03-13 21:01:04 +03:00
commit b460f17029
111 changed files with 5803 additions and 0 deletions

View 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>

View 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}");
});
}
}

View 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>

View 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));
}
}

View 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>

View 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;
}
}

View 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";
}
}

View 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;
}

View File

@@ -0,0 +1,9 @@
using Massenger.Shared;
namespace Massenger.Client.Models;
public sealed record ClientSession(
string AccessToken,
string RefreshToken,
DateTimeOffset AccessTokenExpiresAt,
UserSummaryDto User);

View 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";
}

View 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;
}
}

View 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>

View 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);
}
}
}

View 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>

View 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;
}
}
}

View 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>

View 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();
}
}
}

View 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>

View 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>();
}
}

View 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>

View 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();
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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)!;
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}

View 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);
}
}

View 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();
}

View File

@@ -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"
});
}
}

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -0,0 +1,9 @@
using Foundation;
namespace Massenger.Client;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -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>

View 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>

View 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));
}
}

View 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>

View 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();
}

View 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>

View 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>

View File

@@ -0,0 +1,9 @@
using Foundation;
namespace Massenger.Client;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View 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>

View 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));
}
}

View File

@@ -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>

View File

@@ -0,0 +1,8 @@
{
"profiles": {
"Windows Machine": {
"commandName": "Project",
"nativeDebugging": false
}
}
}

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View 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();
}

View File

@@ -0,0 +1,6 @@
{
"applicationId": "",
"projectId": "",
"apiKey": "",
"senderId": ""
}

View 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

View 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>

View 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>

View 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);
}
}

View 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);
}

View 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;
}

View File

@@ -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;
}
}
}

View 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);
}
}

View 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";
}
}

View 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>();
}

View 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);
}
}

View 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;
}
}
}

View 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;
}
}

View 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());
}
}

View 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));
}
}

View 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);
});
}

View 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();
}
}

View 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();
}
}

View 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);
}
}

View 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;
}
}

View File

@@ -0,0 +1,8 @@
namespace Massenger.Server.Controllers;
public sealed class UploadAttachmentRequest
{
public string? Text { get; set; }
public IFormFile? File { get; set; }
}

View 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());
}
}

View 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;
}

View 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('/', '_');
}
}

View File

@@ -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.");
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View 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;
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}
}

View 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>

View File

@@ -0,0 +1,6 @@
@Massenger.Server_HostAddress = http://localhost:5289
GET {{Massenger.Server_HostAddress}}/weatherforecast/
Accept: application/json
###

View 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();

View 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"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View 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": "*"
}

View File

@@ -0,0 +1,7 @@
namespace Massenger.Shared;
public enum AttachmentKind
{
File = 1,
VoiceNote = 2
}

View 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);

View 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);

View File

@@ -0,0 +1,8 @@
namespace Massenger.Shared;
public enum ChatType
{
Direct = 1,
Group = 2,
Channel = 3
}

View 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