From b460f17029ddbcf490c3b0c7573f842f36410d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Fri, 13 Mar 2026 21:01:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D1=8C=D1=82?= =?UTF-8?q?=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + Massenger.slnx | 7 + MassengerClassic.sln | 69 +++ README.md | 138 +++++ scripts/publish-android-apk.ps1 | 3 + scripts/publish-windows.ps1 | 3 + scripts/run-server.ps1 | 3 + scripts/smoke-test.ps1 | 188 +++++++ src/Massenger.Client/App.xaml | 14 + src/Massenger.Client/App.xaml.cs | 114 +++++ src/Massenger.Client/AppShell.xaml | 26 + src/Massenger.Client/AppShell.xaml.cs | 12 + src/Massenger.Client/Massenger.Client.csproj | 73 +++ src/Massenger.Client/MauiProgram.cs | 46 ++ .../Models/ChatAttachmentItem.cs | 46 ++ .../Models/ChatMessageItem.cs | 49 ++ src/Massenger.Client/Models/ClientSession.cs | 9 + .../Models/DiscoverUserItem.cs | 20 + .../Models/PendingAttachmentItem.cs | 58 +++ src/Massenger.Client/Pages/ChatPage.xaml | 116 +++++ src/Massenger.Client/Pages/ChatPage.xaml.cs | 44 ++ src/Massenger.Client/Pages/ChatsPage.xaml | 47 ++ src/Massenger.Client/Pages/ChatsPage.xaml.cs | 32 ++ src/Massenger.Client/Pages/DiscoverPage.xaml | 75 +++ .../Pages/DiscoverPage.xaml.cs | 24 + src/Massenger.Client/Pages/LoginPage.xaml | 58 +++ src/Massenger.Client/Pages/LoginPage.xaml.cs | 13 + src/Massenger.Client/Pages/ProfilePage.xaml | 39 ++ .../Pages/ProfilePage.xaml.cs | 21 + .../Android/AndroidFirebaseOptionsLoader.cs | 47 ++ .../Platforms/Android/AndroidManifest.xml | 7 + .../AndroidNotificationDisplayService.cs | 59 +++ .../AndroidNotificationPermissionHelper.cs | 41 ++ .../Android/AndroidPushBootstrapper.cs | 96 ++++ .../Platforms/Android/AndroidPushSettings.cs | 40 ++ .../Android/AndroidTaskExtensions.cs | 40 ++ .../Android/FirebaseTokenProvider.cs | 17 + .../Platforms/Android/MainActivity.cs | 59 +++ .../Platforms/Android/MainApplication.cs | 21 + .../MassengerFirebaseMessagingService.cs | 40 ++ .../Android/Resources/values/colors.xml | 6 + .../AndroidPushRegistrationService.cs | 102 ++++ .../Platforms/MacCatalyst/AppDelegate.cs | 9 + .../Platforms/MacCatalyst/Entitlements.plist | 14 + .../Platforms/MacCatalyst/Info.plist | 40 ++ .../Platforms/MacCatalyst/Program.cs | 15 + .../Platforms/Windows/App.xaml | 8 + .../Platforms/Windows/App.xaml.cs | 24 + .../Platforms/Windows/Package.appxmanifest | 46 ++ .../Platforms/Windows/app.manifest | 17 + .../Platforms/iOS/AppDelegate.cs | 9 + src/Massenger.Client/Platforms/iOS/Info.plist | 32 ++ src/Massenger.Client/Platforms/iOS/Program.cs | 15 + .../iOS/Resources/PrivacyInfo.xcprivacy | 51 ++ .../Properties/launchSettings.json | 8 + .../Resources/AppIcon/appicon.svg | 4 + .../Resources/AppIcon/appiconfg.svg | 8 + .../Resources/Fonts/OpenSans-Regular.ttf | Bin 0 -> 107280 bytes .../Resources/Fonts/OpenSans-Semibold.ttf | Bin 0 -> 111204 bytes .../Resources/Images/dotnet_bot.png | Bin 0 -> 92532 bytes .../Resources/Raw/AboutAssets.txt | 15 + .../Resources/Raw/firebase.android.json | 6 + .../Resources/Splash/splash.svg | 8 + .../Resources/Styles/Colors.xaml | 20 + .../Resources/Styles/Styles.xaml | 65 +++ src/Massenger.Client/Services/ApiClient.cs | 348 +++++++++++++ .../Services/IPushRegistrationService.cs | 10 + .../Services/NoOpPushRegistrationService.cs | 10 + .../Services/NotificationActivationService.cs | 37 ++ .../Services/RealtimeService.cs | 100 ++++ .../Services/ServerEndpointStore.cs | 41 ++ .../Services/ServiceHelper.cs | 19 + .../Services/SessionService.cs | 88 ++++ .../ViewModels/BaseViewModel.cs | 42 ++ .../ViewModels/ChatViewModel.cs | 316 ++++++++++++ .../ViewModels/ChatsViewModel.cs | 77 +++ .../ViewModels/DiscoverViewModel.cs | 118 +++++ .../ViewModels/LoginViewModel.cs | 58 +++ .../ViewModels/ProfileViewModel.cs | 74 +++ .../Controllers/AuthController.cs | 104 ++++ .../Controllers/ChatsController.cs | 479 ++++++++++++++++++ .../Controllers/PushController.cs | 107 ++++ .../Controllers/UploadAttachmentRequest.cs | 8 + .../Controllers/UsersController.cs | 47 ++ .../Infrastructure/Auth/JwtOptions.cs | 16 + .../Infrastructure/Auth/TokenService.cs | 120 +++++ .../ClaimsPrincipalExtensions.cs | 22 + .../Infrastructure/DatabaseSeeder.cs | 116 +++++ .../Infrastructure/DtoMapper.cs | 203 ++++++++ .../Infrastructure/Hubs/MessengerHub.cs | 20 + .../Infrastructure/PresenceTracker.cs | 41 ++ .../Push/FirebasePushNotificationSender.cs | 275 ++++++++++ .../Push/IPushNotificationSender.cs | 12 + .../Push/PushNotificationDispatcher.cs | 45 ++ .../Infrastructure/Push/PushOptions.cs | 12 + .../Storage/AttachmentStorageService.cs | 53 ++ .../Storage/DatabaseSchemaBootstrapper.cs | 114 +++++ src/Massenger.Server/Massenger.Server.csproj | 19 + src/Massenger.Server/Massenger.Server.http | 6 + src/Massenger.Server/Program.cs | 119 +++++ .../Properties/launchSettings.json | 23 + .../appsettings.Development.json | 8 + src/Massenger.Server/appsettings.json | 24 + src/Massenger.Shared/AttachmentKind.cs | 7 + src/Massenger.Shared/AuthContracts.cs | 13 + src/Massenger.Shared/ChatContracts.cs | 25 + src/Massenger.Shared/ChatType.cs | 8 + src/Massenger.Shared/Massenger.Shared.csproj | 9 + src/Massenger.Shared/MessageContracts.cs | 23 + src/Massenger.Shared/PushContracts.cs | 39 ++ src/Massenger.Shared/UserContracts.cs | 7 + 111 files changed, 5803 insertions(+) create mode 100644 .gitignore create mode 100644 Massenger.slnx create mode 100644 MassengerClassic.sln create mode 100644 README.md create mode 100644 scripts/publish-android-apk.ps1 create mode 100644 scripts/publish-windows.ps1 create mode 100644 scripts/run-server.ps1 create mode 100644 scripts/smoke-test.ps1 create mode 100644 src/Massenger.Client/App.xaml create mode 100644 src/Massenger.Client/App.xaml.cs create mode 100644 src/Massenger.Client/AppShell.xaml create mode 100644 src/Massenger.Client/AppShell.xaml.cs create mode 100644 src/Massenger.Client/Massenger.Client.csproj create mode 100644 src/Massenger.Client/MauiProgram.cs create mode 100644 src/Massenger.Client/Models/ChatAttachmentItem.cs create mode 100644 src/Massenger.Client/Models/ChatMessageItem.cs create mode 100644 src/Massenger.Client/Models/ClientSession.cs create mode 100644 src/Massenger.Client/Models/DiscoverUserItem.cs create mode 100644 src/Massenger.Client/Models/PendingAttachmentItem.cs create mode 100644 src/Massenger.Client/Pages/ChatPage.xaml create mode 100644 src/Massenger.Client/Pages/ChatPage.xaml.cs create mode 100644 src/Massenger.Client/Pages/ChatsPage.xaml create mode 100644 src/Massenger.Client/Pages/ChatsPage.xaml.cs create mode 100644 src/Massenger.Client/Pages/DiscoverPage.xaml create mode 100644 src/Massenger.Client/Pages/DiscoverPage.xaml.cs create mode 100644 src/Massenger.Client/Pages/LoginPage.xaml create mode 100644 src/Massenger.Client/Pages/LoginPage.xaml.cs create mode 100644 src/Massenger.Client/Pages/ProfilePage.xaml create mode 100644 src/Massenger.Client/Pages/ProfilePage.xaml.cs create mode 100644 src/Massenger.Client/Platforms/Android/AndroidFirebaseOptionsLoader.cs create mode 100644 src/Massenger.Client/Platforms/Android/AndroidManifest.xml create mode 100644 src/Massenger.Client/Platforms/Android/AndroidNotificationDisplayService.cs create mode 100644 src/Massenger.Client/Platforms/Android/AndroidNotificationPermissionHelper.cs create mode 100644 src/Massenger.Client/Platforms/Android/AndroidPushBootstrapper.cs create mode 100644 src/Massenger.Client/Platforms/Android/AndroidPushSettings.cs create mode 100644 src/Massenger.Client/Platforms/Android/AndroidTaskExtensions.cs create mode 100644 src/Massenger.Client/Platforms/Android/FirebaseTokenProvider.cs create mode 100644 src/Massenger.Client/Platforms/Android/MainActivity.cs create mode 100644 src/Massenger.Client/Platforms/Android/MainApplication.cs create mode 100644 src/Massenger.Client/Platforms/Android/MassengerFirebaseMessagingService.cs create mode 100644 src/Massenger.Client/Platforms/Android/Resources/values/colors.xml create mode 100644 src/Massenger.Client/Platforms/Android/Services/AndroidPushRegistrationService.cs create mode 100644 src/Massenger.Client/Platforms/MacCatalyst/AppDelegate.cs create mode 100644 src/Massenger.Client/Platforms/MacCatalyst/Entitlements.plist create mode 100644 src/Massenger.Client/Platforms/MacCatalyst/Info.plist create mode 100644 src/Massenger.Client/Platforms/MacCatalyst/Program.cs create mode 100644 src/Massenger.Client/Platforms/Windows/App.xaml create mode 100644 src/Massenger.Client/Platforms/Windows/App.xaml.cs create mode 100644 src/Massenger.Client/Platforms/Windows/Package.appxmanifest create mode 100644 src/Massenger.Client/Platforms/Windows/app.manifest create mode 100644 src/Massenger.Client/Platforms/iOS/AppDelegate.cs create mode 100644 src/Massenger.Client/Platforms/iOS/Info.plist create mode 100644 src/Massenger.Client/Platforms/iOS/Program.cs create mode 100644 src/Massenger.Client/Platforms/iOS/Resources/PrivacyInfo.xcprivacy create mode 100644 src/Massenger.Client/Properties/launchSettings.json create mode 100644 src/Massenger.Client/Resources/AppIcon/appicon.svg create mode 100644 src/Massenger.Client/Resources/AppIcon/appiconfg.svg create mode 100644 src/Massenger.Client/Resources/Fonts/OpenSans-Regular.ttf create mode 100644 src/Massenger.Client/Resources/Fonts/OpenSans-Semibold.ttf create mode 100644 src/Massenger.Client/Resources/Images/dotnet_bot.png create mode 100644 src/Massenger.Client/Resources/Raw/AboutAssets.txt create mode 100644 src/Massenger.Client/Resources/Raw/firebase.android.json create mode 100644 src/Massenger.Client/Resources/Splash/splash.svg create mode 100644 src/Massenger.Client/Resources/Styles/Colors.xaml create mode 100644 src/Massenger.Client/Resources/Styles/Styles.xaml create mode 100644 src/Massenger.Client/Services/ApiClient.cs create mode 100644 src/Massenger.Client/Services/IPushRegistrationService.cs create mode 100644 src/Massenger.Client/Services/NoOpPushRegistrationService.cs create mode 100644 src/Massenger.Client/Services/NotificationActivationService.cs create mode 100644 src/Massenger.Client/Services/RealtimeService.cs create mode 100644 src/Massenger.Client/Services/ServerEndpointStore.cs create mode 100644 src/Massenger.Client/Services/ServiceHelper.cs create mode 100644 src/Massenger.Client/Services/SessionService.cs create mode 100644 src/Massenger.Client/ViewModels/BaseViewModel.cs create mode 100644 src/Massenger.Client/ViewModels/ChatViewModel.cs create mode 100644 src/Massenger.Client/ViewModels/ChatsViewModel.cs create mode 100644 src/Massenger.Client/ViewModels/DiscoverViewModel.cs create mode 100644 src/Massenger.Client/ViewModels/LoginViewModel.cs create mode 100644 src/Massenger.Client/ViewModels/ProfileViewModel.cs create mode 100644 src/Massenger.Server/Controllers/AuthController.cs create mode 100644 src/Massenger.Server/Controllers/ChatsController.cs create mode 100644 src/Massenger.Server/Controllers/PushController.cs create mode 100644 src/Massenger.Server/Controllers/UploadAttachmentRequest.cs create mode 100644 src/Massenger.Server/Controllers/UsersController.cs create mode 100644 src/Massenger.Server/Infrastructure/Auth/JwtOptions.cs create mode 100644 src/Massenger.Server/Infrastructure/Auth/TokenService.cs create mode 100644 src/Massenger.Server/Infrastructure/ClaimsPrincipalExtensions.cs create mode 100644 src/Massenger.Server/Infrastructure/DatabaseSeeder.cs create mode 100644 src/Massenger.Server/Infrastructure/DtoMapper.cs create mode 100644 src/Massenger.Server/Infrastructure/Hubs/MessengerHub.cs create mode 100644 src/Massenger.Server/Infrastructure/PresenceTracker.cs create mode 100644 src/Massenger.Server/Infrastructure/Push/FirebasePushNotificationSender.cs create mode 100644 src/Massenger.Server/Infrastructure/Push/IPushNotificationSender.cs create mode 100644 src/Massenger.Server/Infrastructure/Push/PushNotificationDispatcher.cs create mode 100644 src/Massenger.Server/Infrastructure/Push/PushOptions.cs create mode 100644 src/Massenger.Server/Infrastructure/Storage/AttachmentStorageService.cs create mode 100644 src/Massenger.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs create mode 100644 src/Massenger.Server/Massenger.Server.csproj create mode 100644 src/Massenger.Server/Massenger.Server.http create mode 100644 src/Massenger.Server/Program.cs create mode 100644 src/Massenger.Server/Properties/launchSettings.json create mode 100644 src/Massenger.Server/appsettings.Development.json create mode 100644 src/Massenger.Server/appsettings.json create mode 100644 src/Massenger.Shared/AttachmentKind.cs create mode 100644 src/Massenger.Shared/AuthContracts.cs create mode 100644 src/Massenger.Shared/ChatContracts.cs create mode 100644 src/Massenger.Shared/ChatType.cs create mode 100644 src/Massenger.Shared/Massenger.Shared.csproj create mode 100644 src/Massenger.Shared/MessageContracts.cs create mode 100644 src/Massenger.Shared/PushContracts.cs create mode 100644 src/Massenger.Shared/UserContracts.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f306981 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.vs/ +**/bin/ +**/obj/ +src/Massenger.Server/Data/ +*.log diff --git a/Massenger.slnx b/Massenger.slnx new file mode 100644 index 0000000..5738690 --- /dev/null +++ b/Massenger.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/MassengerClassic.sln b/MassengerClassic.sln new file mode 100644 index 0000000..8a0e457 --- /dev/null +++ b/MassengerClassic.sln @@ -0,0 +1,69 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Massenger.Shared", "src\Massenger.Shared\Massenger.Shared.csproj", "{2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Massenger.Server", "src\Massenger.Server\Massenger.Server.csproj", "{631B782D-44B2-4680-BC8C-1A0A948A1945}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Massenger.Client", "src\Massenger.Client\Massenger.Client.csproj", "{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|x64.Build.0 = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|x86.ActiveCfg = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Debug|x86.Build.0 = Debug|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|Any CPU.Build.0 = Release|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|x64.ActiveCfg = Release|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|x64.Build.0 = Release|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|x86.ActiveCfg = Release|Any CPU + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B}.Release|x86.Build.0 = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|Any CPU.Build.0 = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|x64.ActiveCfg = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|x64.Build.0 = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|x86.ActiveCfg = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Debug|x86.Build.0 = Debug|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|Any CPU.ActiveCfg = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|Any CPU.Build.0 = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x64.ActiveCfg = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x64.Build.0 = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x86.ActiveCfg = Release|Any CPU + {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x86.Build.0 = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x64.ActiveCfg = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x64.Build.0 = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x86.ActiveCfg = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x86.Build.0 = Debug|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|Any CPU.Build.0 = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x64.ActiveCfg = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x64.Build.0 = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x86.ActiveCfg = Release|Any CPU + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {631B782D-44B2-4680-BC8C-1A0A948A1945} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E5B4217E-FCA8-4944-99A6-12D07E7B5D60} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..d49d65d --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# Massenger + +Massenger is a working Telegram-like messenger MVP built in this folder with one shared client codebase for Windows and Android. + +## Stack + +- `src/Massenger.Server`: ASP.NET Core 10 Web API + SignalR + SQLite. +- `src/Massenger.Shared`: shared DTO/contracts used by server and client. +- `src/Massenger.Client`: .NET MAUI client targeting `net10.0-windows10.0.19041.0` and `net10.0-android`. + +## Implemented + +- user registration and login +- JWT access tokens with rotating refresh tokens +- secure client-side session persistence +- seeded demo accounts and sample chats +- user search +- direct chats +- group chats +- channels with owner-only publishing +- message history persisted in SQLite +- file attachments with metadata persisted in SQLite +- voice notes as audio-classified attachments +- message edit/delete with realtime updates +- authenticated attachment download +- real-time message delivery with SignalR +- Android push registration API and FCM-based push delivery pipeline +- configurable server URL in the client +- publishable Windows `exe` +- publishable Android `apk` + +## Not Implemented + +This is not full Telegram parity. The following are not implemented in this MVP: + +- calls and video +- microphone recording UI for voice notes +- inline audio playback controls +- stickers and inline media previews +- end-to-end encryption +- moderation, advanced channel admin roles, multi-device sync edge cases +- Windows cloud push notifications + +## Demo Accounts + +- `alice / demo123` +- `bob / demo123` +- `carol / demo123` + +These are created automatically on first server start in `src/Massenger.Server/Data/massenger.db`. + +## Run + +1. Start the backend: + +```powershell +./scripts/run-server.ps1 +``` + +2. Run the Windows client from Visual Studio or CLI: + +```powershell +dotnet build .\src\Massenger.Client\Massenger.Client.csproj -f net10.0-windows10.0.19041.0 +.\src\Massenger.Client\bin\Debug\net10.0-windows10.0.19041.0\win-x64\Massenger.Client.exe +``` + +3. Run the Android client from Visual Studio or install the generated APK: + +```powershell +dotnet build .\src\Massenger.Client\Massenger.Client.csproj -f net10.0-android +``` + +The default server URL is: + +- Windows: `http://localhost:5099` +- Android emulator: `http://10.0.2.2:5099` + +On a physical Android device, set the server URL manually in the app to the PC LAN IP, for example `http://192.168.1.10:5099`. + +## Publish + +Windows EXE: + +```powershell +./scripts/publish-windows.ps1 +``` + +Android APK: + +```powershell +./scripts/publish-android-apk.ps1 +``` + +Smoke test against the running backend: + +```powershell +./scripts/smoke-test.ps1 +``` + +## Published Artifacts + +- Windows EXE: `src/Massenger.Client/bin/Release/net10.0-windows10.0.19041.0/win-x64/publish/Massenger.Client.exe` +- Android APK: `src/Massenger.Client/bin/Release/net10.0-android/publish/com.seven.massenger.apk` +- Signed Android APK: `src/Massenger.Client/bin/Release/net10.0-android/publish/com.seven.massenger-Signed.apk` + +## Solutions + +- XML solution: `Massenger.slnx` +- classic Visual Studio solution: `MassengerClassic.sln` + +## Auth Notes + +- Access tokens are short-lived JWTs. +- Refresh tokens are rotated on refresh and stored as hashed server-side sessions. +- Logout revokes the active session immediately, and the revoked access token stops working because the server validates the session id (`sid`) on authenticated requests. +- The JWT signing key in `src/Massenger.Server/appsettings.json` is a development default. Replace it with a strong secret via configuration before any real deployment. + +## Attachment Notes + +- Attachments are stored on the server under `src/Massenger.Server/Data/Attachments`. +- Metadata stored for each attachment: original file name, content type, file size, upload time, and owning message. +- Audio files are classified as `voice notes` by MIME type / file extension and use the same secure attachment pipeline. +- Attachment content is downloaded through an authenticated API route, not via a public static files directory. + +## Channel And Message Lifecycle Notes + +- Channels are broadcast rooms: the creator is the owner, selected users join as subscribers, and only owners can publish. +- Message edit/delete is synchronized through SignalR `MessageUpdated` events. +- Delete is implemented as a soft-delete on the message row with attachment cleanup on the server. + +## Push Setup Notes + +- Android push in this repo is implemented through Firebase Cloud Messaging HTTP v1 on the server and `FirebaseMessagingService` on the Android client. +- Server-side device registrations are stored in SQLite in the `PushDevices` table and managed through `api/push/devices`. +- To enable real Android delivery, set `Push:FirebaseProjectId` and either `Push:ServiceAccountJsonPath` or `Push:ServiceAccountJson` in `src/Massenger.Server/appsettings.json` or environment-specific configuration. +- Before building the Android APK, fill `src/Massenger.Client/Resources/Raw/firebase.android.json` with the Android Firebase values: `applicationId`, `projectId`, `apiKey`, `senderId`. +- If the Firebase configuration is left empty, the app still builds and the server still accepts device registrations, but actual FCM delivery is skipped. +- The current Windows desktop target remains unpackaged (`WindowsPackageType=None`), so this pass does not implement Windows cloud push. diff --git a/scripts/publish-android-apk.ps1 b/scripts/publish-android-apk.ps1 new file mode 100644 index 0000000..8b9cb60 --- /dev/null +++ b/scripts/publish-android-apk.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') +dotnet publish .\src\Massenger.Client\Massenger.Client.csproj -f net10.0-android -c Release -p:AndroidPackageFormat=apk diff --git a/scripts/publish-windows.ps1 b/scripts/publish-windows.ps1 new file mode 100644 index 0000000..a5efe00 --- /dev/null +++ b/scripts/publish-windows.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') +dotnet publish .\src\Massenger.Client\Massenger.Client.csproj -f net10.0-windows10.0.19041.0 -c Release diff --git a/scripts/run-server.ps1 b/scripts/run-server.ps1 new file mode 100644 index 0000000..69d8bd2 --- /dev/null +++ b/scripts/run-server.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') +dotnet run --project .\src\Massenger.Server\Massenger.Server.csproj diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 new file mode 100644 index 0000000..15c1f64 --- /dev/null +++ b/scripts/smoke-test.ps1 @@ -0,0 +1,188 @@ +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Net.Http + +$baseUrl = 'http://localhost:5099' +$stamp = Get-Date -Format 'yyyyMMddHHmmss' +$username = "smoke$stamp" +$password = 'Demo123!' + +$session = Invoke-RestMethod ` + -Uri "$baseUrl/api/auth/register" ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + username = $username + displayName = 'Smoke Test' + password = $password + } | ConvertTo-Json) + +$refreshed = Invoke-RestMethod ` + -Uri "$baseUrl/api/auth/refresh" ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + refreshToken = $session.refreshToken + } | ConvertTo-Json) + +$headers = @{ + Authorization = "Bearer $($refreshed.accessToken)" +} + +$installationId = "push-$stamp" +$pushDevice = Invoke-RestMethod ` + -Uri "$baseUrl/api/push/devices" ` + -Headers $headers ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + platform = 1 + installationId = $installationId + deviceToken = "token-$stamp" + deviceName = "Smoke Android $stamp" + notificationsEnabled = $true + } | ConvertTo-Json) + +$pushDevicesAfterRegister = Invoke-RestMethod ` + -Uri "$baseUrl/api/push/devices" ` + -Headers $headers ` + -Method Get +if ($null -eq $pushDevicesAfterRegister) { + $pushDevicesAfterRegister = @() +} +else { + $pushDevicesAfterRegister = @($pushDevicesAfterRegister) +} + +$bob = Invoke-RestMethod -Uri "$baseUrl/api/users/search?q=bob" -Headers $headers -Method Get | Select-Object -First 1 +$channel = Invoke-RestMethod ` + -Uri "$baseUrl/api/chats/channel" ` + -Headers $headers ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + title = "Smoke Channel $stamp" + memberIds = @($bob.id) + } | ConvertTo-Json) + +$message = Invoke-RestMethod ` + -Uri "$baseUrl/api/chats/$($channel.id)/messages" ` + -Headers $headers ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ text = 'channel smoke message' } | ConvertTo-Json) + +$edited = Invoke-RestMethod ` + -Uri "$baseUrl/api/chats/$($channel.id)/messages/$($message.id)" ` + -Headers $headers ` + -Method Put ` + -ContentType 'application/json' ` + -Body (@{ text = 'channel smoke message edited' } | ConvertTo-Json) + +$tempFile = Join-Path $env:TEMP "massenger-voice-$stamp.ogg" +"voice smoke test $stamp" | Set-Content -Path $tempFile -NoNewline + +$handler = New-Object System.Net.Http.HttpClientHandler +$client = New-Object System.Net.Http.HttpClient($handler) +$client.BaseAddress = [Uri]::new("$baseUrl/") +$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $refreshed.accessToken) +$form = New-Object System.Net.Http.MultipartFormDataContent +$form.Add((New-Object System.Net.Http.StringContent('voice caption')), 'text') +$fileStream = [System.IO.File]::OpenRead($tempFile) +$fileContent = New-Object System.Net.Http.StreamContent($fileStream) +$fileContent.Headers.ContentType = New-Object System.Net.Http.Headers.MediaTypeHeaderValue('audio/ogg') +$form.Add($fileContent, 'file', [System.IO.Path]::GetFileName($tempFile)) +$uploadResponse = $client.PostAsync("api/chats/$($channel.id)/attachments", $form).GetAwaiter().GetResult() +$uploadPayload = $uploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult() +$fileStream.Dispose() +$form.Dispose() +$client.Dispose() +$handler.Dispose() + +if (-not $uploadResponse.IsSuccessStatusCode) { + throw $uploadPayload +} + +$voiceMessage = $uploadPayload | ConvertFrom-Json +$attachment = $voiceMessage.attachments[0] +$downloadHandler = New-Object System.Net.Http.HttpClientHandler +$downloadClient = New-Object System.Net.Http.HttpClient($downloadHandler) +$downloadClient.BaseAddress = [Uri]::new("$baseUrl/") +$downloadClient.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $refreshed.accessToken) +$downloadResponse = $downloadClient.GetAsync($attachment.downloadPath.TrimStart('/')).GetAwaiter().GetResult() +$downloadBytes = $downloadResponse.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() +$downloadClient.Dispose() +$downloadHandler.Dispose() + +$deleted = Invoke-RestMethod ` + -Uri "$baseUrl/api/chats/$($channel.id)/messages/$($message.id)" ` + -Headers $headers ` + -Method Delete + +$bobSession = Invoke-RestMethod ` + -Uri "$baseUrl/api/auth/login" ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ + username = 'bob' + password = 'demo123' + } | ConvertTo-Json) + +$bobHeaders = @{ + Authorization = "Bearer $($bobSession.accessToken)" +} + +$subscriberBlocked = $false +try { + Invoke-RestMethod ` + -Uri "$baseUrl/api/chats/$($channel.id)/messages" ` + -Headers $bobHeaders ` + -Method Post ` + -ContentType 'application/json' ` + -Body (@{ text = 'subscriber should not publish' } | ConvertTo-Json) | Out-Null +} +catch { + $subscriberBlocked = $_.Exception.Response.StatusCode.value__ -eq 403 +} + +$channelForBob = Invoke-RestMethod -Uri "$baseUrl/api/chats/$($channel.id)" -Headers $bobHeaders -Method Get +$channelReload = Invoke-RestMethod -Uri "$baseUrl/api/chats/$($channel.id)" -Headers $headers -Method Get +Invoke-RestMethod -Uri "$baseUrl/api/push/devices/$installationId" -Headers $headers -Method Delete | Out-Null +$pushDevicesAfterDelete = Invoke-RestMethod ` + -Uri "$baseUrl/api/push/devices" ` + -Headers $headers ` + -Method Get +if ($null -eq $pushDevicesAfterDelete) { + $pushDevicesAfterDelete = @() +} +else { + $pushDevicesAfterDelete = @($pushDevicesAfterDelete) +} +Invoke-RestMethod -Uri "$baseUrl/api/auth/logout" -Headers $headers -Method Post | Out-Null + +$logoutBlocked = $false +try { + Invoke-RestMethod -Uri "$baseUrl/api/users/me" -Headers $headers -Method Get | Out-Null +} +catch { + $logoutBlocked = $_.Exception.Response.StatusCode.value__ -eq 401 +} + +[pscustomobject]@{ + Username = $username + ChannelId = $channel.id + EditedMessageId = $edited.id + EditedText = $edited.text + DeletedFlag = $null -ne $deleted.deletedAt + VoiceAttachmentId = $attachment.id + VoiceKind = $attachment.kind + VoiceKindOk = ($attachment.kind -eq 2) -or ($attachment.kind -eq 'VoiceNote') + DownloadOk = [int]$downloadResponse.StatusCode -eq 200 -and $downloadBytes.Length -eq $attachment.fileSizeBytes + PushRegistered = $pushDevice.installationId -eq $installationId + PushListed = $pushDevicesAfterRegister.Count -eq 1 -and $pushDevicesAfterRegister[0].installationId -eq $installationId + PushDeleted = $pushDevicesAfterDelete.Count -eq 0 + MessageCount = $channelReload.messages.Count + SubscriberBlocked = $subscriberBlocked + ChannelReadOnly = -not $channelForBob.canSendMessages + RefreshRotated = $session.refreshToken -ne $refreshed.refreshToken + LogoutBlocked = $logoutBlocked +} diff --git a/src/Massenger.Client/App.xaml b/src/Massenger.Client/App.xaml new file mode 100644 index 0000000..f86dfc9 --- /dev/null +++ b/src/Massenger.Client/App.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/src/Massenger.Client/App.xaml.cs b/src/Massenger.Client/App.xaml.cs new file mode 100644 index 0000000..0dd8604 --- /dev/null +++ b/src/Massenger.Client/App.xaml.cs @@ -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(); + _realtimeService = ServiceHelper.GetRequiredService(); + _pushRegistrationService = ServiceHelper.GetRequiredService(); + _notificationActivationService = ServiceHelper.GetRequiredService(); + _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}"); + }); + } +} diff --git a/src/Massenger.Client/AppShell.xaml b/src/Massenger.Client/AppShell.xaml new file mode 100644 index 0000000..16af77f --- /dev/null +++ b/src/Massenger.Client/AppShell.xaml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/src/Massenger.Client/AppShell.xaml.cs b/src/Massenger.Client/AppShell.xaml.cs new file mode 100644 index 0000000..20d40a4 --- /dev/null +++ b/src/Massenger.Client/AppShell.xaml.cs @@ -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)); + } +} diff --git a/src/Massenger.Client/Massenger.Client.csproj b/src/Massenger.Client/Massenger.Client.csproj new file mode 100644 index 0000000..776ab5d --- /dev/null +++ b/src/Massenger.Client/Massenger.Client.csproj @@ -0,0 +1,73 @@ + + + + net10.0-android + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + Exe + Massenger.Client + true + true + enable + enable + + + SourceGen + + + Massenger + + + com.seven.massenger + + + 1.0 + 1 + + + None + + 21.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Massenger.Client/MauiProgram.cs b/src/Massenger.Client/MauiProgram.cs new file mode 100644 index 0000000..734aa36 --- /dev/null +++ b/src/Massenger.Client/MauiProgram.cs @@ -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() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); + }); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); +#if ANDROID + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); +#else + builder.Services.AddSingleton(); +#endif + + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + +#if DEBUG + builder.Logging.AddDebug(); +#endif + + var app = builder.Build(); + ServiceHelper.Initialize(app.Services); + return app; + } +} diff --git a/src/Massenger.Client/Models/ChatAttachmentItem.cs b/src/Massenger.Client/Models/ChatAttachmentItem.cs new file mode 100644 index 0000000..808510f --- /dev/null +++ b/src/Massenger.Client/Models/ChatAttachmentItem.cs @@ -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"; + } +} diff --git a/src/Massenger.Client/Models/ChatMessageItem.cs b/src/Massenger.Client/Models/ChatMessageItem.cs new file mode 100644 index 0000000..84a93aa --- /dev/null +++ b/src/Massenger.Client/Models/ChatMessageItem.cs @@ -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 Attachments { get; } = message.Attachments + .Select(x => new ChatAttachmentItem(x)) + .ToList(); + + public bool HasAttachments => !IsDeleted && Attachments.Count > 0; + + public bool CanManage => IsMine && !IsDeleted; +} diff --git a/src/Massenger.Client/Models/ClientSession.cs b/src/Massenger.Client/Models/ClientSession.cs new file mode 100644 index 0000000..fa429d8 --- /dev/null +++ b/src/Massenger.Client/Models/ClientSession.cs @@ -0,0 +1,9 @@ +using Massenger.Shared; + +namespace Massenger.Client.Models; + +public sealed record ClientSession( + string AccessToken, + string RefreshToken, + DateTimeOffset AccessTokenExpiresAt, + UserSummaryDto User); diff --git a/src/Massenger.Client/Models/DiscoverUserItem.cs b/src/Massenger.Client/Models/DiscoverUserItem.cs new file mode 100644 index 0000000..f55a8ac --- /dev/null +++ b/src/Massenger.Client/Models/DiscoverUserItem.cs @@ -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"; +} diff --git a/src/Massenger.Client/Models/PendingAttachmentItem.cs b/src/Massenger.Client/Models/PendingAttachmentItem.cs new file mode 100644 index 0000000..c3a8bcf --- /dev/null +++ b/src/Massenger.Client/Models/PendingAttachmentItem.cs @@ -0,0 +1,58 @@ +using Microsoft.Maui.Storage; +using Massenger.Shared; + +namespace Massenger.Client.Models; + +public sealed class PendingAttachmentItem +{ + private static readonly HashSet 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; + } +} diff --git a/src/Massenger.Client/Pages/ChatPage.xaml b/src/Massenger.Client/Pages/ChatPage.xaml new file mode 100644 index 0000000..3a8d496 --- /dev/null +++ b/src/Massenger.Client/Pages/ChatPage.xaml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + +