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