From e18f5f5ad682f1f63a7f19955e69754b21abb616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Sat, 20 Jun 2026 19:52:33 +0300 Subject: [PATCH] Expand Ikar messaging and Android release support --- .dockerignore | 3 + .env.server.example | 7 + scripts/publish-android-update.ps1 | 349 +--- .../ARGUS_PUBLISHING_REQUIREMENTS.md | 403 +---- src/Ikar.AndroidKotlin/README.md | 4 + src/Ikar.AndroidKotlin/app/build.gradle.kts | 8 +- .../app/src/main/AndroidManifest.xml | 35 + .../com/seven/ikar/kotlin/MainActivity.kt | 74 + .../com/seven/ikar/kotlin/app/AppContainer.kt | 34 +- .../java/com/seven/ikar/kotlin/app/IkarApp.kt | 432 +++-- .../seven/ikar/kotlin/app/IkarNativeApp.kt | 10 + .../contacts/ResolvedContactsRepository.kt | 60 +- .../kotlin/core/local/CachedChatEntity.kt | 9 + .../ikar/kotlin/core/local/ChatListCache.kt | 20 + .../seven/ikar/kotlin/core/model/Models.kt | 103 +- .../navigation/IncomingLinkNavigationStore.kt | 42 + .../ikar/kotlin/core/network/ApiClient.kt | 204 ++- .../kotlin/core/push/AndroidPushSettings.kt | 15 +- .../push/ChatNotificationDismissalManager.kt | 72 + .../core/push/NotificationActionReceiver.kt | 15 +- .../push/PushNotificationDisplayService.kt | 134 +- .../ikar/kotlin/core/realtime/RealtimeDtos.kt | 8 +- .../kotlin/core/realtime/RealtimeService.kt | 40 +- .../core/settings/AndroidAppSettings.kt | 11 +- .../settings/AndroidPrivacyRuntimeSettings.kt | 52 + .../core/update/ArgusAppUpdateClient.kt | 12 +- .../core/update/ArgusAppUpdateInstaller.kt | 2 +- .../core/update/ArgusAppUpdateManager.kt | 44 +- .../core/update/ArgusAppUpdateModels.kt | 139 +- .../kotlin/feature/bots/BotEditorScreen.kt | 31 +- .../kotlin/feature/bots/BotEditorViewModel.kt | 80 +- .../ikar/kotlin/feature/bots/BotsScreen.kt | 9 +- .../feature/browser/InAppBrowserScreen.kt | 117 ++ .../chat/AndroidAttachmentFileService.kt | 149 +- .../chat/AndroidVoiceNotePlayerService.kt | 140 +- .../chat/AndroidVoiceNoteRecorderService.kt | 48 +- .../kotlin/feature/chat/ChatDraftStore.kt | 41 + .../ikar/kotlin/feature/chat/ChatScreen.kt | 1531 ++++++++++++++++- .../ikar/kotlin/feature/chat/ChatViewModel.kt | 400 ++++- .../kotlin/feature/chat/PickerMediaCatalog.kt | 194 +++ .../kotlin/feature/chatinfo/ChatInfoScreen.kt | 315 +++- .../feature/chatinfo/ChatInfoViewModel.kt | 211 ++- .../feature/chatinfo/ChatMaterialsScreen.kt | 175 ++ .../chatinfo/ChatMaterialsViewModel.kt | 97 ++ .../ikar/kotlin/feature/chats/ChatsScreen.kt | 278 ++- .../kotlin/feature/chats/ChatsViewModel.kt | 146 +- .../kotlin/feature/devices/DevicesScreen.kt | 61 +- .../feature/devices/DevicesViewModel.kt | 33 +- .../kotlin/feature/discover/DiscoverScreen.kt | 226 ++- .../feature/discover/DiscoverViewModel.kt | 97 +- .../kotlin/feature/forward/ForwardScreen.kt | 11 +- .../kotlin/feature/join/JoinInviteScreen.kt | 92 + .../feature/join/JoinInviteViewModel.kt | 72 + .../ikar/kotlin/feature/login/LoginScreen.kt | 43 +- .../kotlin/feature/login/LoginViewModel.kt | 41 +- .../feature/mainmenu/MainMenuViewModel.kt | 45 +- .../feature/members/ChatMembersScreen.kt | 33 +- .../feature/members/ChatMembersViewModel.kt | 97 +- .../feature/privacy/PrivacySecurityScreen.kt | 379 +++- .../privacy/PrivacySecurityViewModel.kt | 123 ++ .../feature/profile/AccountSettingsScreen.kt | 45 +- .../feature/profile/ProfileViewModel.kt | 57 +- .../feature/search/GlobalSearchScreen.kt | 58 +- .../feature/search/GlobalSearchViewModel.kt | 18 +- ...holdersScreen.kt => AppSettingsScreens.kt} | 115 +- .../feature/settings/ChatSettingsScreen.kt | 148 +- .../settings/DataStorageSettingsScreen.kt | 43 +- .../settings/NotificationSettingsScreen.kt | 114 +- .../settings/NotificationSettingsViewModel.kt | 29 +- .../kotlin/feature/settings/SettingsScreen.kt | 103 +- .../kotlin/feature/share/ShareTargetScreen.kt | 104 +- .../feature/share/ShareTargetViewModel.kt | 101 +- .../kotlin/feature/stories/StoriesScreen.kt | 493 +++++- .../feature/stories/StoriesViewModel.kt | 167 +- .../transfer/AttachmentUploadWorker.kt | 38 +- .../kotlin/feature/update/AppUpdateScreen.kt | 4 +- .../feature/update/AppUpdateViewModel.kt | 2 +- .../ikar/kotlin/ui/AuthenticatedAsyncImage.kt | 16 +- .../seven/ikar/kotlin/ui/ChatWallpapers.kt | 46 + .../com/seven/ikar/kotlin/ui/IkarChrome.kt | 45 +- .../scripts/prepare-argus-publication.ps1 | 2 +- .../scripts/publish-release.ps1 | 489 ++++-- .../AdminUserDeletionTests.cs | 180 ++ .../AuthEmailControllerTests.cs | 201 +++ .../BotApiControllerTests.cs | 790 +++++++++ .../BotCreatorServiceTests.cs | 106 ++ .../BotUpdateServiceTests.cs | 141 ++ src/Ikar.Server.Tests/BotsControllerTests.cs | 35 +- .../ChannelDtoMapperTests.cs | 129 ++ .../ChannelMembersControllerTests.cs | 1123 ++++++++++++ .../ControllerTestFixture.cs | 125 +- .../PrivacyAndStoriesControllerTests.cs | 255 +++ .../PushNotificationDispatcherTests.cs | 130 +- .../SearchControllerTests.cs | 156 +- .../StoryReplyControllerTests.cs | 105 ++ src/Ikar.Server/Controllers/AuthController.cs | 258 ++- .../Controllers/BotApiController.cs | 439 ++++- src/Ikar.Server/Controllers/BotsController.cs | 85 +- .../Controllers/ChatsController.cs | 1367 ++++++++++++++- .../Controllers/StoriesController.cs | 78 +- .../Controllers/UploadAttachmentRequest.cs | 20 + .../Controllers/UsersController.cs | 182 +- src/Ikar.Server/Data/Entities/Entities.cs | 13 + src/Ikar.Server/Data/IkarDbContext.cs | 7 + .../Infrastructure/Admin/AdminService.cs | 64 + .../Auth/EmailAddressNormalizer.cs | 48 + .../Auth/EmailAuthChallengeStore.cs | 76 + .../Infrastructure/Auth/EmailAuthOptions.cs | 18 + .../Infrastructure/Auth/IEmailCodeSender.cs | 10 + .../Auth/SmtpEmailCodeSender.cs | 46 + .../Infrastructure/Bots/BotCreatorService.cs | 490 ++++++ .../Infrastructure/Bots/BotDeletionService.cs | 131 ++ .../Infrastructure/Bots/BotUpdateService.cs | 68 +- .../ChannelDiscussionMessageFactory.cs | 94 + src/Ikar.Server/Infrastructure/DtoMapper.cs | 219 ++- .../Push/FirebasePushNotificationSender.cs | 14 +- .../Push/PushNotificationDispatcher.cs | 78 +- .../Storage/AttachmentStorageService.cs | 40 +- .../Storage/DatabaseSchemaBootstrapper.cs | 69 + .../Updates/AndroidAppUpdateMonitorService.cs | 5 +- .../Updates/AndroidAppUpdateService.cs | 14 +- src/Ikar.Server/Program.cs | 6 + src/Ikar.Shared/ChatContracts.cs | 38 +- src/Ikar.Shared/EmailAuthContracts.cs | 15 + src/Ikar.Shared/MessageContracts.cs | 14 +- src/Ikar.Shared/UserContracts.cs | 3 + 126 files changed, 15172 insertions(+), 2206 deletions(-) create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/navigation/IncomingLinkNavigationStore.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/ChatNotificationDismissalManager.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/browser/InAppBrowserScreen.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatDraftStore.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/PickerMediaCatalog.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatMaterialsScreen.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatMaterialsViewModel.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/join/JoinInviteScreen.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/join/JoinInviteViewModel.kt rename src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/{SettingsPlaceholdersScreen.kt => AppSettingsScreens.kt} (67%) create mode 100644 src/Ikar.Server.Tests/AdminUserDeletionTests.cs create mode 100644 src/Ikar.Server.Tests/AuthEmailControllerTests.cs create mode 100644 src/Ikar.Server.Tests/BotApiControllerTests.cs create mode 100644 src/Ikar.Server.Tests/BotCreatorServiceTests.cs create mode 100644 src/Ikar.Server.Tests/BotUpdateServiceTests.cs create mode 100644 src/Ikar.Server.Tests/StoryReplyControllerTests.cs create mode 100644 src/Ikar.Server/Infrastructure/Auth/EmailAddressNormalizer.cs create mode 100644 src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs create mode 100644 src/Ikar.Server/Infrastructure/Auth/EmailAuthOptions.cs create mode 100644 src/Ikar.Server/Infrastructure/Auth/IEmailCodeSender.cs create mode 100644 src/Ikar.Server/Infrastructure/Auth/SmtpEmailCodeSender.cs create mode 100644 src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs create mode 100644 src/Ikar.Server/Infrastructure/Bots/BotDeletionService.cs create mode 100644 src/Ikar.Server/Infrastructure/Channels/ChannelDiscussionMessageFactory.cs create mode 100644 src/Ikar.Shared/EmailAuthContracts.cs diff --git a/.dockerignore b/.dockerignore index a275c424..4d045ad1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,9 @@ .git .vs .codex-* +.env +.env.* +!.env.example **/bin **/obj docker-data diff --git a/.env.server.example b/.env.server.example index 33988daa..07791512 100644 --- a/.env.server.example +++ b/.env.server.example @@ -11,3 +11,10 @@ Push__ServiceAccountJsonPath= Push__ServiceAccountJson= Admin__Username= Admin__Password= +EmailAuth__SmtpHost=smtp.yandex.ru +EmailAuth__SmtpPort=587 +EmailAuth__EnableSsl=true +EmailAuth__Username=kusoft +EmailAuth__Password= +EmailAuth__FromAddress=kusoft@yandex.ru +EmailAuth__FromName=Ikar diff --git a/scripts/publish-android-update.ps1 b/scripts/publish-android-update.ps1 index 71f6eef3..b489eb30 100644 --- a/scripts/publish-android-update.ps1 +++ b/scripts/publish-android-update.ps1 @@ -4,295 +4,88 @@ param( [Parameter(Mandatory = $true)] [string]$Password, [string]$ArgusBaseUrl = 'https://argus.kusoft.xyz', - [string]$ArgusInternalBaseUrl = 'http://127.0.0.1:5105', - [string]$ArgusEnvPath = '/home/sevenhill/argus-deploy/.env.server', + [string]$ArgusInternalBaseUrl = 'unused', + [string]$ArgusEnvPath = 'unused', [string]$Slug = 'ikar-android', [string]$Notes = 'Android client update for Ikar', - [string]$AppName = 'Ikar Android', - [string]$Summary = 'Android client build', - [string]$Description = 'Android release channel for the Ikar client.', + [string]$AppName = 'Ikar', + [string]$Summary = 'Standalone Android client for Ikar on Kotlin and Jetpack Compose.', + [string]$Description = 'Standalone Android client on Kotlin and Jetpack Compose, prepared as a staged replacement for the current MAUI client.', [string]$HomepageUrl = 'https://ikar.kusoft.xyz', + [string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/Ikar.git', [string]$Platform = 'android', [string]$PackageKind = 'apk', - [string]$Channel = 'stable' + [string]$Channel = 'stable', + [switch]$Unlisted ) $ErrorActionPreference = 'Stop' -Set-Location (Join-Path $PSScriptRoot '..') +$repoRoot = Split-Path -Parent $PSScriptRoot +$androidRoot = Join-Path $repoRoot 'src\Ikar.AndroidKotlin' +$prepareScript = Join-Path $androidRoot 'scripts\prepare-argus-publication.ps1' +$publishScript = Join-Path $androidRoot 'scripts\publish-release.ps1' -$repoRoot = (Get-Location).Path -$projectPath = '.\src\Ikar.Client\Ikar.Client.csproj' - -[xml]$projectXml = Get-Content $projectPath -$versionName = $projectXml.Project.PropertyGroup.ApplicationDisplayVersion | Select-Object -First 1 -$versionCode = [int]($projectXml.Project.PropertyGroup.ApplicationVersion | Select-Object -First 1) -$publishDir = Join-Path $repoRoot (".codex-temp\android-update-publish\v{0}" -f $versionCode) - -if (Test-Path $publishDir) { - Remove-Item $publishDir -Recurse -Force +if (-not (Test-Path $prepareScript)) { + throw "Argus preparation script was not found: $prepareScript" +} +if (-not (Test-Path $publishScript)) { + throw "Argus SSH publication script was not found: $publishScript" } -dotnet publish $projectPath -f net10.0-android -c Release -p:AndroidPackageFormat=apk -p:UseSharedCompilation=false -o $publishDir +Push-Location $androidRoot +try { + $prepareArgs = @( + '-Slug', $Slug, + '-Name', $AppName, + '-Summary', $Summary, + '-Description', $Description, + '-Channel', $Channel, + '-Platform', $Platform, + '-Notes', $Notes, + '-RepositoryUrl', $RepositoryUrl, + '-HomepageUrl', $HomepageUrl + ) + if ($Unlisted) { + $prepareArgs += '-Unlisted' + } -$sourceApk = Join-Path $publishDir 'com.seven.ikar-Signed.apk' -if (-not (Test-Path $sourceApk)) { - throw "Signed APK not found: $sourceApk" + & $prepareScript @prepareArgs + if ($LASTEXITCODE -ne 0) { + throw "Argus package preparation failed with exit code $LASTEXITCODE." + } + + $metadataPath = Get-ChildItem -Path (Join-Path $androidRoot 'build\argus-publication') ` + -Recurse ` + -Filter 'argus-publication.json' | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if ($null -eq $metadataPath) { + throw 'Prepared Argus publication metadata was not found.' + } + + $metadata = Get-Content -LiteralPath $metadataPath.FullName -Raw | ConvertFrom-Json + + & $publishScript ` + -SshHost $ServerHost ` + -SshUsername $Username ` + -SshPassword $Password ` + -PublicBaseUrl $ArgusBaseUrl ` + -Slug $metadata.slug ` + -Name $metadata.name ` + -Summary $metadata.summary ` + -Description $metadata.description ` + -AndroidPackageName $metadata.androidPackageName ` + -Version $metadata.version ` + -AndroidVersionCode $metadata.androidVersionCode ` + -PackagePath $metadata.packagePath ` + -Channel $metadata.channel ` + -Platform $metadata.platform ` + -PackageKind $PackageKind ` + -Notes $metadata.notes ` + -RepositoryUrl $metadata.repositoryUrl ` + -HomepageUrl $metadata.homepageUrl ` + -Unlisted:([bool](-not $metadata.isListed)) +} finally { + Pop-Location } - -$remoteFileName = "ikar-android-$versionCode.apk" -$localCopy = Join-Path $publishDir $remoteFileName -Copy-Item $sourceApk $localCopy -Force - -$manifest = [ordered]@{ - versionCode = $versionCode - versionName = $versionName - packageFileName = $remoteFileName - publishedAt = [DateTimeOffset]::UtcNow.ToString('O') - notes = $Notes -} - -$manifestPath = Join-Path $publishDir 'latest.json' -$manifest | ConvertTo-Json -Depth 4 | Set-Content $manifestPath -Encoding UTF8 - -$argusPublishResult = @" -import json -import os -import paramiko -import posixpath - -host = os.environ['IKAR_UPDATE_HOST'] -username = os.environ['IKAR_UPDATE_USERNAME'] -password = os.environ['IKAR_UPDATE_PASSWORD'] -env_path = os.environ['IKAR_UPDATE_ARGUS_ENV_PATH'] -local_apk_path = os.environ['IKAR_UPDATE_APK_PATH'] -remote_apk_name = os.environ['IKAR_UPDATE_REMOTE_APK_NAME'] -argus_internal_base_url = os.environ['IKAR_UPDATE_ARGUS_INTERNAL_BASE_URL'] -slug = os.environ['IKAR_UPDATE_ARGUS_SLUG'] -app_name = os.environ['IKAR_UPDATE_ARGUS_NAME'] -summary = os.environ['IKAR_UPDATE_ARGUS_SUMMARY'] -description = os.environ['IKAR_UPDATE_ARGUS_DESCRIPTION'] -homepage_url = os.environ['IKAR_UPDATE_ARGUS_HOMEPAGE_URL'] -release_version = os.environ['IKAR_UPDATE_ARGUS_VERSION'] -channel = os.environ['IKAR_UPDATE_ARGUS_CHANNEL'] -platform = os.environ['IKAR_UPDATE_ARGUS_PLATFORM'] -package_kind = os.environ['IKAR_UPDATE_ARGUS_PACKAGE_KIND'] -notes = os.environ['IKAR_UPDATE_ARGUS_NOTES'] -temp_root = f"/home/{username}/.argus-upload" - -client = paramiko.SSHClient() -client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -client.connect(host, username=username, password=password, timeout=10) -sftp = client.open_sftp() - -with sftp.open(env_path, 'r') as handle: - content = handle.read().decode('utf-8', errors='replace') - -values = {} -for raw_line in content.splitlines(): - line = raw_line.strip() - if not line or line.startswith('#') or '=' not in line: - continue - key, value = line.split('=', 1) - values[key.strip()] = value.strip() - -username = values.get('Admin__Username') -password = values.get('Admin__Password') -if not username or not password: - raise RuntimeError(f"Admin credentials were not found in {env_path}.") - -parts = temp_root.strip('/').split('/') -current = '/' -for part in parts: - current = posixpath.join(current, part) - try: - sftp.stat(current) - except FileNotFoundError: - sftp.mkdir(current) - -remote_apk_path = posixpath.join(temp_root, remote_apk_name) -sftp.put(local_apk_path, remote_apk_path) -sftp.close() - -def run_remote_python(script: str) -> str: - stdin, stdout, stderr = client.exec_command("python3 -", timeout=600) - stdin.write(script) - stdin.channel.shutdown_write() - out = stdout.read().decode('utf-8', errors='replace') - err = stderr.read().decode('utf-8', errors='replace') - exit_status = stdout.channel.recv_exit_status() - if exit_status != 0: - raise RuntimeError(f"Remote publish failed with exit status {exit_status}\\nSTDOUT:\\n{out}\\nSTDERR:\\n{err}") - return out - -remote_payload = json.dumps({ - 'base_url': argus_internal_base_url.rstrip('/'), - 'slug': slug, - 'package_path': remote_apk_path, - 'admin_username': username, - 'admin_password': password, - 'app_name': app_name, - 'summary': summary, - 'description': description, - 'homepage_url': homepage_url, - 'release_version': release_version, - 'release_channel': channel, - 'release_platform': platform, - 'release_package_kind': package_kind, - 'release_notes': notes, -}) - -remote_script = ''' -import json -import os -import urllib.request -import uuid - -payload = json.loads(%s) -base_url = payload['base_url'] -slug = payload['slug'] -package_path = payload['package_path'] -admin_username = payload['admin_username'] -admin_password = payload['admin_password'] -app_name = payload['app_name'] -summary = payload['summary'] -description = payload['description'] -homepage_url = payload['homepage_url'] -release_version = payload['release_version'] -release_channel = payload['release_channel'] -release_platform = payload['release_platform'] -release_package_kind = payload['release_package_kind'] -release_notes = payload['release_notes'] - -def read_response(response): - body = response.read().decode('utf-8', errors='replace') - return response.getcode(), body - -opener = urllib.request.build_opener() - -login_payload = json.dumps({ - 'username': admin_username, - 'password': admin_password, -}).encode('utf-8') -login_request = urllib.request.Request( - f"{base_url}/api/admin/session/login", - data=login_payload, - headers={'Content-Type': 'application/json'}, - method='POST') -login_response = opener.open(login_request, timeout=600) -login_status, login_body = read_response(login_response) -if login_status < 200 or login_status >= 300: - raise RuntimeError(f"Login failed with status {login_status}: {login_body}") - -cookie_header = login_response.headers.get('Set-Cookie', '') -auth_cookie = cookie_header.split(';', 1)[0].strip() -if not auth_cookie: - raise RuntimeError('Argus login did not return an auth cookie.') - -metadata_payload = json.dumps({ - 'name': app_name, - 'summary': summary, - 'description': description, - 'homepageUrl': homepage_url or None, - 'repositoryUrl': None, - 'isListed': True, -}).encode('utf-8') -metadata_request = urllib.request.Request( - f"{base_url}/api/admin/apps/{slug}", - data=metadata_payload, - headers={ - 'Content-Type': 'application/json', - 'Cookie': auth_cookie, - }, - method='PUT') -metadata_response = opener.open(metadata_request, timeout=600) -metadata_status, metadata_body = read_response(metadata_response) -if metadata_status < 200 or metadata_status >= 300: - raise RuntimeError(f"Metadata update failed with status {metadata_status}: {metadata_body}") - -with open(package_path, 'rb') as handle: - file_bytes = handle.read() - -boundary = f"----IkarArgusBoundary{uuid.uuid4().hex}" -parts: list[bytes] = [] - -def add_field(name: str, value: str): - parts.append(f"--{boundary}\\r\\n".encode('utf-8')) - parts.append(f'Content-Disposition: form-data; name="{name}"\\r\\n\\r\\n'.encode('utf-8')) - parts.append(value.encode('utf-8')) - parts.append(b"\\r\\n") - -for field_name, field_value in [ - ('Version', release_version), - ('Channel', release_channel), - ('Platform', release_platform), - ('PackageKind', release_package_kind), -]: - add_field(field_name, field_value) - -if release_notes: - add_field('Notes', release_notes) - -file_name = os.path.basename(package_path) -parts.append(f"--{boundary}\\r\\n".encode('utf-8')) -parts.append( - f'Content-Disposition: form-data; name="Package"; filename="{file_name}"\\r\\n'.encode('utf-8')) -parts.append(b"Content-Type: application/vnd.android.package-archive\\r\\n\\r\\n") -parts.append(file_bytes) -parts.append(b"\\r\\n") -parts.append(f"--{boundary}--\\r\\n".encode('utf-8')) - -release_request = urllib.request.Request( - f"{base_url}/api/admin/apps/{slug}/releases", - data=b''.join(parts), - headers={ - 'Content-Type': f'multipart/form-data; boundary={boundary}', - 'Cookie': auth_cookie, - }, - method='POST') -release_response = opener.open(release_request, timeout=600) -release_status, release_body = read_response(release_response) -if release_status < 200 or release_status >= 300: - raise RuntimeError(f"Release upload failed with status {release_status}: {release_body}") - -print(release_body) -''' % json.dumps(remote_payload) - -publish_output = run_remote_python(remote_script) - -try: - sftp = client.open_sftp() - sftp.remove(remote_apk_path) - sftp.close() -except Exception: - pass - -client.close() -print(json.dumps({'status': 'UPLOAD_OK', 'release': publish_output.strip()})) -"@ | ForEach-Object { - $env:IKAR_UPDATE_HOST = $ServerHost - $env:IKAR_UPDATE_USERNAME = $Username - $env:IKAR_UPDATE_PASSWORD = $Password - $env:IKAR_UPDATE_ARGUS_ENV_PATH = $ArgusEnvPath - $env:IKAR_UPDATE_APK_PATH = (Resolve-Path $localCopy) - $env:IKAR_UPDATE_REMOTE_APK_NAME = $remoteFileName - $env:IKAR_UPDATE_ARGUS_INTERNAL_BASE_URL = $ArgusInternalBaseUrl - $env:IKAR_UPDATE_ARGUS_SLUG = $Slug - $env:IKAR_UPDATE_ARGUS_NAME = $AppName - $env:IKAR_UPDATE_ARGUS_SUMMARY = $Summary - $env:IKAR_UPDATE_ARGUS_DESCRIPTION = $Description - $env:IKAR_UPDATE_ARGUS_HOMEPAGE_URL = $HomepageUrl - $env:IKAR_UPDATE_ARGUS_VERSION = "$versionName+$versionCode" - $env:IKAR_UPDATE_ARGUS_CHANNEL = $Channel - $env:IKAR_UPDATE_ARGUS_PLATFORM = $Platform - $env:IKAR_UPDATE_ARGUS_PACKAGE_KIND = $PackageKind - $env:IKAR_UPDATE_ARGUS_NOTES = $Notes - $_ -} | python - - -$publishResult = $argusPublishResult | ConvertFrom-Json -if ($publishResult.status -ne 'UPLOAD_OK') { - throw "Argus publish failed." -} - -Write-Output $publishResult.status diff --git a/src/Ikar.AndroidKotlin/ARGUS_PUBLISHING_REQUIREMENTS.md b/src/Ikar.AndroidKotlin/ARGUS_PUBLISHING_REQUIREMENTS.md index 21d4b442..1cfd4dc6 100644 --- a/src/Ikar.AndroidKotlin/ARGUS_PUBLISHING_REQUIREMENTS.md +++ b/src/Ikar.AndroidKotlin/ARGUS_PUBLISHING_REQUIREMENTS.md @@ -1,372 +1,65 @@ -# Требования к публикации приложений в Argus +# Argus publication requirements for Ikar Android -Этот документ фиксирует текущие требования публикации на основе фактического кода `Argus` и Android-клиента `Argus Store` в этом репозитории. Он разделяет: +This repository follows the shared Argus publication instruction: -1. жёсткие требования, которые реально проверяет сервер; -2. дополнительные требования, без которых приложение не будет нормально устанавливаться через `Argus Store`; -3. условия, при которых `Argus Store` сможет предлагать автообновления. +`G:\Repos\Marketplace\ARGUS_PUBLICATION.md` -Если цель публикации именно в Android-клиент `Argus Store`, нужно одновременно соблюдать и серверные требования, и Android-специфичные требования из этого документа. +That file is the source of truth for publishing any new Ikar Android version to Argus. -Источники: `scripts/publish-release.ps1:2-139`, `Program.cs:179-239`, `Program.cs:335-661`, `Contracts/ArgusDtos.cs:3-96`, `Infrastructure/SlugUtility.cs:7-39`, `Infrastructure/IdentifierUtility.cs:7-38`, `Infrastructure/SemanticVersionUtility.cs:5-240`, `Infrastructure/PackageStorageService.cs:8-97`, `android/app/src/main/java/xyz/kusoft/argusstore/StoreModels.kt:12-110`, `android/app/src/main/java/xyz/kusoft/argusstore/StoreCatalogLoader.kt:7-34`, `android/app/src/main/java/xyz/kusoft/argusstore/PackageInstallerManager.kt:15-128`, `android/app/src/main/java/xyz/kusoft/argusstore/PackageStateResolver.kt:11-252`, `README.md:13-18`, `README.md:74-113`. +## Current rules -## 1. Что ИИ обязан подготовить перед публикацией +- Argus is a read-only public catalog for clients. +- Do not use an HTTP admin API, upload endpoint, token, or browser form for publication. +- Publish only through SSH to the Argus server. +- Store release files under `/srv/argus-data/Packages`. +- Store release metadata in SQLite database `/srv/argus-data/argus.db`. +- Update clients must call only the public manifest endpoint: + `GET https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}`. -Перед публикацией ИИ должен выдать: +## Ikar Android values -- путь к одному готовому release-файлу; -- `slug`; -- `name`; -- `summary`; -- `description`; -- `version`; -- `channel`; -- `platform`; -- `packageKind`; -- `notes` при наличии; -- `repositoryUrl` при наличии; -- `homepageUrl` при наличии; -- `androidPackageName` для Android-релизов; -- `androidVersionCode` для Android-релизов; -- решение, будет ли приложение публичным (`isListed=true`) или скрытым (`isListed=false`). +- `slug`: `ikar-android` +- `channel`: `stable` +- `platform`: `android` +- `packageKind`: `apk` +- public base URL: `https://argus.kusoft.xyz` -Причина: именно эти поля использует текущий publish-скрипт и серверные DTO. -Источник: `scripts/publish-release.ps1:2-46`, `Contracts/ArgusDtos.cs:62-94`. +## Local workflow -## 2. Жёсткие серверные требования +1. Build and stage a signed APK: -### 2.1. Требования к `slug` + ```powershell + .\scripts\prepare-argus-publication.ps1 + ``` -- `slug` обязателен. -- Разрешены только строчные латинские буквы, цифры и дефисы. -- Максимальная длина `slug` — `100` символов. -- Значение должно уже быть нормализовано; сервер не принимает произвольный mixed-case `slug`. +2. Publish the staged APK through SSH/SQLite: -Фактическое правило валидации: + ```powershell + .\scripts\publish-release.ps1 ` + -SshPassword "" ` + -Slug "ikar-android" ` + -Name "Ikar" ` + -Summary "" ` + -Description "" ` + -Version "" ` + -PackagePath "" + ``` -- `SlugUtility.Normalize(...)` переводит строку в lowercase, заменяет любые неалфавитно-цифровые символы на `-`, схлопывает повторные дефисы и обрезает дефисы по краям. -- `SlugUtility.IsValid(...)` принимает только строки, которые уже совпадают со своим нормализованным вариантом и имеют длину до `100`. +3. Verify the public manifest: -Источник: `Infrastructure/SlugUtility.cs:7-39`, `Program.cs:341-345`, `Program.cs:393-397`. + ```text + https://argus.kusoft.xyz/api/apps/ikar-android/manifest?platform=android&channel=stable + ``` -### 2.2. Обязательные поля метаданных приложения +## Client update behavior -Сервер отклоняет публикацию метаданных, если отсутствует хотя бы одно из полей: +The Ikar Android updater must: -- `name` -- `summary` -- `description` - -Пустые строки и строки из одних пробелов считаются отсутствующими. - -Источник: `Program.cs:372-379`, `Program.cs:582-607`. - -### 2.3. `androidPackageName` - -- Для не-Android публикаций поле опционально. -- Для Android-публикаций поле обязательно на уровне карточки приложения до загрузки релиза. -- Формат должен быть похож на Java package name. - -Фактические правила проверки: - -- минимум 2 сегмента, разделённых точками; -- строка не может начинаться или заканчиваться точкой; -- каждый сегмент должен начинаться с буквы или `_`; -- в сегментах допустимы только буквы, цифры и `_`. - -Примеры подходящего формата: - -- `com.example.app` -- `xyz.kusoft.aletheia` - -Примеры, которые сервер отвергнет: - -- `App` -- `.com.example` -- `com.example.` -- `com.example.my-app` - -Источник: `Program.cs:423-429`, `Program.cs:601-605`, `Program.cs:639-656`. - -### 2.4. Обязательные поля релиза - -Сервер отклоняет загрузку релиза, если отсутствует хотя бы одно из полей: - -- `version` -- `package` (сам файл) - -Пустой файл тоже отклоняется. - -Источник: `Program.cs:401-405`, `Program.cs:616-624`. - -### 2.5. Когда `androidVersionCode` обязателен - -`androidVersionCode` обязателен, если сервер считает релиз Android-артефактом. Сейчас это происходит, когда выполняется хотя бы одно условие: - -- `platform` содержит подстроку `android`, или -- `packageKind` равен `apk`, `xapk` или `apks`. - -Дополнительные правила: - -- если `androidVersionCode` передан, он должен быть положительным целым числом; -- для Android-артефактов `androidVersionCode` не может быть `null`. - -Источник: `Program.cs:418-420`, `Program.cs:423-429`, `Program.cs:613-633`, `Program.cs:659-661`. - -### 2.6. Ограничение на дубликаты релизов - -Сервер не разрешает второй релиз с тем же набором: - -- `slug` -- `version` -- `channel` -- `platform` - -`packageKind` в этом конфликте не участвует. Это означает, что нельзя загрузить два разных файла с одинаковыми `version + channel + platform` для одного и того же приложения. - -Если нужны разные ABI-сборки одной версии, они должны отличаться именно значением `platform`. - -Источник: `Program.cs:432-444`. - -### 2.7. Публичность приложения - -- В publish-скрипте приложение по умолчанию публикуется как публичное (`isListed=true`). -- Если использовать ключ `-Unlisted`, сервер сохранит `isListed=false`. -- Публичные API `/api/apps`, `/api/apps/{slug}` и `/api/apps/{slug}/manifest` возвращают только `IsListed` приложения. - -Практический вывод: если приложение должно появиться в `Argus Store`, его нельзя публиковать как `Unlisted`. - -Источник: `scripts/publish-release.ps1:30`, `scripts/publish-release.ps1:59-67`, `Program.cs:179-239`, `Program.cs:378`. - -## 3. Что нужно именно для Android-клиента Argus Store - -Ниже не только серверные требования, но и дополнительные условия текущего Android-клиента. Без них релиз может загрузиться на сервер, но приложение либо не появится в клиенте, либо будет помечено как `Unsupported`, либо не сможет установиться. - -Источник: `android/app/src/main/java/xyz/kusoft/argusstore/StoreCatalogLoader.kt:7-34`, `StoreModels.kt:12-110`, `PackageInstallerManager.kt:15-128`, `PackageStateResolver.kt:11-129`. - -### 3.1. Для установки через Argus Store нужен именно `apk` - -Текущий Android-клиент считает установимыми только релизы, у которых: - -- `packageKind == "apk"` - -`xapk` и `apks` сейчас сервер распознаёт как Android-артефакты для валидации, но Android-клиент их не выбирает как installable release. - -Практическое требование: если приложение должно устанавливаться через `Argus Store`, публикуй обычный `.apk` и ставь `packageKind=apk`. - -Источник: `Program.cs:659-661`, `android/app/src/main/java/xyz/kusoft/argusstore/StoreModels.kt:27-29`, `StoreModels.kt:72-82`. - -### 3.2. Для Android-клиента `androidPackageName` должен быть заполнен - -Если у карточки приложения отсутствует `androidPackageName`, клиент помечает приложение как неподдерживаемое для установки. - -Источник: `android/app/src/main/java/xyz/kusoft/argusstore/PackageStateResolver.kt:12-21`. - -### 3.3. `androidVersionCode` релиза должен совпадать с APK - -Перед отправкой APK в `PackageInstaller` клиент читает скачанный APK и сверяет: - -- `archiveInfo.packageName == androidPackageName` -- `archive versionCode == androidVersionCode` - -Если хотя бы одна проверка не проходит, установка отклоняется. - -Практическое требование: публикуемый `androidPackageName` должен точно совпадать с `applicationId`/package name внутри APK, а `androidVersionCode` — точно с `versionCode` внутри APK. - -Источник: `android/app/src/main/java/xyz/kusoft/argusstore/PackageInstallerManager.kt:24-55`. - -### 3.4. Требования к `platform` для подбора совместимого APK - -Клиент выбирает релиз как совместимый, если: - -- `packageKind == "apk"`, и -- `platform` соответствует одному из распознаваемых вариантов. - -Релиз считается универсальным, если `platform` равен: - -- `android` -- `android-universal` -- `android-generic` -- `generic` - -Для ABI-специфичных APK клиент ищет в `platform` одну из подстрок, связанных с ABI устройства: - -- `arm64` -- `arm64-v8a` -- `android-arm64` -- `android-arm64-v8a` -- `armv7` -- `armeabi-v7a` -- `android-armv7` -- `android-armeabi-v7a` -- `x86_64` -- `android-x86_64` -- `x86` -- `android-x86` - -Рекомендация для предсказуемости публикации: - -- universal APK: использовать `platform=android-universal`; -- ARM64 APK: использовать `platform=android-arm64`; -- ARMv7 APK: использовать `platform=android-armv7`; -- x86_64 APK: использовать `platform=android-x86_64`; -- x86 APK: использовать `platform=android-x86`. - -Первые два блока выше — фактическое поведение клиента. Последний блок — рекомендация для единообразия, а не серверная проверка. - -Источник: `android/app/src/main/java/xyz/kusoft/argusstore/StoreModels.kt:72-110`. - -### 3.5. Как клиент выбирает предпочитаемый APK - -Среди совместимых installable APK клиент выбирает релиз по такому порядку: - -1. больший `androidVersionCode`; -2. более поздний `publishedAt`; -3. большее строковое `version`. - -Практический вывод: для Android-обновлений нельзя полагаться только на красивый `version`; нужно монотонно увеличивать `androidVersionCode`. - -Источник: `android/app/src/main/java/xyz/kusoft/argusstore/StoreModels.kt:72-82`, `PackageStateResolver.kt:44-59`. - -## 4. Что нужно для корректной работы обновлений - -### 4.1. Для обычного обнаружения обновлений - -Чтобы клиент видел, что доступно обновление, нужны одновременно: - -- установленный пакет с тем же `androidPackageName`; -- опубликованный совместимый APK; -- у релиза должен быть заполнен `androidVersionCode`; -- новый `androidVersionCode` должен быть больше установленного на устройстве. - -Источник: `android/app/src/main/java/xyz/kusoft/argusstore/PackageStateResolver.kt:33-59`. - -### 4.2. Для best-effort автообновлений без ручного подтверждения - -Текущий клиент считает автообновление потенциально возможным только если одновременно выполняются все условия: - -- приложение уже установлено; -- опубликованный APK имеет `androidVersionCode`; -- в настройках Android разрешены установки из `Argus Store`; -- устройство работает минимум на Android 12 / API 31; -- установленное приложение было установлено именно `Argus Store`; -- `targetSdkVersion` установленного приложения не ниже минимального значения для текущего API устройства. - -Минимальные `targetSdkVersion`, которые сейчас проверяет клиент: - -- API 31-32: `29` -- API 33: `30` -- API 34: `31` -- API 35: `33` -- API 36 и выше: `34` - -Важно: - -- эти условия не гарантируют полностью бесшумное обновление на любом устройстве; -- это только текущая эвристика клиента, при которой он считает no-touch update допустимым к попытке. - -Источник: `android/app/src/main/java/xyz/kusoft/argusstore/PackageStateResolver.kt:76-129`, `PackageStateResolver.kt:244-252`. - -## 5. Рекомендации по версии и артефакту - -### 5.1. `version` - -Сервер выбирает `latest` релиз по semantic version precedence, если обе сравниваемые версии парсятся как semver. Если хотя бы одна версия не парсится, сравнение откатывается к обычному case-insensitive строковому сравнению. - -Практическая рекомендация: использовать semver-совместимые версии: - -- `1.0.0` -- `1.2.3` -- `1.2.3-beta.1` -- `v1.2.3` - -Это рекомендация для предсказуемого выбора `latest`, а не отдельная серверная валидация. - -Источник: `Infrastructure/SemanticVersionUtility.cs:5-240`, `Program.cs:563-567`, `README.md:20`. - -### 5.2. Расширение файла - -Сервер сохраняет любой непустой загруженный файл и вычисляет для него: - -- `SHA-256` -- размер в байтах -- исходное имя файла -- content type - -Но текущий publish-скрипт автоматически выставляет специальный MIME type только для файлов с расширением `.apk`. Для остальных расширений он ставит `application/octet-stream`. - -Практическое требование для Android-публикаций: использовать реальный `.apk` файл. - -Источник: `Infrastructure/PackageStorageService.cs:8-97`, `scripts/publish-release.ps1:111-121`. - -## 6. Шаблон brief'а для другого ИИ - -Ниже шаблон, который можно отдавать ИИ как входное задание. Он построен только из полей и ограничений, которые реально используются текущим `Argus`. - -```yaml -argusPublication: - target: "Argus" - mustBeVisibleInArgusStore: true - - app: - slug: "" - name: "" - summary: "" - description: "" - repositoryUrl: "" - homepageUrl: "" - isListed: true - - android: - androidPackageName: "" - versionName: "" - versionCode: - targetSdkVersion: - abiKind: "" - - release: - version: "" - channel: "stable" - platform: "" - packageKind: "apk" - notes: "" - packagePath: "" - - mandatoryChecks: - - "APK exists and is non-empty." - - "APK package name exactly matches androidPackageName." - - "APK versionCode exactly matches androidVersionCode." - - "If the app should appear in Argus Store, isListed must be true." - - "No existing Argus release uses the same slug + version + channel + platform." - - "If this is an update, androidVersionCode is greater than the already published Android version code." -``` - -## 7. Минимальная команда публикации - -Текущий стандартный путь публикации в этом репозитории — `scripts/publish-release.ps1`, который: - -1. логинится в admin API; -2. делает `PUT /api/admin/apps/{slug}`; -3. делает `POST /api/admin/apps/{slug}/releases`. - -Минимальный пример для Android APK: - -```powershell -.\scripts\publish-release.ps1 ` - -BaseUrl "https://argus.kusoft.xyz" ` - -Username "" ` - -Password "" ` - -Slug "" ` - -Name "" ` - -Summary "" ` - -Description "" ` - -AndroidPackageName "" ` - -Version "" ` - -Channel "stable" ` - -Platform "android-universal" ` - -PackageKind "apk" ` - -AndroidVersionCode ` - -PackagePath "" -``` - -Источник: `scripts/publish-release.ps1:53-130`, `README.md:74-90`. +- treat HTTP 404 from the manifest endpoint as "no compatible release"; +- compare the installed app version to `release.version`; +- build the download URL as `https://argus.kusoft.xyz` plus `release.downloadPath` unless `downloadPath` is already absolute; +- download the APK to a temporary/cache file; +- verify the downloaded file with `release.sha256`; +- delete and reject the download on SHA-256 mismatch; +- submit the APK to the Android package installer; +- record the new installed version only after Android reports a successful install. diff --git a/src/Ikar.AndroidKotlin/README.md b/src/Ikar.AndroidKotlin/README.md index b999d7cb..fd7a9436 100644 --- a/src/Ikar.AndroidKotlin/README.md +++ b/src/Ikar.AndroidKotlin/README.md @@ -24,10 +24,14 @@ - Локальная release-подпись хранится в gitignored файлах `argus-signing.local.properties` и `signing/`. - Единая координата публикации для клиента Икар: slug `ikar-android`, channel `stable`, platform `android`. Именно этот manifest запрашивают текущий Android-клиент и серверный proxy обновлений. +- Публикация новых версий в Argus выполняется только по инструкции `G:\Repos\Marketplace\ARGUS_PUBLICATION.md`. + У Argus больше нет HTTP admin API/upload endpoint для публикации: файл релиза передается на Pi по SSH, кладется в `/srv/argus-data/Packages`, а запись релиза добавляется в `/srv/argus-data/argus.db`. - Первый запуск локального signing pipeline: - `.\scripts\new-argus-upload-keystore.ps1` - Подготовка signed APK и metadata JSON для Argus: - `.\scripts\prepare-argus-publication.ps1` +- Публикация уже подготовленного APK: + - `.\scripts\publish-release.ps1 -SshPassword "" -Slug "ikar-android" -Name "Ikar" -Summary "" -Description "" -Version "" -PackagePath ""` Скрипт подготовки собирает `release` APK, проверяет подпись и извлекает из самого APK: - `androidPackageName` diff --git a/src/Ikar.AndroidKotlin/app/build.gradle.kts b/src/Ikar.AndroidKotlin/app/build.gradle.kts index 577622dc..427668f5 100644 --- a/src/Ikar.AndroidKotlin/app/build.gradle.kts +++ b/src/Ikar.AndroidKotlin/app/build.gradle.kts @@ -37,8 +37,8 @@ android { applicationId = "com.seven.ikar.kotlin" minSdk = 24 targetSdk = 36 - versionCode = 180 - versionName = "3.67" + versionCode = 209 + versionName = "3.96" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -88,6 +88,7 @@ android { dependencies { val composeBom = platform("androidx.compose:compose-bom:2026.03.00") val roomVersion = "2.8.4" + val media3Version = "1.10.1" implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4") @@ -106,9 +107,12 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") implementation("io.coil-kt:coil-compose:2.7.0") + implementation("io.coil-kt:coil-gif:2.7.0") implementation("com.microsoft.signalr:signalr:8.0.17") implementation("com.google.firebase:firebase-messaging:25.0.1") implementation("io.github.webrtc-sdk:android:144.7559.01") + implementation("androidx.media3:media3-exoplayer:$media3Version") + implementation("androidx.media3:media3-ui:$media3Version") implementation(composeBom) androidTestImplementation(composeBom) diff --git a/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml b/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml index 56d41882..5ef3fc1d 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml +++ b/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml @@ -42,6 +42,41 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/MainActivity.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/MainActivity.kt index 59ae9bce..3dcd2052 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/MainActivity.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/MainActivity.kt @@ -17,7 +17,9 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import com.seven.ikar.kotlin.app.IkarApp import com.seven.ikar.kotlin.app.IkarNativeApp +import com.seven.ikar.kotlin.core.push.AndroidActiveChatState import com.seven.ikar.kotlin.core.push.AndroidAppVisibilityState +import com.seven.ikar.kotlin.core.push.ChatNotificationDismissalManager import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupHelper import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupResult import com.seven.ikar.kotlin.core.push.InitialCallPermissionPromptStore @@ -25,6 +27,7 @@ import com.seven.ikar.kotlin.core.push.endedCallIdFromIntent import com.seven.ikar.kotlin.core.push.pendingIncomingCallInviteFromIntent import com.seven.ikar.kotlin.core.push.PushDataKeys import com.seven.ikar.kotlin.core.push.PushDataKinds +import com.seven.ikar.kotlin.core.network.ServerConfig import com.seven.ikar.kotlin.feature.share.PendingExternalShare import com.seven.ikar.kotlin.ui.SecureWindowFlagController import kotlinx.coroutines.launch @@ -95,6 +98,10 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() AndroidAppVisibilityState.setVisible(true) + lifecycleScope.launch { + runCatching { applicationContainer.pushRegistrationManager.registerCurrentDeviceAsync() } + } + ChatNotificationDismissalManager.cancelChatNotifications(this, AndroidActiveChatState.getActiveChatId()) enforceAppLockIfNeeded() } @@ -118,6 +125,10 @@ class MainActivity : ComponentActivity() { } applicationContainer.appLockState.setLocked(true) + if (applicationContainer.privacyRuntimeSettings.hasPasscode()) { + return + } + SecureWindowFlagController.setSecure(window, AppLockSecureToken, true) val keyguardManager = getSystemService(KeyguardManager::class.java) val unlockIntent = if (keyguardManager.isDeviceSecure) { @@ -176,6 +187,10 @@ class MainActivity : ComponentActivity() { private fun handleIncomingIntent(intent: Intent?) { val incomingIntent = intent ?: return if (incomingIntent.action == Intent.ACTION_VIEW) { + if (handleIkarDeepLink(incomingIntent.data)) { + return + } + val archiveUri = incomingIntent.data ?: incomingIntent.readSingleStream() if (archiveUri != null) { lifecycleScope.launch { @@ -233,6 +248,65 @@ class MainActivity : ComponentActivity() { } } + private fun handleIkarDeepLink(uri: Uri?): Boolean { + val link = uri ?: return false + val scheme = link.scheme?.lowercase() + val host = link.host?.lowercase() + val messengerHost = Uri.parse(ServerConfig.MessengerBaseUrl).host?.lowercase() + val segments = link.pathSegments.orEmpty() + + if (scheme in setOf("http", "https") && host == messengerHost) { + when (segments.firstOrNull()) { + "join" -> { + applicationContainer.incomingLinkNavigationStore.setPendingInvite(segments.getOrNull(1)) + return true + } + + "chat" -> { + applicationContainer.incomingPushNavigationStore.setPendingChat( + chatId = segments.getOrNull(1), + messageId = link.getQueryParameter("messageId") + ) + return true + } + + "channel" -> { + applicationContainer.incomingLinkNavigationStore.setPendingPublicChannel(segments.getOrNull(1)) + return true + } + } + + segments.firstOrNull()?.takeIf { it.startsWith("@") }?.let { usernameSegment -> + applicationContainer.incomingLinkNavigationStore.setPendingPublicChannel(usernameSegment) + return true + } + } + + if (scheme == "ikar") { + when (host) { + "join" -> { + applicationContainer.incomingLinkNavigationStore.setPendingInvite(segments.firstOrNull()) + return true + } + + "chat" -> { + applicationContainer.incomingPushNavigationStore.setPendingChat( + chatId = segments.firstOrNull(), + messageId = link.getQueryParameter("messageId") + ) + return true + } + + "channel" -> { + applicationContainer.incomingLinkNavigationStore.setPendingPublicChannel(segments.firstOrNull()) + return true + } + } + } + + return false + } + private suspend fun extractPendingShare(intent: Intent): PendingExternalShare? { val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim()?.takeIf { it.isNotBlank() } val attachmentUris = when (intent.action) { diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt index cf20a763..21d789c1 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt @@ -8,6 +8,7 @@ import com.seven.ikar.kotlin.core.local.ChatListCache import com.seven.ikar.kotlin.core.local.ChatMessageCache import com.seven.ikar.kotlin.core.local.IkarLocalDatabase import com.seven.ikar.kotlin.core.model.AuthSessionDto +import com.seven.ikar.kotlin.core.navigation.IncomingLinkNavigationStore import com.seven.ikar.kotlin.core.network.ApiClient import com.seven.ikar.kotlin.core.network.ClientVersionProvider import com.seven.ikar.kotlin.core.push.AndroidPushSettings @@ -23,6 +24,7 @@ import com.seven.ikar.kotlin.core.settings.AppProxyType import com.seven.ikar.kotlin.core.settings.AppSettingsSnapshot import com.seven.ikar.kotlin.core.session.AuthorizedSessionManager import com.seven.ikar.kotlin.core.session.SessionStore +import com.seven.ikar.kotlin.core.update.ArgusAppUpdateClient import com.seven.ikar.kotlin.core.update.ArgusAppUpdateManager import com.seven.ikar.kotlin.feature.archive.AndroidArchiveFileService import com.seven.ikar.kotlin.feature.archive.IncomingArchiveStore @@ -32,6 +34,7 @@ import com.seven.ikar.kotlin.feature.call.CallManager import com.seven.ikar.kotlin.feature.chat.AndroidAttachmentFileService import com.seven.ikar.kotlin.feature.chat.AndroidVoiceNotePlayerService import com.seven.ikar.kotlin.feature.chat.AndroidVoiceNoteRecorderService +import com.seven.ikar.kotlin.feature.chat.ChatDraftStore import com.seven.ikar.kotlin.feature.chat.ChatScrollStateStore import com.seven.ikar.kotlin.feature.share.IncomingShareStore import com.seven.ikar.kotlin.feature.transfer.TransferQueueRepository @@ -74,6 +77,8 @@ class AppContainer(context: Context) { @Volatile private var activeHttpClient: OkHttpClient = buildHttpClient(appSettings.loadSnapshot()) + @Volatile + private var activeProxySignature: String = proxySignature(appSettings.loadSnapshot()) val httpClient: OkHttpClient get() = activeHttpClient @@ -89,7 +94,10 @@ class AppContainer(context: Context) { dao = localDatabase.cachedChatDao(), json = json ) - val appUpdateManager = ArgusAppUpdateManager(appContext) + val appUpdateManager = ArgusAppUpdateManager( + context = appContext, + client = ArgusAppUpdateClient(proxyProvider = { buildProxy(appSettings.loadSnapshot()) }) + ) val apiClient = ApiClient( httpClientProvider = { httpClient }, json = json, @@ -114,13 +122,20 @@ class AppContainer(context: Context) { scheduler = transferWorkScheduler ) val chatScrollStateStore = ChatScrollStateStore(appContext, json) + val chatDraftStore = ChatDraftStore(appContext) val incomingShareStore = IncomingShareStore() val incomingArchiveStore = IncomingArchiveStore() val incomingPushNavigationStore = IncomingPushNavigationStore() + val incomingLinkNavigationStore = IncomingLinkNavigationStore() val incomingCallInviteStore = IncomingCallInviteStore() - val voiceNotePlayerService = AndroidVoiceNotePlayerService() + val voiceNotePlayerService = AndroidVoiceNotePlayerService(appContext) val voiceNoteRecorderService = AndroidVoiceNoteRecorderService(appContext) - val realtimeService = RealtimeService(clientVersionProvider) + val realtimeService = RealtimeService( + clientVersionProvider = clientVersionProvider, + configureHttpClientBuilder = { builder -> + buildProxy(appSettings.loadSnapshot())?.let(builder::proxy) + } + ) val webRtcCallService = AndroidWebRtcCallService(appContext) val callTonePlayer = AndroidCallTonePlayer() val callManager = CallManager( @@ -141,7 +156,12 @@ class AppContainer(context: Context) { init { applicationScope.launch { appSettings.snapshot.collect { snapshot -> + val nextProxySignature = proxySignature(snapshot) activeHttpClient = buildHttpClient(snapshot) + if (nextProxySignature != activeProxySignature) { + activeProxySignature = nextProxySignature + realtimeService.reconnectForNetworkSettings() + } runCatching { attachmentFileService.enforceCachePolicy( maxCacheMb = snapshot.maxCacheMb, @@ -180,4 +200,12 @@ class AppContainer(context: Context) { } return Proxy(proxyType, InetSocketAddress(host, settings.proxyPort)) } + + private fun proxySignature(settings: AppSettingsSnapshot): String = + listOf( + settings.proxyEnabled.toString(), + settings.proxyType.name, + settings.proxyHost.trim(), + settings.proxyPort.toString() + ).joinToString(separator = "|") } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarApp.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarApp.kt index c243ffdc..19d0b65f 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarApp.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarApp.kt @@ -1,10 +1,16 @@ package com.seven.ikar.kotlin.app import android.net.Uri -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -18,6 +24,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -36,16 +45,17 @@ import com.seven.ikar.kotlin.core.model.ChatType import com.seven.ikar.kotlin.core.settings.AppLocaleManager import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorScreen import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorViewModel -import com.seven.ikar.kotlin.feature.bots.BotEditorScreen -import com.seven.ikar.kotlin.feature.bots.BotEditorViewModel -import com.seven.ikar.kotlin.feature.bots.BotsScreen -import com.seven.ikar.kotlin.feature.bots.BotsViewModel +import com.seven.ikar.kotlin.feature.browser.InAppBrowserScreen import com.seven.ikar.kotlin.feature.call.CallScreen import com.seven.ikar.kotlin.core.push.AndroidActiveChatState +import com.seven.ikar.kotlin.core.push.ChatNotificationDismissalManager import com.seven.ikar.kotlin.feature.chat.ChatScreen import com.seven.ikar.kotlin.feature.chat.ChatViewModel import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoScreen import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoViewModel +import com.seven.ikar.kotlin.feature.chatinfo.ChatMaterialFilter +import com.seven.ikar.kotlin.feature.chatinfo.ChatMaterialsScreen +import com.seven.ikar.kotlin.feature.chatinfo.ChatMaterialsViewModel import com.seven.ikar.kotlin.feature.chats.ChatsScreen import com.seven.ikar.kotlin.feature.chats.ChatsViewModel import com.seven.ikar.kotlin.feature.contacts.ContactsScreen @@ -58,6 +68,8 @@ import com.seven.ikar.kotlin.feature.folders.ChatFoldersScreen import com.seven.ikar.kotlin.feature.folders.ChatFoldersViewModel import com.seven.ikar.kotlin.feature.forward.ForwardScreen import com.seven.ikar.kotlin.feature.forward.ForwardViewModel +import com.seven.ikar.kotlin.feature.join.JoinInviteScreen +import com.seven.ikar.kotlin.feature.join.JoinInviteViewModel import com.seven.ikar.kotlin.feature.login.LoginScreen import com.seven.ikar.kotlin.feature.login.LoginViewModel import com.seven.ikar.kotlin.feature.mainmenu.MainMenuViewModel @@ -78,16 +90,16 @@ import com.seven.ikar.kotlin.feature.settings.ChatNotificationSettingsViewModel import com.seven.ikar.kotlin.feature.settings.ChatWallpaperScreen import com.seven.ikar.kotlin.feature.settings.ChatSettingsViewModel import com.seven.ikar.kotlin.feature.settings.AppSettingsViewModel -import com.seven.ikar.kotlin.feature.settings.BlockedUsersPlaceholderScreen import com.seven.ikar.kotlin.feature.settings.DataStorageSettingsScreen -import com.seven.ikar.kotlin.feature.settings.LanguagePlaceholderScreen import com.seven.ikar.kotlin.feature.settings.NotificationSettingsScreen import com.seven.ikar.kotlin.feature.settings.NotificationSettingsViewModel -import com.seven.ikar.kotlin.feature.settings.PrivacyExceptionsPlaceholderScreen -import com.seven.ikar.kotlin.feature.settings.ProxyPlaceholderScreen +import com.seven.ikar.kotlin.feature.settings.ProxySettingsScreen import com.seven.ikar.kotlin.feature.settings.SettingsScreen import com.seven.ikar.kotlin.feature.settings.SettingsViewModel -import com.seven.ikar.kotlin.feature.settings.ThemePlaceholderScreen +import com.seven.ikar.kotlin.feature.settings.LanguageSettingsScreen +import com.seven.ikar.kotlin.feature.settings.ThemeSettingsScreen +import com.seven.ikar.kotlin.feature.share.ShareTargetScreen +import com.seven.ikar.kotlin.feature.share.ShareTargetViewModel import com.seven.ikar.kotlin.feature.stories.StoriesScreen import com.seven.ikar.kotlin.feature.stories.StoriesViewModel import com.seven.ikar.kotlin.feature.stories.StoryComposerScreen @@ -95,10 +107,13 @@ import com.seven.ikar.kotlin.feature.update.AppUpdateScreen import com.seven.ikar.kotlin.feature.update.AppUpdateViewModel import com.seven.ikar.kotlin.ui.LocalIkarAccessToken import com.seven.ikar.kotlin.ui.LocalUnreadChatsCount +import com.seven.ikar.kotlin.ui.SecureWindowEffect import com.seven.ikar.kotlin.ui.theme.IkarKotlinTheme import kotlinx.coroutines.launch private const val ChatRoutePattern = "chat/{chatId}?messageId={messageId}&scrollToLatest={scrollToLatest}&scrollRequestId={scrollRequestId}" +private const val JoinInviteRoutePattern = "join/{token}" +private const val BrowserRoutePattern = "browser?url={url}" private fun chatRoute( chatId: String, @@ -117,6 +132,10 @@ private fun chatRoute( return if (query.isEmpty()) route else "$route?${query.joinToString("&")}" } +private fun joinInviteRoute(token: String): String = "join/${Uri.encode(token)}" + +private fun browserRoute(url: String): String = "browser?url=${Uri.encode(url)}" + @Composable fun IkarApp() { val context = LocalContext.current @@ -128,8 +147,10 @@ fun IkarApp() { val pendingArchive by application.container.incomingArchiveStore.pendingArchive.collectAsStateWithLifecycle() val pendingCallInvite by application.container.incomingCallInviteStore.pendingInvite.collectAsStateWithLifecycle() val pendingPushNavigation by application.container.incomingPushNavigationStore.pendingNavigation.collectAsStateWithLifecycle() + val pendingLinkNavigation by application.container.incomingLinkNavigationStore.pendingNavigation.collectAsStateWithLifecycle() val appSettings by application.container.appSettings.snapshot.collectAsStateWithLifecycle() val isAppLocked by application.container.appLockState.isLocked.collectAsStateWithLifecycle() + SecureWindowEffect(enabled = isAppLocked) val mainMenuViewModel: MainMenuViewModel = viewModel( factory = viewModelFactory { initializer { MainMenuViewModel(application.container) } @@ -201,12 +222,13 @@ fun IkarApp() { } LoginScreen( state = state, + onEmailChanged = viewModel::updateEmail, onPhoneNumberChanged = viewModel::updatePhoneNumber, onDisplayNameChanged = viewModel::updateDisplayName, onVerificationCodeChanged = viewModel::updateVerificationCode, onRequestCodeClick = viewModel::requestCode, onVerifyCodeClick = viewModel::verifyCode, - onChangePhoneNumberClick = viewModel::changePhoneNumber + onChangeEmailClick = viewModel::changeEmail ) } @@ -218,6 +240,16 @@ fun IkarApp() { ) val state by viewModel.uiState.collectAsStateWithLifecycle() val chatSettings by application.container.chatSettings.snapshot.collectAsStateWithLifecycle() + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.refresh() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } LaunchedEffect(state.pendingShareNavigation?.requestId) { val target = state.pendingShareNavigation ?: return@LaunchedEffect viewModel.consumePendingShareNavigation() @@ -249,6 +281,11 @@ fun IkarApp() { onPinSelectedChatsClick = viewModel::togglePinSelectedChats, onArchiveSelectedChatsClick = viewModel::archiveSelectedChats, onMuteSelectedChatsClick = viewModel::muteSelectedChats, + onTogglePinChat = viewModel::togglePinChat, + onToggleArchiveChat = viewModel::toggleArchiveChat, + onToggleMuteChat = viewModel::toggleMuteChat, + onMarkChatRead = viewModel::markChatRead, + onSwipeDeleteChat = viewModel::showSwipeDeleteDialog, onDismissDeleteDialog = viewModel::dismissDeleteDialog, onDeleteForEveryoneChanged = viewModel::updateDeleteForEveryone, onConfirmDeleteSelected = viewModel::confirmDeleteSelected, @@ -269,10 +306,99 @@ fun IkarApp() { onSettingsClick = { navigatePrimary("settings") }, onNotificationsClick = { navController.navigate("settings/notifications") }, onGlobalSearchClick = { navController.navigate("search") }, + onFilterChanged = viewModel::updateFilter, onQueryChanged = viewModel::updateQuery ) } + composable("share") { + val share = pendingShare + if (share == null) { + LaunchedEffect(Unit) { + navigatePrimary("chats") + } + } else { + val viewModel: ShareTargetViewModel = viewModel( + key = "share-target-${share.id}", + factory = viewModelFactory { + initializer { ShareTargetViewModel(application.container, share) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(state.pendingChatId) { + val chatId = state.pendingChatId ?: return@LaunchedEffect + viewModel.consumePendingChatNavigation() + application.container.incomingShareStore.clear() + navController.navigate( + chatRoute( + chatId = chatId, + scrollToLatest = true, + scrollRequestId = System.currentTimeMillis() + ) + ) { + popUpTo("share") { + inclusive = true + } + launchSingleTop = true + } + } + ShareTargetScreen( + state = state, + onBackClick = { + application.container.incomingShareStore.clear() + navController.popBackStack() + }, + onQueryChanged = viewModel::updateQuery, + onRefresh = viewModel::refresh, + onUserClick = viewModel::sendToUser + ) + } + } + + composable( + route = BrowserRoutePattern, + arguments = listOf( + navArgument("url") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { backStackEntry -> + val url = backStackEntry.arguments?.getString("url").orEmpty() + InAppBrowserScreen( + url = url, + onBackClick = { navController.popBackStack() } + ) + } + + composable( + route = JoinInviteRoutePattern, + arguments = listOf(navArgument("token") { type = NavType.StringType }) + ) { backStackEntry -> + val token = backStackEntry.arguments?.getString("token").orEmpty() + val viewModel: JoinInviteViewModel = viewModel( + key = "join-invite-$token", + factory = viewModelFactory { + initializer { JoinInviteViewModel(application.container, token) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(state.pendingChatId) { + val chatId = state.pendingChatId ?: return@LaunchedEffect + viewModel.consumePendingChatNavigation() + navController.navigate(chatRoute(chatId = chatId, scrollToLatest = true)) { + popUpTo(JoinInviteRoutePattern) { inclusive = true } + launchSingleTop = true + } + } + JoinInviteScreen( + state = state, + onBackClick = { navigatePrimary("chats") }, + onRetryClick = viewModel::join + ) + } + composable("search") { val viewModel: GlobalSearchViewModel = viewModel( factory = viewModelFactory { @@ -345,6 +471,8 @@ fun IkarApp() { state = state, viewModel = viewModel, chatSettings = chatSettings, + largeEmojiEnabled = appSettings.largeEmoji, + animatedBackgroundsEnabled = appSettings.animatedBackgrounds, onBackClick = { navController.popBackStack() }, onPeerProfileClick = state.peer?.id?.let { userId -> { navController.navigate("profile/user/$userId") } @@ -359,6 +487,14 @@ fun IkarApp() { navController.navigate("chat/$chatId/notifications?title=$encodedTitle") }, onChatWallpaperClick = { navController.navigate("settings/chats/wallpaper") }, + onOpenLinkedDiscussion = { discussionChatId, discussionMessageId -> + navController.navigate(chatRoute(discussionChatId, messageId = discussionMessageId)) { + launchSingleTop = true + } + }, + onOpenUrl = { url -> + navController.navigate(browserRoute(url)) + }, onForwardMessage = { message -> if (!message.hasProtectedContent) { navController.navigate("forward/${message.chatId}/${message.id}") @@ -424,6 +560,8 @@ fun IkarApp() { onToggleCanPin = viewModel::toggleCanPin, onToggleCanDelete = viewModel::toggleCanDelete, onToggleCanInvite = viewModel::toggleCanInvite, + onTransferOwnership = viewModel::transferOwnership, + onRemoveMember = viewModel::removeMember, onMemberSearchChanged = viewModel::updateMemberSearchQuery, onAddMember = { user -> viewModel.addMember(user, asAdmin = false) }, onAddAdmin = { user -> viewModel.addMember(user, asAdmin = true) }, @@ -450,12 +588,47 @@ fun IkarApp() { onRefreshClick = viewModel::refresh, onMembersClick = { navController.navigate("chat/$chatId/members") }, onSearchClick = { navController.navigate("search") }, + onProfileTitleChanged = viewModel::updateProfileTitle, + onProfileDescriptionChanged = viewModel::updateProfileDescription, + onProfilePublicUsernameChanged = viewModel::updateProfilePublicUsername, + onMaterialsClick = { filter -> navController.navigate("chat/$chatId/materials/${filter.routeValue}") }, onInviteTitleChanged = viewModel::updateInviteTitle, onInviteJoinRequestChanged = viewModel::updateInviteCreatesJoinRequest, + onInviteExpiresInHoursChanged = viewModel::updateInviteExpiresInHours, + onInviteMaxUsesChanged = viewModel::updateInviteMaxUses, + onInviteMemberLimitChanged = viewModel::updateInviteMemberLimit, onCreateInviteClick = viewModel::createInviteLink, onRevokeInviteClick = viewModel::revokeInviteLink, onTopicTitleChanged = viewModel::updateTopicTitle, - onCreateTopicClick = viewModel::createTopic + onCreateTopicClick = viewModel::createTopic, + onLinkDiscussionClick = viewModel::setLinkedDiscussion, + onClearDiscussionClick = viewModel::clearLinkedDiscussion + ) + } + + composable( + route = "chat/{chatId}/materials/{filter}", + arguments = listOf( + navArgument("chatId") { type = NavType.StringType }, + navArgument("filter") { type = NavType.StringType } + ) + ) { backStackEntry -> + val chatId = backStackEntry.arguments?.getString("chatId").orEmpty() + val filter = ChatMaterialFilter.fromRoute(backStackEntry.arguments?.getString("filter")) + val viewModel: ChatMaterialsViewModel = viewModel( + key = "chat-materials-$chatId-${filter.routeValue}", + factory = viewModelFactory { + initializer { ChatMaterialsViewModel(application.container, chatId, filter) } + } + ) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ChatMaterialsScreen( + state = state, + onBackClick = { navController.popBackStack() }, + onLoadMore = viewModel::loadMore, + onMessageClick = { message -> + navController.navigate(chatRoute(chatId, messageId = message.id)) + } ) } @@ -510,7 +683,6 @@ fun IkarApp() { onBackClick = { navController.popBackStack() }, onDisplayNameChanged = viewModel::updateDisplayName, onAboutChanged = viewModel::updateAbout, - onSaveClick = viewModel::save, onDeleteAvatarClick = viewModel::deleteAvatar, onAvatarSelected = viewModel::uploadAvatar ) @@ -560,9 +732,13 @@ fun IkarApp() { onQueryChanged = viewModel::updateQuery, onRefreshClick = viewModel::refresh, onUserClick = viewModel::handleUserTap, + onChannelClick = viewModel::openPublicChannel, onBeginGroupSelection = viewModel::beginGroupSelection, onBeginChannelSelection = viewModel::beginChannelSelection, onCancelSelection = viewModel::cancelSelection, + onGroupBackClick = viewModel::previousGroupWizardStep, + onGroupTitleChanged = viewModel::updateGroupTitle, + onContinueGroupWizard = viewModel::continueGroupWizard, onChannelBackClick = viewModel::previousChannelWizardStep, onChannelTitleChanged = viewModel::updateChannelTitle, onChannelDescriptionChanged = viewModel::updateChannelDescription, @@ -621,7 +797,6 @@ fun IkarApp() { onChatSettingsClick = { navController.navigate("settings/chats") }, onPrivacyClick = { navController.navigate("settings/privacy") }, onNotificationsClick = { navController.navigate("settings/notifications") }, - onBotsClick = { navController.navigate("bots") }, onFoldersClick = { navController.navigate("settings/folders") }, onDevicesClick = { navController.navigate("settings/devices") }, onUpdatesClick = { navController.navigate("settings/update") }, @@ -643,21 +818,19 @@ fun IkarApp() { PrivacySecurityScreen( state = state, onBackClick = { navController.popBackStack() }, - onRefreshClick = viewModel::refresh, - onBlockedUsersClick = { navController.navigate("settings/privacy/blocked") }, - onExceptionsClick = { navController.navigate("settings/privacy/exceptions") }, - onUpdate = viewModel::update + onUpdate = viewModel::update, + onUserSearchQueryChanged = viewModel::updateUserSearchQuery, + onBlockUser = viewModel::blockUser, + onUnblockUser = viewModel::unblockUser, + onAlwaysAllowUser = viewModel::alwaysAllowUser, + onNeverAllowUser = viewModel::neverAllowUser, + onRemoveAlwaysAllow = viewModel::removeAlwaysAllow, + onRemoveNeverAllow = viewModel::removeNeverAllow, + onEnablePasscode = viewModel::enablePasscode, + onDisablePasscode = viewModel::disablePasscode ) } - composable("settings/privacy/blocked") { - BlockedUsersPlaceholderScreen(onBackClick = { navController.popBackStack() }) - } - - composable("settings/privacy/exceptions") { - PrivacyExceptionsPlaceholderScreen(onBackClick = { navController.popBackStack() }) - } - composable("settings/data-storage") { val viewModel: AppSettingsViewModel = viewModel( factory = viewModelFactory { @@ -681,7 +854,7 @@ fun IkarApp() { } ) val state by viewModel.uiState.collectAsStateWithLifecycle() - ThemePlaceholderScreen( + ThemeSettingsScreen( state = state, onBackClick = { navController.popBackStack() }, onSnapshotChanged = viewModel::updateSnapshot, @@ -696,7 +869,7 @@ fun IkarApp() { } ) val state by viewModel.uiState.collectAsStateWithLifecycle() - LanguagePlaceholderScreen( + LanguageSettingsScreen( state = state, onBackClick = { navController.popBackStack() }, onSnapshotChanged = viewModel::updateSnapshot @@ -710,7 +883,7 @@ fun IkarApp() { } ) val state by viewModel.uiState.collectAsStateWithLifecycle() - ProxyPlaceholderScreen( + ProxySettingsScreen( state = state, onBackClick = { navController.popBackStack() }, onSnapshotChanged = viewModel::updateSnapshot @@ -724,6 +897,16 @@ fun IkarApp() { } ) val state by viewModel.uiState.collectAsStateWithLifecycle() + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.refresh() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } StoriesScreen( state = state, onBackClick = { navController.popBackStack() }, @@ -734,6 +917,11 @@ fun IkarApp() { onCloseStoryPreview = viewModel::closeStoryPreview, onNextStory = viewModel::openNextStory, onPreviousStory = viewModel::openPreviousStory, + onStoryReplyTextChanged = viewModel::updateStoryReplyText, + onSendStoryReply = viewModel::sendStoryReply, + onSendStoryReaction = viewModel::sendStoryReaction, + onShowStoryViews = viewModel::loadStoryViews, + onCloseStoryViews = viewModel::closeStoryViews, onRetryTransfer = viewModel::retryTransfer, onCancelTransfer = viewModel::cancelTransfer ) @@ -767,6 +955,11 @@ fun IkarApp() { }, onNextStory = viewModel::openNextStory, onPreviousStory = viewModel::openPreviousStory, + onStoryReplyTextChanged = viewModel::updateStoryReplyText, + onSendStoryReply = viewModel::sendStoryReply, + onSendStoryReaction = viewModel::sendStoryReaction, + onShowStoryViews = viewModel::loadStoryViews, + onCloseStoryViews = viewModel::closeStoryViews, onRetryTransfer = viewModel::retryTransfer, onCancelTransfer = viewModel::cancelTransfer ) @@ -805,8 +998,7 @@ fun IkarApp() { DevicesScreen( state = state, onBackClick = { navController.popBackStack() }, - onRefreshClick = viewModel::refresh, - onRevokeClick = viewModel::revoke + onRefreshClick = viewModel::refresh ) } @@ -959,82 +1151,13 @@ fun IkarApp() { NotificationSettingsScreen( state = state, messageSoundOptions = viewModel.messageSoundOptions, - callRingtoneOptions = viewModel.callRingtoneOptions, - repeatIntervalOptions = viewModel.repeatIntervalOptions, onBackClick = { navController.popBackStack() }, onSnapshotChanged = { snapshot -> viewModel.updateSnapshot { snapshot } }, onMessageSoundChanged = viewModel::setMessageSound, - onCallRingtoneChanged = viewModel::setCallRingtone, - onRepeatIntervalChanged = viewModel::setRepeatInterval, - onSaveClick = viewModel::save, onResetClick = viewModel::reset ) } - composable("bots") { - val viewModel: BotsViewModel = viewModel( - factory = viewModelFactory { - initializer { BotsViewModel(application.container) } - } - ) - val state by viewModel.uiState.collectAsStateWithLifecycle() - BotsScreen( - state = state, - onBackClick = { navController.popBackStack() }, - onCreateBotClick = { navController.navigate("bots/editor") }, - onBotClick = { bot -> navController.navigate("bots/editor?botId=${bot.id}") } - ) - } - - composable( - route = "bots/editor?botId={botId}", - arguments = listOf( - navArgument("botId") { - type = NavType.StringType - nullable = true - defaultValue = null - } - ) - ) { backStackEntry -> - val botId = backStackEntry.arguments?.getString("botId") - val viewModel: BotEditorViewModel = viewModel( - key = "bot-editor-${botId ?: "new"}", - factory = viewModelFactory { - initializer { BotEditorViewModel(application.container, botId) } - } - ) - val state by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(state.pendingChatId) { - val chatId = state.pendingChatId ?: return@LaunchedEffect - viewModel.consumePendingChatNavigation() - navController.navigate("chat/$chatId") - } - LaunchedEffect(state.deleteCompleted) { - if (!state.deleteCompleted) { - return@LaunchedEffect - } - - viewModel.consumeDeleteCompleted() - navController.navigate("bots") { - popUpTo("bots") { - inclusive = true - } - } - } - BotEditorScreen( - state = state, - onBackClick = { navController.popBackStack() }, - onDisplayNameChanged = viewModel::updateDisplayName, - onUsernameChanged = viewModel::updateUsername, - onAboutChanged = viewModel::updateAbout, - onCommandsChanged = viewModel::updateCommandsText, - onEnabledChanged = viewModel::updateEnabled, - onSaveClick = viewModel::save, - onRegenerateTokenClick = viewModel::regenerateToken, - onDeleteBotClick = viewModel::deleteBot, - onOpenChatClick = viewModel::openChat - ) - } } LaunchedEffect(pendingShare?.id, currentBackStackEntry?.destination?.route) { @@ -1044,11 +1167,13 @@ fun IkarApp() { if (application.container.sessionStore.read() == null) { return@LaunchedEffect } - if (currentBackStackEntry?.destination?.route == "chats") { + if (currentBackStackEntry?.destination?.route == "share") { return@LaunchedEffect } - navigatePrimary("chats") + navController.navigate("share") { + launchSingleTop = true + } } LaunchedEffect(pendingArchive?.id, currentBackStackEntry?.destination?.route) { @@ -1082,6 +1207,48 @@ fun IkarApp() { application.container.incomingCallInviteStore.clear(invite.requestId) } + LaunchedEffect( + pendingLinkNavigation?.requestId, + currentBackStackEntry?.destination?.route, + currentBackStackEntry?.arguments?.getString("token") + ) { + val pendingLink = pendingLinkNavigation ?: return@LaunchedEffect + if (application.container.sessionStore.read() == null) { + return@LaunchedEffect + } + + pendingLink.publicChannelUsername?.let { username -> + runCatching { + application.container.executeAuthorized { session -> + application.container.apiClient.getPublicChannelByUsername(session.accessToken, username) + } + }.onSuccess { chat -> + navController.navigate(chatRoute(chatId = chat.id)) { + launchSingleTop = true + } + } + application.container.incomingLinkNavigationStore.clear(pendingLink.requestId) + return@LaunchedEffect + } + + val inviteToken = pendingLink.inviteToken ?: run { + application.container.incomingLinkNavigationStore.clear(pendingLink.requestId) + return@LaunchedEffect + } + val currentRoute = currentBackStackEntry?.destination?.route + val currentToken = currentBackStackEntry?.arguments?.getString("token") + val route = joinInviteRoute(inviteToken) + if (currentRoute == JoinInviteRoutePattern && currentToken == inviteToken) { + application.container.incomingLinkNavigationStore.clear(pendingLink.requestId) + return@LaunchedEffect + } + + navController.navigate(route) { + launchSingleTop = true + } + application.container.incomingLinkNavigationStore.clear(pendingLink.requestId) + } + LaunchedEffect( pendingPushNavigation?.requestId, currentBackStackEntry?.destination?.route, @@ -1125,6 +1292,9 @@ fun IkarApp() { null } AndroidActiveChatState.setActiveChatId(activeChatId) + if (activeChatId != null) { + ChatNotificationDismissalManager.cancelChatNotifications(context, activeChatId) + } } activeCall?.let { call -> @@ -1137,7 +1307,16 @@ fun IkarApp() { } if (isAppLocked) { - AppLockedOverlay() + AppLockedOverlay( + onUnlock = { passcode -> + if (application.container.privacyRuntimeSettings.verifyPasscode(passcode)) { + application.container.appLockState.setLocked(false) + true + } else { + false + } + } + ) } } } @@ -1146,17 +1325,52 @@ fun IkarApp() { } @Composable -private fun AppLockedOverlay() { +private fun AppLockedOverlay(onUnlock: (String) -> Boolean) { + var passcode by rememberSaveable { mutableStateOf("") } + var errorMessage by rememberSaveable { mutableStateOf(null) } Box( modifier = Modifier .fillMaxSize() .background(Color(0xFF101820)), contentAlignment = Alignment.Center ) { - Text( - text = "Икар заблокирован", - style = MaterialTheme.typography.headlineLarge, - color = Color.White - ) + Column( + modifier = Modifier.padding(horizontal = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Text( + text = "Икар заблокирован", + style = MaterialTheme.typography.headlineLarge, + color = Color.White + ) + OutlinedTextField( + value = passcode, + onValueChange = { + passcode = it.filter(Char::isDigit).take(12) + errorMessage = null + }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + label = { Text("Код-пароль") } + ) + errorMessage?.let { + Text(it, color = Color(0xFFFFB4AB), style = MaterialTheme.typography.bodySmall) + } + Button( + enabled = passcode.length >= 4, + onClick = { + if (onUnlock(passcode)) { + passcode = "" + errorMessage = null + } else { + errorMessage = "Неверный код" + } + } + ) { + Text("Разблокировать") + } + } } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarNativeApp.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarNativeApp.kt index 9a9211fd..af351259 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarNativeApp.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/IkarNativeApp.kt @@ -1,8 +1,11 @@ package com.seven.ikar.kotlin.app import android.app.Application +import android.os.Build import coil.ImageLoader import coil.ImageLoaderFactory +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder import coil.disk.DiskCache import coil.memory.MemoryCache import coil.request.CachePolicy @@ -47,5 +50,12 @@ class IkarNativeApp : Application(), ImageLoaderFactory { .networkCachePolicy(CachePolicy.ENABLED) .respectCacheHeaders(false) .crossfade(true) + .components { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + } .build() } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/ResolvedContactsRepository.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/ResolvedContactsRepository.kt index 9e6e6bda..9fcb5b83 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/ResolvedContactsRepository.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/contacts/ResolvedContactsRepository.kt @@ -67,22 +67,25 @@ class ResolvedContactsRepository( return@withContext emptyList() } val contacts = loadResolvedContacts() - if (query.isBlank()) { - return@withContext contacts - } - val merged = contacts.toMutableList() val existingIds = merged.map { it.id }.toMutableSet() - val remoteUsers = authorizedSessionManager.executeAuthorized { session -> - apiClient.searchUsers(session.accessToken, query) + + if (query.isNotBlank()) { + val remoteUsers = authorizedSessionManager.executeAuthorized { session -> + apiClient.searchUsers(session.accessToken, query) + } + remoteUsers.forEach { user -> + if (existingIds.add(user.id)) { + merged += user.toDiscoverItem() + } + } } - remoteUsers.forEach { user -> - if (existingIds.add(user.id)) { - merged += DiscoverUserItem( - user = user, - displayName = user.displayName, - normalizedPhoneNumber = PhoneNumberNormalizer.normalize(user.phoneNumber) ?: "" - ) + + if (shouldShowCreatorBotShortcut(query)) { + loadCreatorBotShortcut()?.let { user -> + if (existingIds.add(user.id)) { + merged += user.toDiscoverItem() + } } } @@ -92,4 +95,35 @@ class ResolvedContactsRepository( .thenBy { it.displayName.lowercase() } ) } + + private suspend fun loadCreatorBotShortcut(): UserSummaryDto? = + runCatching { + authorizedSessionManager.executeAuthorized { session -> + apiClient.searchUsers(session.accessToken, CreatorBotUsername) + .firstOrNull { user -> + user.isBot && user.username.equals(CreatorBotUsername, ignoreCase = true) + } + } + }.getOrNull() + + private fun UserSummaryDto.toDiscoverItem(): DiscoverUserItem = + DiscoverUserItem( + user = this, + displayName = displayName, + normalizedPhoneNumber = PhoneNumberNormalizer.normalize(phoneNumber) ?: "" + ) + + private fun shouldShowCreatorBotShortcut(query: String): Boolean { + val normalized = query.trim().lowercase() + return normalized.isBlank() || + CreatorBotUsername.contains(normalized) || + "botfather".contains(normalized) || + "создатель ботов".contains(normalized) || + "создать бота".contains(normalized) || + "бот".contains(normalized) + } + + private companion object { + private const val CreatorBotUsername = "botfatherbot" + } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatEntity.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatEntity.kt index 9ce7ec9f..5c5dd88d 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatEntity.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/CachedChatEntity.kt @@ -45,6 +45,15 @@ interface CachedChatDao { ) suspend fun loadChats(ownerUserId: String): List + @Query( + """ + SELECT * FROM cached_chats + WHERE owner_user_id = :ownerUserId AND chat_id = :chatId + LIMIT 1 + """ + ) + suspend fun loadChat(ownerUserId: String, chatId: String): CachedChatEntity? + @Upsert suspend fun upsert(chats: List) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/ChatListCache.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/ChatListCache.kt index 56091386..877d5dfc 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/ChatListCache.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/ChatListCache.kt @@ -35,4 +35,24 @@ class ChatListCache( } ) } + + suspend fun markChatRead(ownerUserId: String, chatId: String) { + val entity = dao.loadChat(ownerUserId, chatId) ?: return + val payload = runCatching { + val chat = json.decodeFromString(entity.payloadJson) + json.encodeToString(chat.copy(unreadCount = 0)) + }.getOrElse { + entity.payloadJson + } + + dao.upsert( + listOf( + entity.copy( + unreadCount = 0, + payloadJson = payload, + updatedAtMillis = System.currentTimeMillis() + ) + ) + ) + } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt index 31da6a90..6c16fe03 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt @@ -53,6 +53,22 @@ object ChatJoinRequestStatusSerializer : KSerializer { override fun serialize(encoder: Encoder, value: ChatJoinRequestStatus) = encoder.encodeInt(value.wireValue) } +@Serializable(with = JoinChatResultStatusSerializer::class) +enum class JoinChatResultStatus(val wireValue: Int) { + Joined(0), + PendingApproval(1); + + companion object { + fun fromWireValue(value: Int): JoinChatResultStatus = entries.firstOrNull { it.wireValue == value } ?: Joined + } +} + +object JoinChatResultStatusSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("JoinChatResultStatus", PrimitiveKind.INT) + override fun deserialize(decoder: Decoder): JoinChatResultStatus = JoinChatResultStatus.fromWireValue(decoder.decodeInt()) + override fun serialize(encoder: Encoder, value: JoinChatResultStatus) = encoder.encodeInt(value.wireValue) +} + @Serializable(with = ModerationLogTargetTypeSerializer::class) enum class ModerationLogTargetType(val wireValue: Int) { Chat(0), @@ -202,6 +218,14 @@ data class MessageReactionDto( val isMine: Boolean ) +@Serializable +data class StoryReplyDto( + val storyId: String, + val authorUserId: String, + val previewText: String, + val attachmentKind: String? = null +) + @Serializable data class MessageDto( val id: String, @@ -225,7 +249,10 @@ data class MessageDto( val ttlSeconds: Int? = null, val postedAsChannel: Boolean = false, val displayAuthorName: String? = null, - val viewCount: Int? = null + val viewCount: Int? = null, + val storyReply: StoryReplyDto? = null, + val discussionMessageId: String? = null, + val commentCount: Int? = null ) @Serializable @@ -272,7 +299,8 @@ data class ChatSummaryDto( val publicUsername: String? = null, val linkedDiscussionChatId: String? = null, val channelSignaturesEnabled: Boolean = false, - val description: String? = null + val description: String? = null, + val canDeleteForEveryone: Boolean = false ) @Serializable @@ -299,7 +327,8 @@ data class ChatDetailsDto( val publicUsername: String? = null, val linkedDiscussionChatId: String? = null, val channelSignaturesEnabled: Boolean = false, - val description: String? = null + val description: String? = null, + val canDeleteForEveryone: Boolean = false ) @Serializable @@ -320,6 +349,7 @@ data class ChatInfoDto( val channelSignaturesEnabled: Boolean = false, val canManageChannel: Boolean = false, val canPostAsChannel: Boolean = false, + val canInviteUsers: Boolean = false, val description: String? = null ) @@ -355,7 +385,13 @@ data class ChatStateRequest( val hasProtectedContent: Boolean? = null, val defaultMessageTtlSeconds: Int? = null, val channelSignaturesEnabled: Boolean? = null, - val linkedDiscussionChatId: String? = null + val linkedDiscussionChatId: String? = null, + val clearLinkedDiscussionChat: Boolean = false, + val title: String? = null, + val description: String? = null, + val publicUsername: String? = null, + val clearDescription: Boolean = false, + val clearPublicUsername: Boolean = false ) @Serializable @@ -368,13 +404,28 @@ data class ChatInviteLinkDto( val createsJoinRequest: Boolean = false, val isRevoked: Boolean = false, val createdAt: String, - val revokedAt: String? = null + val revokedAt: String? = null, + val expiresAt: String? = null, + val maxUses: Int? = null, + val usesCount: Int = 0, + val memberLimit: Int? = null ) @Serializable data class CreateChatInviteLinkRequest( val title: String? = null, - val createsJoinRequest: Boolean = false + val createsJoinRequest: Boolean = false, + val expiresAt: String? = null, + val maxUses: Int? = null, + val memberLimit: Int? = null +) + +@Serializable +data class JoinChatResultDto( + val status: JoinChatResultStatus, + val chat: ChatDetailsDto? = null, + val joinRequestId: String? = null, + val joinRequestStatus: ChatJoinRequestStatus? = null ) @Serializable @@ -507,6 +558,19 @@ data class PhoneCodeChallengeDto( val isRegistered: Boolean ) +@Serializable +data class RequestEmailCodeRequest( + val email: String, + val phoneNumber: String +) + +@Serializable +data class EmailCodeChallengeDto( + val challengeId: String, + val expiresAt: String, + val isRegistered: Boolean +) + @Serializable data class VerifyPhoneCodeRequest( val challengeId: String, @@ -515,6 +579,15 @@ data class VerifyPhoneCodeRequest( val displayName: String? = null ) +@Serializable +data class VerifyEmailCodeRequest( + val challengeId: String, + val email: String, + val phoneNumber: String, + val code: String, + val displayName: String? = null +) + @Serializable data class RefreshSessionRequest( val refreshToken: String @@ -534,7 +607,8 @@ data class SendMessageRequest( val replyToMessageId: String? = null, val topicId: String? = null, val hasProtectedContent: Boolean? = null, - val ttlSeconds: Int? = null + val ttlSeconds: Int? = null, + val storyReplyStoryId: String? = null ) @Serializable @@ -566,6 +640,16 @@ data class RegisterPushDeviceRequest( val notificationsEnabled: Boolean ) +@Serializable +data class PushDeviceDto( + val id: String, + val platform: PushPlatform, + val installationId: String, + val deviceName: String? = null, + val notificationsEnabled: Boolean, + val updatedAt: String +) + @Serializable data class AndroidAppReleaseDto( val versionCode: Int, @@ -586,10 +670,12 @@ data class ArgusManifestDto( data class ArgusReleaseDto( val id: String, val version: String, + val androidVersionCode: Int? = null, val downloadPath: String, val originalFileName: String? = null, val packageSizeBytes: Long? = null, val publishedAt: String, + val notes: String? = null, val releaseNotes: String? = null ) @@ -648,6 +734,9 @@ data class PrivacySettingsDto( val blockedUserIds: List = emptyList(), val alwaysAllowUserIds: List = emptyList(), val neverAllowUserIds: List = emptyList(), + val blockedUsers: List = emptyList(), + val alwaysAllowUsers: List = emptyList(), + val neverAllowUsers: List = emptyList(), val passcodeLockEnabled: Boolean = false, val showMessagePreview: Boolean = true, val updatedAt: String diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/navigation/IncomingLinkNavigationStore.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/navigation/IncomingLinkNavigationStore.kt new file mode 100644 index 00000000..48e5dbc0 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/navigation/IncomingLinkNavigationStore.kt @@ -0,0 +1,42 @@ +package com.seven.ikar.kotlin.core.navigation + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class PendingLinkNavigation( + val inviteToken: String? = null, + val publicChannelUsername: String? = null, + val requestId: Long = System.nanoTime() +) + +class IncomingLinkNavigationStore { + private val _pendingNavigation = MutableStateFlow(null) + val pendingNavigation: StateFlow = _pendingNavigation.asStateFlow() + + fun setPendingInvite(inviteToken: String?) { + val normalizedToken = inviteToken?.trim()?.takeIf { it.isNotBlank() } ?: run { + _pendingNavigation.value = null + return + } + _pendingNavigation.value = PendingLinkNavigation(inviteToken = normalizedToken) + } + + fun setPendingPublicChannel(username: String?) { + val normalizedUsername = username + ?.trim() + ?.trimStart('@') + ?.takeIf { it.isNotBlank() } ?: run { + _pendingNavigation.value = null + return + } + _pendingNavigation.value = PendingLinkNavigation(publicChannelUsername = normalizedUsername) + } + + fun clear(requestId: Long? = null) { + val current = _pendingNavigation.value ?: return + if (requestId == null || current.requestId == requestId) { + _pendingNavigation.value = null + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt index 332a9c28..c9026d6d 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt @@ -20,21 +20,23 @@ import com.seven.ikar.kotlin.core.model.ChatJoinRequestDto import com.seven.ikar.kotlin.core.model.ChatTopicDto import com.seven.ikar.kotlin.core.model.CreateChatInviteLinkRequest import com.seven.ikar.kotlin.core.model.ContactMatchDto -import com.seven.ikar.kotlin.core.model.CreateBotRequest import com.seven.ikar.kotlin.core.model.CreateChannelRequest import com.seven.ikar.kotlin.core.model.CreateChatTopicRequest import com.seven.ikar.kotlin.core.model.CreateStoryRequest -import com.seven.ikar.kotlin.core.model.CreatedBotDto import com.seven.ikar.kotlin.core.model.CreateDirectChatRequest import com.seven.ikar.kotlin.core.model.CreateGroupChatRequest import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile +import com.seven.ikar.kotlin.core.model.EmailCodeChallengeDto import com.seven.ikar.kotlin.core.model.ForwardMessageRequest +import com.seven.ikar.kotlin.core.model.JoinChatResultDto import com.seven.ikar.kotlin.core.model.MessageDto import com.seven.ikar.kotlin.core.model.MessageSearchPageDto import com.seven.ikar.kotlin.core.model.ModerationLogDto import com.seven.ikar.kotlin.core.model.PhoneCodeChallengeDto import com.seven.ikar.kotlin.core.model.PrivacySettingsDto +import com.seven.ikar.kotlin.core.model.PushDeviceDto import com.seven.ikar.kotlin.core.model.RegenerateBotTokenResponseDto +import com.seven.ikar.kotlin.core.model.RequestEmailCodeRequest import com.seven.ikar.kotlin.core.model.RequestPhoneCodeRequest import com.seven.ikar.kotlin.core.model.RegisterPushDeviceRequest import com.seven.ikar.kotlin.core.model.RefreshSessionRequest @@ -54,6 +56,7 @@ import com.seven.ikar.kotlin.core.model.UpdateMyProfileRequest import com.seven.ikar.kotlin.core.model.UpdatePrivacySettingsRequest import com.seven.ikar.kotlin.core.model.UserSessionDto import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.core.model.VerifyEmailCodeRequest import com.seven.ikar.kotlin.core.model.VerifyPhoneCodeRequest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -138,6 +141,21 @@ class ApiClient( PhoneCodeChallengeDto.serializer() ) + suspend fun requestEmailCode(email: String, phoneNumber: String): EmailCodeChallengeDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/auth/request-email-code")) + .post( + json.encodeToString( + RequestEmailCodeRequest.serializer(), + RequestEmailCodeRequest(email = email, phoneNumber = phoneNumber) + ).toRequestBody(JSON) + ) + .withClientVersionHeaders() + .build(), + EmailCodeChallengeDto.serializer() + ) + suspend fun verifyPhoneCode( challengeId: String, phoneNumber: String, @@ -163,6 +181,33 @@ class ApiClient( AuthSessionDto.serializer() ) + suspend fun verifyEmailCode( + challengeId: String, + email: String, + phoneNumber: String, + code: String, + displayName: String? + ): AuthSessionDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/auth/verify-email-code")) + .post( + json.encodeToString( + VerifyEmailCodeRequest.serializer(), + VerifyEmailCodeRequest( + challengeId = challengeId, + email = email, + phoneNumber = phoneNumber, + code = code, + displayName = displayName?.takeIf { it.isNotBlank() } + ) + ).toRequestBody(JSON) + ) + .withClientVersionHeaders() + .build(), + AuthSessionDto.serializer() + ) + suspend fun refreshSession(refreshToken: String): AuthSessionDto = executeJsonRequest( Request.Builder() @@ -200,6 +245,32 @@ class ApiClient( kotlinx.serialization.builtins.ListSerializer(ChatFolderDto.serializer()) ) + suspend fun getPublicChannels(accessToken: String, query: String = "", limit: Int = 50): List = + executeJsonRequest( + Request.Builder() + .url( + ServerConfig.absoluteMessengerUrl( + "/api/chats/public-channels?q=${encodeQuery(query)}&limit=${limit.coerceIn(1, 100)}" + ) + ) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(ChatSummaryDto.serializer()) + ) + + suspend fun getPublicChannelByUsername(accessToken: String, username: String): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/public/${encodeQuery(username.trim().trimStart('@'))}")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + suspend fun searchChats(accessToken: String, query: String, filter: String? = null, limit: Int = 50): List = searchChatsPage( accessToken = accessToken, @@ -280,7 +351,10 @@ class ApiClient( accessToken: String, chatId: String, title: String? = null, - createsJoinRequest: Boolean = false + createsJoinRequest: Boolean = false, + expiresAt: String? = null, + maxUses: Int? = null, + memberLimit: Int? = null ): ChatInviteLinkDto = executeJsonRequest( Request.Builder() @@ -288,7 +362,13 @@ class ApiClient( .post( json.encodeToString( CreateChatInviteLinkRequest.serializer(), - CreateChatInviteLinkRequest(title = title?.takeIf { it.isNotBlank() }, createsJoinRequest = createsJoinRequest) + CreateChatInviteLinkRequest( + title = title?.takeIf { it.isNotBlank() }, + createsJoinRequest = createsJoinRequest, + expiresAt = expiresAt, + maxUses = maxUses, + memberLimit = memberLimit + ) ).toRequestBody(JSON) ) .withBearer(accessToken) @@ -307,7 +387,7 @@ class ApiClient( .build() ) - suspend fun joinChatByInviteLink(accessToken: String, token: String): ChatDetailsDto = + suspend fun joinChatByInviteLink(accessToken: String, token: String): JoinChatResultDto = executeJsonRequest( Request.Builder() .url(ServerConfig.absoluteMessengerUrl("/api/chats/join/${encodeQuery(token)}")) @@ -315,7 +395,7 @@ class ApiClient( .withBearer(accessToken) .withClientVersionHeaders() .build(), - ChatDetailsDto.serializer() + JoinChatResultDto.serializer() ) suspend fun getChatJoinRequests(accessToken: String, chatId: String): List = @@ -393,6 +473,22 @@ class ApiClient( ChatMessagesPageDto.serializer() ) + suspend fun getChatMessagesAround( + accessToken: String, + chatId: String, + aroundMessageId: String, + limit: Int = 50 + ): ChatMessagesPageDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/messages?aroundMessageId=$aroundMessageId&limit=$limit")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatMessagesPageDto.serializer() + ) + suspend fun searchChatMessages( accessToken: String, chatId: String, @@ -501,6 +597,28 @@ class ApiClient( ChatDetailsDto.serializer() ) + suspend fun transferChatOwnership(accessToken: String, chatId: String, memberUserId: String): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/members/$memberUserId/transfer-owner")) + .post("".toRequestBody(JSON)) + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + + suspend fun removeChatMember(accessToken: String, chatId: String, memberUserId: String): ChatDetailsDto = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/members/$memberUserId")) + .delete() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + ChatDetailsDto.serializer() + ) + suspend fun getChatModerationLog(accessToken: String, chatId: String, limit: Int = 100): List = executeJsonRequest( Request.Builder() @@ -529,7 +647,8 @@ class ApiClient( replyToMessageId: String? = null, topicId: String? = null, hasProtectedContent: Boolean? = null, - ttlSeconds: Int? = null + ttlSeconds: Int? = null, + storyReplyStoryId: String? = null ): MessageDto = executeJsonRequest( Request.Builder() @@ -542,7 +661,8 @@ class ApiClient( replyToMessageId = replyToMessageId, topicId = topicId, hasProtectedContent = hasProtectedContent, - ttlSeconds = ttlSeconds + ttlSeconds = ttlSeconds, + storyReplyStoryId = storyReplyStoryId ) ).toRequestBody(JSON) ) @@ -627,13 +747,17 @@ class ApiClient( chatId: String, text: String?, replyToMessageId: String?, - file: UploadFileSource + file: UploadFileSource, + isVoiceNote: Boolean = false ): MessageDto = withContext(Dispatchers.IO) { val multipart = MultipartBody.Builder() .setType(MultipartBody.FORM) .apply { text?.takeIf { it.isNotBlank() }?.let { addFormDataPart("text", it) } replyToMessageId?.takeIf { it.isNotBlank() }?.let { addFormDataPart("replyToMessageId", it) } + if (isVoiceNote) { + addFormDataPart("isVoiceNote", "true") + } addFormDataPart("file", file.fileName, file.asRequestBody()) } .build() @@ -710,6 +834,16 @@ class ApiClient( .build() ) + suspend fun markChatRead(accessToken: String, chatId: String) = + executeUnitRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/chats/$chatId/read")) + .post(ByteArray(0).toRequestBody(null)) + .withBearer(accessToken) + .withClientVersionHeaders() + .build() + ) + suspend fun setMessageReaction(accessToken: String, chatId: String, messageId: String, emoji: String): MessageDto = executeJsonRequest( Request.Builder() @@ -1154,22 +1288,6 @@ class ApiClient( BotSummaryDto.serializer() ) - suspend fun createBot(accessToken: String, username: String, displayName: String, about: String?): CreatedBotDto = - executeJsonRequest( - Request.Builder() - .url(ServerConfig.absoluteMessengerUrl("/api/bots")) - .post( - json.encodeToString( - CreateBotRequest.serializer(), - CreateBotRequest(username, displayName, about?.takeIf { it.isNotBlank() }) - ).toRequestBody(JSON) - ) - .withBearer(accessToken) - .withClientVersionHeaders() - .build(), - CreatedBotDto.serializer() - ) - suspend fun updateBot(accessToken: String, botId: String, displayName: String, about: String?, isEnabled: Boolean): BotSummaryDto = executeJsonRequest( Request.Builder() @@ -1233,6 +1351,17 @@ class ApiClient( .build() ) + suspend fun getPushDevices(accessToken: String): List = + executeJsonRequest( + Request.Builder() + .url(ServerConfig.absoluteMessengerUrl("/api/push/devices")) + .get() + .withBearer(accessToken) + .withClientVersionHeaders() + .build(), + kotlinx.serialization.builtins.ListSerializer(PushDeviceDto.serializer()) + ) + suspend fun unregisterPushDevice(accessToken: String, installationId: String) = executeUnitRequest( Request.Builder() @@ -1256,19 +1385,39 @@ class ApiClient( val version = manifest.release.version val plusIndex = version.lastIndexOf('+') val versionName = if (plusIndex > 0) version.substring(0, plusIndex) else version - val versionCode = version.substringAfterLast('+', "0").toIntOrNull() ?: 0 + val versionCode = manifest.release.androidVersionCode + ?: parseVersionCodeFromVersion(version) + ?: parseVersionCodeFromFileName(manifest.release.originalFileName) + ?: 0 return AndroidAppReleaseDto( versionCode = versionCode, versionName = versionName, downloadPath = ServerConfig.absoluteArgusUrl(manifest.release.downloadPath), publishedAt = manifest.release.publishedAt, - notes = manifest.release.releaseNotes, + notes = manifest.release.notes ?: manifest.release.releaseNotes, isRequired = true, minimumSupportedVersionCode = versionCode ) } + private fun parseVersionCodeFromVersion(version: String): Int? { + val buildMetadata = version + .substringAfterLast('+', missingDelimiterValue = "") + .takeIf { it.isNotBlank() } + ?: return null + + return buildMetadata.toIntOrNull()?.takeIf { it > 0 } + } + + private fun parseVersionCodeFromFileName(fileName: String?): Int? = + fileName + ?.let(AndroidApkVersionCodeRegex::find) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?.takeIf { it > 0 } + private suspend fun executeJsonRequest(request: Request, serializer: kotlinx.serialization.KSerializer): T = withContext(Dispatchers.IO) { try { @@ -1380,6 +1529,7 @@ class ApiClient( companion object { private val JSON = "application/json; charset=utf-8".toMediaType() + private val AndroidApkVersionCodeRegex = Regex("""ikar-android-(\d+)\.apk$""", RegexOption.IGNORE_CASE) } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidPushSettings.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidPushSettings.kt index 37618c66..1be0b99b 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidPushSettings.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/AndroidPushSettings.kt @@ -2,10 +2,15 @@ package com.seven.ikar.kotlin.core.push import android.content.Context import android.util.Base64 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import java.util.UUID class AndroidPushSettings(context: Context) { private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val _snapshot = MutableStateFlow(loadSnapshot()) + val snapshot: StateFlow = _snapshot.asStateFlow() fun getInstallationId(): String { val existing = preferences.getString(KEY_INSTALLATION_ID, null) @@ -53,7 +58,7 @@ class AndroidPushSettings(context: Context) { } fun getChannelsEnabled(): Boolean = - preferences.getBoolean(KEY_CHANNELS_ENABLED, false) + preferences.getBoolean(KEY_CHANNELS_ENABLED, true) fun setChannelsEnabled(enabled: Boolean) { preferences.edit().putBoolean(KEY_CHANNELS_ENABLED, enabled).apply() @@ -137,7 +142,7 @@ class AndroidPushSettings(context: Context) { } fun getPopupWindowsEnabled(): Boolean = - preferences.getBoolean(KEY_POPUP_WINDOWS_ENABLED, false) + preferences.getBoolean(KEY_POPUP_WINDOWS_ENABLED, true) fun setPopupWindowsEnabled(enabled: Boolean) { preferences.edit().putBoolean(KEY_POPUP_WINDOWS_ENABLED, enabled).apply() @@ -230,6 +235,7 @@ class AndroidPushSettings(context: Context) { setRestartOnCloseEnabled(snapshot.restartOnCloseEnabled) setBackgroundConnectionEnabled(snapshot.backgroundConnectionEnabled) setRepeatInterval(snapshot.repeatInterval) + _snapshot.value = loadSnapshot() } fun loadChatNotificationSnapshot(chatId: String): ChatNotificationSettingsSnapshot = @@ -278,7 +284,7 @@ class AndroidPushSettings(context: Context) { messageSound = NotificationSoundOption.Default, privateChatsEnabled = true, groupsEnabled = true, - channelsEnabled = false, + channelsEnabled = true, storiesEnabled = false, reactionsEnabled = true, callVibrationEnabled = true, @@ -290,7 +296,7 @@ class AndroidPushSettings(context: Context) { inAppVibrationEnabled = true, inAppShowTextEnabled = true, soundInActiveChatEnabled = true, - popupWindowsEnabled = false, + popupWindowsEnabled = true, contactJoinedEnabled = false, pinnedMessagesEnabled = true, restartOnCloseEnabled = false, @@ -326,6 +332,7 @@ class AndroidPushSettings(context: Context) { chatKeys.forEach(::remove) apply() } + _snapshot.value = loadSnapshot() } private fun > getEnum(key: String, defaultValue: T): T { diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/ChatNotificationDismissalManager.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/ChatNotificationDismissalManager.kt new file mode 100644 index 00000000..eff10152 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/ChatNotificationDismissalManager.kt @@ -0,0 +1,72 @@ +package com.seven.ikar.kotlin.core.push + +import android.content.Context +import androidx.core.app.NotificationManagerCompat + +object ChatNotificationDismissalManager { + private const val PreferencesName = "ikar_chat_notifications" + private const val ChatKeyPrefix = "chat:" + private const val MaxTrackedNotificationsPerChat = 64 + + fun recordChatNotification(context: Context, chatId: String?, notificationId: Int) { + val normalizedChatId = chatId?.trim()?.takeIf { it.isNotBlank() } ?: return + val preferences = context.applicationContext.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE) + val key = chatKey(normalizedChatId) + val currentIds = preferences.getStringSet(key, emptySet()).orEmpty() + .mapNotNull(String::toIntOrNull) + .filterNot { it == notificationId } + val nextIds = (currentIds + notificationId) + .takeLast(MaxTrackedNotificationsPerChat) + .map(Int::toString) + .toSet() + + preferences.edit() + .putStringSet(key, nextIds) + .apply() + } + + fun cancelChatNotifications(context: Context, chatId: String?) { + val normalizedChatId = chatId?.trim()?.takeIf { it.isNotBlank() } ?: return + val preferences = context.applicationContext.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE) + val key = chatKey(normalizedChatId) + val notificationIds = preferences.getStringSet(key, emptySet()).orEmpty() + .mapNotNull(String::toIntOrNull) + + if (notificationIds.isNotEmpty()) { + val notificationManager = NotificationManagerCompat.from(context) + notificationIds.forEach(notificationManager::cancel) + } + + preferences.edit() + .remove(key) + .apply() + } + + fun cancelNotification(context: Context, chatId: String?, notificationId: Int?) { + if (notificationId != null) { + NotificationManagerCompat.from(context).cancel(notificationId) + } + + val normalizedChatId = chatId?.trim()?.takeIf { it.isNotBlank() } ?: return + val resolvedNotificationId = notificationId ?: return + val preferences = context.applicationContext.getSharedPreferences(PreferencesName, Context.MODE_PRIVATE) + val key = chatKey(normalizedChatId) + val nextIds = preferences.getStringSet(key, emptySet()).orEmpty() + .mapNotNull(String::toIntOrNull) + .filterNot { it == resolvedNotificationId } + .map(Int::toString) + .toSet() + + preferences.edit() + .apply { + if (nextIds.isEmpty()) { + remove(key) + } else { + putStringSet(key, nextIds) + } + } + .apply() + } + + private fun chatKey(chatId: String): String = ChatKeyPrefix + chatId +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt index d9284d68..8e74108b 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt @@ -3,7 +3,6 @@ package com.seven.ikar.kotlin.core.push import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import androidx.core.app.NotificationManagerCompat import com.seven.ikar.kotlin.app.IkarNativeApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -39,19 +38,23 @@ class NotificationActionReceiver : BroadcastReceiver() { } val app = context.applicationContext as? IkarNativeApp ?: return - runCatching { + val ownerUserId = runCatching { app.container.executeAuthorized { session -> app.container.apiClient.acknowledgeRead( accessToken = session.accessToken, chatId = chatId, messageId = messageId ) + session.user.id } - }.onSuccess { - if (notificationId != Int.MIN_VALUE) { - NotificationManagerCompat.from(context).cancel(notificationId) - } + }.getOrNull() ?: return + + runCatching { app.container.chatListCache.markChatRead(ownerUserId, chatId) } + app.container.realtimeService.notifyChatsChangedLocally() + if (notificationId != Int.MIN_VALUE) { + ChatNotificationDismissalManager.cancelNotification(context, chatId, notificationId) } + ChatNotificationDismissalManager.cancelChatNotifications(context, chatId) } private fun handleAcceptIncomingCall(context: Context, intent: Intent) { diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt index 9b539118..07fea943 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt @@ -6,9 +6,13 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import com.seven.ikar.kotlin.BuildConfig import com.seven.ikar.kotlin.MainActivity import com.seven.ikar.kotlin.app.IkarNativeApp import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer @@ -26,6 +30,22 @@ object PushNotificationDisplayService { AndroidPushBootstrapper.tryInitialize(context) val kind = data[PushDataKeys.Kind].orEmpty() + clearStaleAppUpdateNotification(context) + val isAppUpdateNotification = kind == PushDataKinds.AppUpdateAvailable + if (isAppUpdateNotification) { + val updateVersionCodeRaw = data[PushDataKeys.VersionCode] + val updateVersionCode = updateVersionCodeRaw?.toLongOrNull() + if (updateVersionCode == null || updateVersionCode <= BuildConfig.VERSION_CODE.toLong()) { + cancelAppUpdateNotification(context, updateVersionCodeRaw) + return + } + + if (AndroidAppVisibilityState.isVisible()) { + cancelAppUpdateNotification(context, updateVersionCodeRaw) + return + } + } + val app = context.applicationContext as? IkarNativeApp when (kind) { PushDataKinds.AudioCallInvite, @@ -65,6 +85,7 @@ object PushNotificationDisplayService { val isMessageReaction = kind == PushDataKinds.MessageReaction val isChatNotification = isChatMessage || isMessageReaction val chatId = data[PushDataKeys.ChatId].orEmpty() + val chatType = data[PushDataKeys.ChatType].orEmpty() val chatNotificationSettings = if (isChatNotification && chatId.isNotBlank()) { settings.loadChatNotificationSnapshot(chatId) } else { @@ -73,6 +94,9 @@ object PushNotificationDisplayService { if (isChatNotification && chatNotificationSettings?.notificationsEnabled == false) { return } + if (isChatNotification && !isChatCategoryEnabled(settings, chatType, isMessageReaction)) { + return + } val hideMessagePreview = isChatNotification && (app?.container?.privacyRuntimeSettings?.loadSnapshot()?.showMessagePreview == false || chatNotificationSettings?.showMessagePreview == false) @@ -101,13 +125,13 @@ object PushNotificationDisplayService { ).joinToString(separator = "|").takeIf { it.isNotBlank() } ) } + if (settings.getInAppVibrationEnabled()) { + vibrateForInAppNotification(context) + } return } val popupWindowsEnabled = chatNotificationSettings?.popupWindowsEnabled ?: settings.getPopupWindowsEnabled() - if (isChatNotification && isAppVisible && !popupWindowsEnabled) { - return - } val senderContactDisplayName = if (isChatNotification) { resolveContactDisplayName(context, data[PushDataKeys.SenderPhoneNumber]) @@ -123,7 +147,6 @@ object PushNotificationDisplayService { } } - val chatType = data[PushDataKeys.ChatType].orEmpty() val title = if (isChatNotification && chatType.equals(ChatTypeDirect, ignoreCase = true)) { senderContactDisplayName ?: fallbackTitle } else { @@ -179,10 +202,14 @@ object PushNotificationDisplayService { data.forEach { (key, value) -> putExtra(key, value) } } - val notificationId = ((if (isMessageReaction) "reaction:${data[PushDataKeys.MessageId].orEmpty()}" else data[PushDataKeys.MessageId]) - ?: data[PushDataKeys.CallId] - ?: data[PushDataKeys.VersionCode] - ?: System.currentTimeMillis().toString()).hashCode() + val notificationId = if (isAppUpdateNotification) { + AppUpdateNotificationId + } else { + ((if (isMessageReaction) "reaction:${data[PushDataKeys.MessageId].orEmpty()}" else data[PushDataKeys.MessageId]) + ?: data[PushDataKeys.CallId] + ?: data[PushDataKeys.VersionCode] + ?: System.currentTimeMillis().toString()).hashCode() + } val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE @@ -212,12 +239,17 @@ object PushNotificationDisplayService { PushDataKinds.AudioCallInvite, PushDataKinds.VideoCallInvite, PushDataKinds.AppUpdateAvailable -> NotificationCompat.PRIORITY_HIGH - else -> NotificationCompat.PRIORITY_HIGH + else -> if (isChatNotification && isAppVisible && !popupWindowsEnabled) { + NotificationCompat.PRIORITY_DEFAULT + } else { + NotificationCompat.PRIORITY_HIGH + } } val category = when (kind) { PushDataKinds.AudioCallInvite, PushDataKinds.VideoCallInvite -> NotificationCompat.CATEGORY_CALL + PushDataKinds.AppUpdateAvailable -> NotificationCompat.CATEGORY_STATUS else -> NotificationCompat.CATEGORY_MESSAGE } @@ -265,19 +297,103 @@ object PushNotificationDisplayService { .addAction(android.R.drawable.ic_menu_view, "Отметить как прочитанное", markReadPendingIntent) } + if (isAppUpdateNotification) { + cancelAppUpdateNotification(context, data[PushDataKeys.VersionCode]) + data[PushDataKeys.VersionCode] + ?.toLongOrNull() + ?.let { rememberAppUpdateNotification(context, it) } + } + NotificationManagerCompat.from(context).notify(notificationId, notificationBuilder.build()) + if (isChatNotification && chatId.isNotBlank()) { + ChatNotificationDismissalManager.recordChatNotification(context, chatId, notificationId) + } } + private const val AppUpdateNotificationId = 193000 + private const val AppUpdatePreferencesName = "ikar_app_update_notifications" + private const val AppUpdateVersionCodeKey = "version_code" + private const val InAppVibrationDurationMillis = 60L private const val GenericNewMessageText = "\u041d\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" private const val GenericNewReactionText = "\u041d\u043e\u0432\u0430\u044f \u0440\u0435\u0430\u043a\u0446\u0438\u044f" private const val ChatTypeDirect = "Direct" private const val ChatTypeGroup = "Group" + private const val ChatTypeChannel = "Channel" private const val ContactNameCacheTtlMillis = 5 * 60 * 1000L private val contactNameCacheLock = Any() @Volatile private var contactNameCache: ContactNameCache? = null + private fun cancelAppUpdateNotification(context: Context, versionCode: String?) { + val notificationManager = NotificationManagerCompat.from(context) + notificationManager.cancel(AppUpdateNotificationId) + versionCode?.takeIf { it.isNotBlank() }?.let { notificationManager.cancel(it.hashCode()) } + context.applicationContext + .getSharedPreferences(AppUpdatePreferencesName, Context.MODE_PRIVATE) + .edit() + .remove(AppUpdateVersionCodeKey) + .apply() + } + + private fun rememberAppUpdateNotification(context: Context, versionCode: Long) { + context.applicationContext + .getSharedPreferences(AppUpdatePreferencesName, Context.MODE_PRIVATE) + .edit() + .putLong(AppUpdateVersionCodeKey, versionCode) + .apply() + } + + private fun clearStaleAppUpdateNotification(context: Context) { + val preferences = context.applicationContext.getSharedPreferences(AppUpdatePreferencesName, Context.MODE_PRIVATE) + val updateVersionCode = preferences.getLong(AppUpdateVersionCodeKey, Long.MIN_VALUE) + if (updateVersionCode != Long.MIN_VALUE && updateVersionCode <= BuildConfig.VERSION_CODE.toLong()) { + cancelAppUpdateNotification(context, updateVersionCode.toString()) + } + } + + private fun isChatCategoryEnabled( + settings: AndroidPushSettings, + chatType: String, + isMessageReaction: Boolean + ): Boolean { + if (isMessageReaction && !settings.getReactionsEnabled()) { + return false + } + + return when { + chatType.equals(ChatTypeDirect, ignoreCase = true) -> settings.getPrivateChatsEnabled() + chatType.equals(ChatTypeGroup, ignoreCase = true) -> settings.getGroupsEnabled() + chatType.equals(ChatTypeChannel, ignoreCase = true) -> settings.getChannelsEnabled() + else -> true + } + } + + private fun vibrateForInAppNotification(context: Context) { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } ?: return + + if (!vibrator.hasVibrator()) { + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate( + VibrationEffect.createOneShot( + InAppVibrationDurationMillis, + VibrationEffect.DEFAULT_AMPLITUDE + ) + ) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(InAppVibrationDurationMillis) + } + } + private fun PendingIncomingCallInvite.withResolvedContactDisplayName(context: Context): PendingIncomingCallInvite { val contactName = resolveContactDisplayName(context, callerPhoneNumber) ?: return this return copy(callerDisplayName = contactName) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeDtos.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeDtos.kt index 7697e86a..2b907cf7 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeDtos.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeDtos.kt @@ -64,7 +64,9 @@ data class RealtimeMessageDto( val ttlSeconds: Int? = null, val postedAsChannel: Boolean = false, val displayAuthorName: String? = null, - val viewCount: Int? = null + val viewCount: Int? = null, + val discussionMessageId: String? = null, + val commentCount: Int? = null ) data class RealtimeMessageReplyDto( @@ -207,7 +209,9 @@ fun RealtimeMessageDto.toCore(): MessageDto = MessageDto( ttlSeconds = ttlSeconds, postedAsChannel = postedAsChannel, displayAuthorName = displayAuthorName, - viewCount = viewCount + viewCount = viewCount, + discussionMessageId = discussionMessageId, + commentCount = commentCount ) fun RealtimeMessageReplyDto.toCore(): MessageReplyDto = MessageReplyDto( diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt index ca13e58b..d858a84a 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -40,7 +41,8 @@ enum class RealtimeConnectionStage { } class RealtimeService( - private val clientVersionProvider: ClientVersionProvider + private val clientVersionProvider: ClientVersionProvider, + private val configureHttpClientBuilder: (OkHttpClient.Builder) -> Unit = {} ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val mutex = Mutex() @@ -66,6 +68,9 @@ class RealtimeService( private val _chatsChanged = MutableSharedFlow(extraBufferCapacity = 8) val chatsChanged: SharedFlow = _chatsChanged + private val _storiesChanged = MutableSharedFlow(extraBufferCapacity = 8) + val storiesChanged: SharedFlow = _storiesChanged + private val _appUpdateAvailable = MutableSharedFlow(extraBufferCapacity = 8) val appUpdateAvailable: SharedFlow = _appUpdateAvailable @@ -115,6 +120,34 @@ class RealtimeService( } } + fun reconnectForNetworkSettings() { + scope.launch { + var shouldReconnect = false + mutex.withLock { + if (!accessToken.isNullOrBlank()) { + shouldReconnect = true + reconnectJob?.cancel() + reconnectJob = null + connection?.stop()?.blockingAwait() + connection = null + _stage.value = RealtimeConnectionStage.Disconnected + } + } + + if (shouldReconnect) { + ensureConnected() + } + } + } + + fun notifyChatsChangedLocally() { + _chatsChanged.tryEmit(Unit) + } + + fun notifyStoriesChangedLocally() { + _storiesChanged.tryEmit(Unit) + } + fun startCall(chatId: String, mediaType: CallMediaType): OutgoingCallDto { val activeConnection = requireConnectedConnection() return if (mediaType == CallMediaType.Audio) { @@ -190,6 +223,7 @@ class RealtimeService( private fun buildConnection(accessToken: String): HubConnection { val hubUrl = buildHubUrl() val hub = HubConnectionBuilder.create(hubUrl) + .setHttpClientBuilderCallback { builder -> configureHttpClientBuilder(builder) } .withAccessTokenProvider(Single.defer { Single.just(accessToken) }) .withHeader("X-Ikar-Client-Platform", clientVersionProvider.platform) .withHeader("X-Ikar-Client-Version-Code", clientVersionProvider.versionCode) @@ -216,6 +250,10 @@ class RealtimeService( scope.launch { _chatsChanged.emit(Unit) } }) + hub.on("StoriesChanged", { + scope.launch { _storiesChanged.emit(Unit) } + }) + hub.on("AppUpdateAvailable", { payload: AndroidAppReleaseDto -> scope.launch { _appUpdateAvailable.emit(payload) } }, AndroidAppReleaseDto::class.java) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt index aed81640..7fcf622c 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt @@ -65,7 +65,7 @@ class AndroidAppSettings(context: Context) { largeEmoji = preferences.getBoolean(KEY_LARGE_EMOJI, true), languageMode = getEnum(KEY_LANGUAGE_MODE, AppLanguageMode.System), proxyEnabled = preferences.getBoolean(KEY_PROXY_ENABLED, false), - proxyType = getEnum(KEY_PROXY_TYPE, AppProxyType.Socks5), + proxyType = getEnum(KEY_PROXY_TYPE, AppProxyType.Socks5).supportedProxyType(), proxyHost = preferences.getString(KEY_PROXY_HOST, "").orEmpty(), proxyPort = preferences.getInt(KEY_PROXY_PORT, 1080).coerceIn(1, 65_535), proxySecret = preferences.getString(KEY_PROXY_SECRET, "").orEmpty() @@ -79,6 +79,7 @@ class AndroidAppSettings(context: Context) { val sanitized = snapshot.copy( keepMediaDays = snapshot.keepMediaDays.coerceIn(1, 365), maxCacheMb = snapshot.maxCacheMb.coerceIn(64, 8192), + proxyType = snapshot.proxyType.supportedProxyType(), proxyPort = snapshot.proxyPort.coerceIn(1, 65_535) ) preferences.edit().apply { @@ -111,12 +112,16 @@ class AndroidAppSettings(context: Context) { private fun > getEnum(key: String, defaultValue: T): T { val rawValue = preferences.getString(key, defaultValue.name).orEmpty() - @Suppress("UNCHECKED_CAST") return defaultValue.declaringJavaClass.enumConstants - ?.firstOrNull { it.name == rawValue } as? T + ?.firstOrNull { it.name == rawValue } ?: defaultValue } + private fun AppProxyType.supportedProxyType(): AppProxyType = when (this) { + AppProxyType.MtProto -> AppProxyType.Socks5 + else -> this + } + companion object { private const val PREFERENCES_NAME = "ikar_kotlin_app_settings" private const val KEY_AUTO_DOWNLOAD_PHOTOS = "auto_download_photos" diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidPrivacyRuntimeSettings.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidPrivacyRuntimeSettings.kt index 1c8cf946..50e20170 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidPrivacyRuntimeSettings.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidPrivacyRuntimeSettings.kt @@ -1,10 +1,15 @@ package com.seven.ikar.kotlin.core.settings import android.content.Context +import android.util.Base64 import com.seven.ikar.kotlin.core.model.PrivacySettingsDto import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec data class PrivacyRuntimeSnapshot( val passcodeLockEnabled: Boolean = false, @@ -23,6 +28,9 @@ class AndroidPrivacyRuntimeSettings(context: Context) { ) fun saveFrom(settings: PrivacySettingsDto) { + if (!settings.passcodeLockEnabled) { + clearPasscode() + } saveSnapshot( PrivacyRuntimeSnapshot( passcodeLockEnabled = settings.passcodeLockEnabled, @@ -31,6 +39,38 @@ class AndroidPrivacyRuntimeSettings(context: Context) { ) } + fun hasPasscode(): Boolean = + !preferences.getString(KEY_PASSCODE_SALT, null).isNullOrBlank() && + !preferences.getString(KEY_PASSCODE_HASH, null).isNullOrBlank() + + fun setPasscode(passcode: String) { + val salt = ByteArray(PasscodeSaltBytes) + SecureRandom().nextBytes(salt) + val hash = hashPasscode(passcode, salt) + preferences.edit() + .putString(KEY_PASSCODE_SALT, Base64.encodeToString(salt, Base64.NO_WRAP)) + .putString(KEY_PASSCODE_HASH, Base64.encodeToString(hash, Base64.NO_WRAP)) + .apply() + } + + fun verifyPasscode(passcode: String): Boolean { + val salt = preferences.getString(KEY_PASSCODE_SALT, null) + ?.let { Base64.decode(it, Base64.NO_WRAP) } + ?: return false + val expectedHash = preferences.getString(KEY_PASSCODE_HASH, null) + ?.let { Base64.decode(it, Base64.NO_WRAP) } + ?: return false + val actualHash = hashPasscode(passcode, salt) + return MessageDigest.isEqual(expectedHash, actualHash) + } + + fun clearPasscode() { + preferences.edit() + .remove(KEY_PASSCODE_SALT) + .remove(KEY_PASSCODE_HASH) + .apply() + } + private fun saveSnapshot(snapshot: PrivacyRuntimeSnapshot) { preferences.edit() .putBoolean(KEY_PASSCODE_LOCK_ENABLED, snapshot.passcodeLockEnabled) @@ -39,10 +79,22 @@ class AndroidPrivacyRuntimeSettings(context: Context) { _snapshot.value = snapshot } + private fun hashPasscode(passcode: String, salt: ByteArray): ByteArray { + val spec = PBEKeySpec(passcode.toCharArray(), salt, PasscodeIterations, PasscodeKeyBits) + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + .generateSecret(spec) + .encoded + } + companion object { private const val PREFERENCES_NAME = "ikar_kotlin_privacy_runtime" private const val KEY_PASSCODE_LOCK_ENABLED = "passcode_lock_enabled" private const val KEY_SHOW_MESSAGE_PREVIEW = "show_message_preview" + private const val KEY_PASSCODE_SALT = "passcode_salt" + private const val KEY_PASSCODE_HASH = "passcode_hash" + private const val PasscodeSaltBytes = 16 + private const val PasscodeIterations = 120_000 + private const val PasscodeKeyBits = 256 } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateClient.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateClient.kt index e9bb869e..8312bdb1 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateClient.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateClient.kt @@ -3,10 +3,13 @@ package com.seven.ikar.kotlin.core.update import org.json.JSONObject import java.io.File import java.net.HttpURLConnection +import java.net.Proxy import java.net.URL import java.security.MessageDigest -class ArgusAppUpdateClient { +class ArgusAppUpdateClient( + private val proxyProvider: () -> Proxy? = { null } +) { fun fetchManifest(config: ArgusAppUpdateConfig): ArgusAppManifest? { val url = buildManifestUrl(config) val connection = openConnection(url) @@ -62,12 +65,15 @@ class ArgusAppUpdateClient { return "$baseUrl/api/apps/$slug/manifest?platform=${config.platform}&channel=${config.channel}" } - private fun openConnection(url: String): HttpURLConnection = - (URL(url).openConnection() as HttpURLConnection).apply { + private fun openConnection(url: String): HttpURLConnection { + val targetUrl = URL(url) + val connection = proxyProvider()?.let(targetUrl::openConnection) ?: targetUrl.openConnection() + return (connection as HttpURLConnection).apply { connectTimeout = 15_000 readTimeout = 60_000 setRequestProperty("Accept", "application/json") } + } private fun parseManifest(body: String): ArgusAppManifest { val root = JSONObject(body) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateInstaller.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateInstaller.kt index e827b4ba..323bbb87 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateInstaller.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateInstaller.kt @@ -17,7 +17,7 @@ class ArgusAppUpdateInstaller(private val context: Context) { update: AvailableIkarUpdate ): AppUpdateSubmissionResult { val expectedPackageName = update.installedApp.packageName - val expectedVersionCode = update.manifest.release.androidVersionCode + val expectedVersionCode = update.manifest.release.resolvedVersionCode val archiveInfo = readArchiveInfo(packageFile) ?: return reject( expectedPackageName, diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateManager.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateManager.kt index a26ef040..a810eda1 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateManager.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateManager.kt @@ -45,18 +45,16 @@ class ArgusAppUpdateManager( "На сервере Argus нет совместимого релиза для ${config.slug}." ) - val manifestPackageName = manifest.androidPackageName?.trim() - if (manifestPackageName.isNullOrBlank()) { - return AppUpdateCheckResult.Incompatible( - "В карточке Argus не указан androidPackageName." - ) - } - - if (!manifestPackageName.equals(installedApp.packageName, ignoreCase = false)) { - return AppUpdateCheckResult.Incompatible( - "Argus отдаёт пакет $manifestPackageName, а текущее приложение имеет пакет ${installedApp.packageName}." - ) - } + manifest.androidPackageName + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { manifestPackageName -> + if (!manifestPackageName.equals(installedApp.packageName, ignoreCase = false)) { + return AppUpdateCheckResult.Incompatible( + "Argus отдаёт пакет $manifestPackageName, а текущее приложение имеет пакет ${installedApp.packageName}." + ) + } + } if (!manifest.release.packageKind.equals("apk", ignoreCase = true)) { return AppUpdateCheckResult.Incompatible( @@ -64,12 +62,24 @@ class ArgusAppUpdateManager( ) } - val remoteVersionCode = manifest.release.androidVersionCode - ?: return AppUpdateCheckResult.Incompatible( - "Argus не вернул androidVersionCode для релиза." - ) + val remoteVersionCode = manifest.release.resolvedVersionCode + val installedVersionName = installedApp.versionName?.trim().orEmpty() + val versionNameComparison = installedVersionName + .takeIf { it.isNotBlank() } + ?.let { + ArgusReleaseVersionComparator.compare( + remoteVersion = manifest.release.resolvedVersionName, + localVersion = it + ) + } + val isUpdateAvailable = when { + versionNameComparison == null -> remoteVersionCode == null || remoteVersionCode > installedApp.versionCode + versionNameComparison > 0 -> true + versionNameComparison == 0 -> remoteVersionCode != null && remoteVersionCode > installedApp.versionCode + else -> false + } - if (remoteVersionCode <= installedApp.versionCode) { + if (!isUpdateAvailable) { return AppUpdateCheckResult.UpToDate(manifest) } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateModels.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateModels.kt index 88d13028..bc3e2684 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateModels.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/update/ArgusAppUpdateModels.kt @@ -27,7 +27,38 @@ data class ArgusPublishedRelease( val sha256: String, val publishedAt: String, val notes: String? -) +) { + val resolvedVersionName: String + get() = version.substringBefore('+').trim().ifBlank { version.trim() } + + val resolvedVersionCode: Long? + get() = androidVersionCode?.takeIf { it > 0 } + ?: version.substringAfterLast('+', missingDelimiterValue = "") + .trim() + .toLongOrNull() + ?.takeIf { it > 0 } + ?: parseVersionCodeFromFileName(originalFileName) + + companion object { + private val VersionCodeFileNameRegex = Regex( + """(?:^|[-_])(\d+)\.apk$""", + RegexOption.IGNORE_CASE + ) + + fun parseVersionCodeFromFileName(fileName: String?): Long? { + if (fileName.isNullOrBlank()) { + return null + } + + return VersionCodeFileNameRegex + .find(fileName.trim()) + ?.groupValues + ?.getOrNull(1) + ?.toLongOrNull() + ?.takeIf { it > 0 } + } + } +} data class ArgusAppManifest( val slug: String, @@ -45,8 +76,112 @@ data class AvailableIkarUpdate( val installedApp: InstalledIkarApp, val manifest: ArgusAppManifest ) { + val versionCode: Long + get() = manifest.release.resolvedVersionCode ?: 0L + val downloadUrl: String - get() = config.baseUrl.trimEnd('/') + manifest.release.downloadPath + get() { + val rawPath = manifest.release.downloadPath.trim() + if (rawPath.startsWith("http://", ignoreCase = true) || + rawPath.startsWith("https://", ignoreCase = true) + ) { + return rawPath + } + + return config.baseUrl.trimEnd('/') + "/" + rawPath.trimStart('/') + } +} + +internal object ArgusReleaseVersionComparator { + fun compare(remoteVersion: String, localVersion: String?): Int { + val remote = normalize(remoteVersion) + val local = normalize(localVersion.orEmpty()) + + if (remote.isBlank()) { + return 0 + } + if (local.isBlank()) { + return 1 + } + + val remoteSemantic = SemanticVersion.parse(remote) + val localSemantic = SemanticVersion.parse(local) + return if (remoteSemantic != null && localSemantic != null) { + remoteSemantic.compareTo(localSemantic) + } else { + remote.compareTo(local, ignoreCase = true) + } + } + + private fun normalize(version: String): String = + version.trim() + .removePrefix("v") + .removePrefix("V") + .substringBefore('+') + .trim() + + private data class SemanticVersion( + val major: Long, + val minor: Long, + val patch: Long, + val preRelease: List + ) : Comparable { + override fun compareTo(other: SemanticVersion): Int = + compareValuesBy(this, other, SemanticVersion::major, SemanticVersion::minor, SemanticVersion::patch) + .takeIf { it != 0 } + ?: comparePreRelease(preRelease, other.preRelease) + + companion object { + private val Pattern = Regex("""^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$""") + + fun parse(value: String): SemanticVersion? { + val match = Pattern.matchEntire(value) ?: return null + return SemanticVersion( + major = match.groupValues[1].toLongOrNull() ?: return null, + minor = match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() }?.toLongOrNull() ?: 0L, + patch = match.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }?.toLongOrNull() ?: 0L, + preRelease = match.groupValues.getOrNull(4) + ?.takeIf { it.isNotBlank() } + ?.split('.') + .orEmpty() + ) + } + + private fun comparePreRelease(left: List, right: List): Int { + if (left.isEmpty() && right.isEmpty()) { + return 0 + } + if (left.isEmpty()) { + return 1 + } + if (right.isEmpty()) { + return -1 + } + + val maxSize = maxOf(left.size, right.size) + for (index in 0 until maxSize) { + val leftPart = left.getOrNull(index) ?: return -1 + val rightPart = right.getOrNull(index) ?: return 1 + val comparison = comparePreReleasePart(leftPart, rightPart) + if (comparison != 0) { + return comparison + } + } + return 0 + } + + private fun comparePreReleasePart(left: String, right: String): Int { + val leftNumber = left.toLongOrNull() + val rightNumber = right.toLongOrNull() + return when { + leftNumber != null && rightNumber != null -> leftNumber.compareTo(rightNumber) + leftNumber != null -> -1 + rightNumber != null -> 1 + else -> left.compareTo(right, ignoreCase = true) + } + } + } + } } sealed interface AppUpdateCheckResult { diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorScreen.kt index ce301e7c..94b9fa4f 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorScreen.kt @@ -1,5 +1,6 @@ package com.seven.ikar.kotlin.feature.bots +import android.content.ClipData import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -13,7 +14,6 @@ import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.SmartToy import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -31,12 +31,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -48,12 +50,12 @@ fun BotEditorScreen( onAboutChanged: (String) -> Unit, onCommandsChanged: (String) -> Unit, onEnabledChanged: (Boolean) -> Unit, - onSaveClick: () -> Unit, onRegenerateTokenClick: () -> Unit, onDeleteBotClick: () -> Unit, onOpenChatClick: () -> Unit ) { - val clipboardManager = LocalClipboardManager.current + val clipboard = LocalClipboard.current + val clipboardScope = rememberCoroutineScope() var showDeleteConfirmation by remember { mutableStateOf(false) } if (showDeleteConfirmation) { AlertDialog( @@ -81,7 +83,7 @@ fun BotEditorScreen( Scaffold( topBar = { TopAppBar( - title = { Text(if (state.isExistingBot) "Редактор бота" else "Новый бот") }, + title = { Text(if (state.isExistingBot) "Редактор бота" else "Создатель ботов") }, navigationIcon = { IconButton(onClick = onBackClick) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад") @@ -147,8 +149,13 @@ fun BotEditorScreen( } } - Button(onClick = onSaveClick, enabled = !state.isSaving && !state.isDeleting, modifier = Modifier.fillMaxWidth()) { - Text(if (state.isSaving) "Сохранение..." else "Сохранить") + if (!state.isExistingBot) { + Text( + "Новых ботов создавайте в чате с @botfatherbot командой /создать_бота.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else if (state.isSaving) { + Text("Сохраняется...", color = MaterialTheme.colorScheme.onSurfaceVariant) } if (state.isExistingBot) { @@ -183,7 +190,13 @@ fun BotEditorScreen( Text("Текущий токен", fontWeight = FontWeight.SemiBold) Text(state.latestToken, style = MaterialTheme.typography.bodySmall) OutlinedButton( - onClick = { clipboardManager.setText(AnnotatedString(state.latestToken)) }, + onClick = { + clipboardScope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText("Ikar bot token", state.latestToken)) + ) + } + }, modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Outlined.ContentCopy, contentDescription = null) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorViewModel.kt index 4ad0021f..f231d16f 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotEditorViewModel.kt @@ -6,6 +6,8 @@ import com.seven.ikar.kotlin.app.AppContainer import com.seven.ikar.kotlin.core.model.AuthSessionDto import com.seven.ikar.kotlin.core.model.BotCommandDto import com.seven.ikar.kotlin.core.model.BotSummaryDto +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,29 +40,34 @@ class BotEditorViewModel( ) : ViewModel() { private val _uiState = MutableStateFlow(BotEditorUiState(session = container.sessionStore.read(), botId = botId)) val uiState: StateFlow = _uiState.asStateFlow() + private var autosaveJob: Job? = null init { load() } fun updateDisplayName(value: String) { - _uiState.value = _uiState.value.copy(displayName = value) + _uiState.value = _uiState.value.copy(displayName = value, errorMessage = null, infoMessage = null) + scheduleExistingBotAutosave() } fun updateUsername(value: String) { - _uiState.value = _uiState.value.copy(username = value) + _uiState.value = _uiState.value.copy(username = value, errorMessage = null, infoMessage = null) } fun updateAbout(value: String) { - _uiState.value = _uiState.value.copy(about = value) + _uiState.value = _uiState.value.copy(about = value, errorMessage = null, infoMessage = null) + scheduleExistingBotAutosave() } fun updateCommandsText(value: String) { - _uiState.value = _uiState.value.copy(commandsText = value) + _uiState.value = _uiState.value.copy(commandsText = value, errorMessage = null, infoMessage = null) + scheduleExistingBotAutosave() } fun updateEnabled(value: Boolean) { - _uiState.value = _uiState.value.copy(isEnabled = value) + _uiState.value = _uiState.value.copy(isEnabled = value, errorMessage = null, infoMessage = null) + scheduleExistingBotAutosave() } fun load() { @@ -85,8 +92,10 @@ class BotEditorViewModel( _uiState.value = state.copy(errorMessage = "Укажите отображаемое имя бота.") return } - if (!state.isExistingBot && state.username.isBlank()) { - _uiState.value = state.copy(errorMessage = "Укажите имя пользователя бота.") + if (!state.isExistingBot) { + _uiState.value = state.copy( + errorMessage = "Создавайте ботов через чат с @botfatherbot командой /создать_бота." + ) return } @@ -95,34 +104,19 @@ class BotEditorViewModel( runCatching { container.executeAuthorized { session -> val parsedCommands = parseCommands(state.commandsText) - if (!state.isExistingBot) { - val created = container.apiClient.createBot( - accessToken = session.accessToken, - username = state.username.trim(), - displayName = state.displayName.trim(), - about = state.about.trim().ifBlank { null } - ) - val updatedBot = if (parsedCommands.isNotEmpty()) { - container.apiClient.setBotCommands(session.accessToken, created.bot.id, parsedCommands) - } else { - created.bot - } - Triple(session, updatedBot, Pair(created.accessToken, "Бот создан.")) - } else { - val updated = container.apiClient.updateBot( - accessToken = session.accessToken, - botId = state.botId.orEmpty(), - displayName = state.displayName.trim(), - about = state.about.trim().ifBlank { null }, - isEnabled = state.isEnabled - ) - val withCommands = container.apiClient.setBotCommands( - accessToken = session.accessToken, - botId = updated.id, - commands = parsedCommands - ) - Triple(session, withCommands, Pair(state.latestToken, "Бот сохранён.")) - } + val updated = container.apiClient.updateBot( + accessToken = session.accessToken, + botId = state.botId.orEmpty(), + displayName = state.displayName.trim(), + about = state.about.trim().ifBlank { null }, + isEnabled = state.isEnabled + ) + val withCommands = container.apiClient.setBotCommands( + accessToken = session.accessToken, + botId = updated.id, + commands = parsedCommands + ) + Triple(session, withCommands, Pair(state.latestToken, "Бот обновлён.")) } }.onSuccess { result -> val (session, bot, extra) = result @@ -233,4 +227,20 @@ class BotEditorViewModel( private fun formatCommands(commands: List): String = commands.joinToString(separator = "\n") { "/${it.command} - ${it.description}" } + + private fun scheduleExistingBotAutosave() { + if (!_uiState.value.isExistingBot) { + return + } + + autosaveJob?.cancel() + autosaveJob = viewModelScope.launch { + delay(BotAutosaveDelayMillis) + save() + } + } + + companion object { + private const val BotAutosaveDelayMillis = 600L + } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotsScreen.kt index 4cb885f9..4c13bad3 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotsScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/bots/BotsScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack -import androidx.compose.material.icons.outlined.Add import androidx.compose.material3.Icon import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme @@ -41,7 +40,7 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted fun BotsScreen( state: BotsUiState, onBackClick: () -> Unit, - onCreateBotClick: () -> Unit, + onOpenCreatorBotClick: () -> Unit, onBotClick: (BotSummaryDto) -> Unit ) { IkarScreenSurface(modifier = Modifier.fillMaxSize()) { @@ -72,8 +71,8 @@ fun BotsScreen( } ) - Button(onClick = onCreateBotClick) { - Text("Создать бота") + Button(onClick = onOpenCreatorBotClick) { + Text("Открыть Создателя ботов") } state.errorMessage?.let { @@ -98,7 +97,7 @@ fun BotsScreen( ) { Text("Ботов пока нет", style = MaterialTheme.typography.headlineMedium) Text( - "Создайте первого бота и подключите его через Bot API.", + "Создайте первого бота через чат с Создателем ботов.", style = MaterialTheme.typography.bodyMedium, color = TelegramInkMuted ) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/browser/InAppBrowserScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/browser/InAppBrowserScreen.kt new file mode 100644 index 00000000..61bb4670 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/browser/InAppBrowserScreen.kt @@ -0,0 +1,117 @@ +package com.seven.ikar.kotlin.feature.browser + +import android.annotation.SuppressLint +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun InAppBrowserScreen( + url: String, + onBackClick: () -> Unit +) { + val context = LocalContext.current + var title by remember(url) { mutableStateOf(url.hostTitle()) } + var progress by remember(url) { mutableIntStateOf(0) } + val webView = remember(url) { + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadsImagesAutomatically = true + settings.allowFileAccess = false + settings.allowContentAccess = false + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return false + } + } + webChromeClient = object : WebChromeClient() { + override fun onReceivedTitle(view: WebView, receivedTitle: String?) { + title = receivedTitle?.takeIf { it.isNotBlank() } ?: view.url?.hostTitle() ?: title + } + + override fun onProgressChanged(view: WebView, newProgress: Int) { + progress = newProgress.coerceIn(0, 100) + } + } + if (url.isNotBlank()) { + loadUrl(url) + } + } + } + + BackHandler { + if (webView.canGoBack()) { + webView.goBack() + } else { + onBackClick() + } + } + + DisposableEffect(webView) { + onDispose { + webView.stopLoading() + webView.destroy() + } + } + + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + IkarSimpleHeader(title = title, onBackClick = onBackClick) + Text( + text = url, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1 + ) + if (progress in 1..99) { + LinearProgressIndicator(progress = { progress / 100f }) + } + AndroidView( + factory = { webView }, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +private fun String.hostTitle(): String = + runCatching { android.net.Uri.parse(this).host } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?: "Браузер" diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidAttachmentFileService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidAttachmentFileService.kt index 56ed8103..6bf698c1 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidAttachmentFileService.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidAttachmentFileService.kt @@ -4,11 +4,18 @@ import android.content.ActivityNotFoundException import android.content.ContentValues import android.content.Context import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore import android.provider.OpenableColumns +import android.util.Base64 import androidx.core.content.FileProvider import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile import com.seven.ikar.kotlin.core.network.UploadFileSource @@ -41,6 +48,7 @@ internal fun LocalAttachmentFile.toUploadFileSource(): UploadFileSource = class AndroidAttachmentFileService( private val context: Context ) { + private val maxCacheFileNameLength = 120 private val maxAttachmentSizeBytes = 25L * 1024L * 1024L private val attachmentsDirectory: File by lazy { File(context.cacheDir, "attachments").apply { mkdirs() } @@ -72,15 +80,94 @@ class AndroidAttachmentFileService( ) } + suspend fun createBuiltInGifAttachment(fileName: String, base64Data: String): LocalAttachmentFile = + withContext(Dispatchers.IO) { + val sanitizedFileName = sanitizeFileName(fileName).ifBlank { "ikar_gif.gif" } + val bytes = Base64.decode(base64Data, Base64.DEFAULT) + if (bytes.size > maxAttachmentSizeBytes) { + throw IOException("File must be 25 MB or smaller.") + } + + val target = File(attachmentsDirectory, "pending_${System.currentTimeMillis()}_$sanitizedFileName") + target.outputStream().use { output -> output.write(bytes) } + LocalAttachmentFile( + fileName = sanitizedFileName, + contentType = "image/gif", + fileSizeBytes = bytes.size.toLong(), + localPath = target.absolutePath + ) + } + + suspend fun createBuiltInStickerAttachment( + fileName: String, + emoji: String, + label: String, + backgroundColor: Int, + accentColor: Int + ): LocalAttachmentFile = withContext(Dispatchers.IO) { + val sanitizedFileName = sanitizeFileName(fileName).ifBlank { "ikar_sticker.png" } + val target = File(attachmentsDirectory, "pending_${System.currentTimeMillis()}_$sanitizedFileName") + val bitmap = Bitmap.createBitmap(StickerCanvasSizePx, StickerCanvasSizePx, Bitmap.Config.ARGB_8888) + try { + val canvas = Canvas(bitmap) + val bounds = RectF(0f, 0f, StickerCanvasSizePx.toFloat(), StickerCanvasSizePx.toFloat()) + val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = backgroundColor + style = Paint.Style.FILL + } + canvas.drawRoundRect(bounds, 96f, 96f, backgroundPaint) + + val accentPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = accentColor + style = Paint.Style.FILL + alpha = 36 + } + canvas.drawCircle(StickerCanvasSizePx * 0.78f, StickerCanvasSizePx * 0.18f, 96f, accentPaint) + canvas.drawCircle(StickerCanvasSizePx * 0.18f, StickerCanvasSizePx * 0.78f, 72f, accentPaint) + + val emojiPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textAlign = Paint.Align.CENTER + textSize = 220f + typeface = Typeface.DEFAULT + } + val emojiBaseline = StickerCanvasSizePx * 0.50f - (emojiPaint.descent() + emojiPaint.ascent()) / 2 + canvas.drawText(emoji, StickerCanvasSizePx / 2f, emojiBaseline, emojiPaint) + + val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = accentColor + textAlign = Paint.Align.CENTER + textSize = 46f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + canvas.drawText(label.take(22), StickerCanvasSizePx / 2f, StickerCanvasSizePx * 0.88f, labelPaint) + + target.outputStream().use { output -> + if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) { + throw IOException("Failed to create sticker file.") + } + } + } finally { + bitmap.recycle() + } + + LocalAttachmentFile( + fileName = sanitizedFileName, + contentType = "image/png", + fileSizeBytes = target.length(), + localPath = target.absolutePath + ) + } + fun createAttachmentCacheFile(attachmentId: String, fileName: String): File = - File(attachmentsDirectory, "${attachmentId}_${sanitizeFileName(fileName)}") + File(attachmentsDirectory, "${sanitizeFileName(attachmentId)}_${sanitizeCacheFileName(fileName)}") suspend fun cacheDownloadedAttachment(file: DownloadedAttachmentFile): LocalAttachmentFile = withContext(Dispatchers.IO) { val target = File(file.localPath) target.setLastModified(System.currentTimeMillis()) LocalAttachmentFile( - fileName = target.name.substringAfter('_', file.fileName), + fileName = file.fileName, contentType = file.contentType, fileSizeBytes = file.fileSizeBytes.takeIf { it >= 0L } ?: target.length(), localPath = target.absolutePath @@ -235,6 +322,16 @@ class AndroidAttachmentFileService( } } + suspend fun saveCachedImageToGalleryIfMissing(localPath: String, fileName: String, contentType: String): Uri? = + withContext(Dispatchers.IO) { + val sanitizedFileName = sanitizeFileName(fileName) + if (galleryImageExists(sanitizedFileName)) { + return@withContext null + } + + saveCachedImageToGallery(localPath, sanitizedFileName, contentType) + } + private fun requireExistingFile(localPath: String): File { val file = File(localPath) if (!file.exists()) { @@ -244,6 +341,29 @@ class AndroidAttachmentFileService( return file } + private fun galleryImageExists(displayName: String): Boolean { + val resolver = context.contentResolver + val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val projection = arrayOf(MediaStore.Images.Media._ID) + val selection: String + val arguments: Array + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ? AND (${MediaStore.Images.Media.RELATIVE_PATH} = ? OR ${MediaStore.Images.Media.RELATIVE_PATH} = ?)" + arguments = arrayOf( + displayName, + Environment.DIRECTORY_PICTURES + "/Ikar", + Environment.DIRECTORY_PICTURES + "/Ikar/" + ) + } else { + selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?" + arguments = arrayOf(displayName) + } + + return resolver.query(collection, projection, selection, arguments, null)?.use { cursor -> + cursor.moveToFirst() + } ?: false + } + private fun contentUriFor(file: File): Uri = FileProvider.getUriForFile( context, @@ -298,4 +418,29 @@ class AndroidAttachmentFileService( private fun sanitizeFileName(value: String): String = value.trim().ifBlank { "attachment" }.replace(Regex("[^A-Za-z0-9._-]"), "_") + + private fun sanitizeCacheFileName(value: String): String { + val decodedName = runCatching { Uri.decode(value) }.getOrDefault(value) + val sanitizedName = sanitizeFileName(decodedName) + if (sanitizedName.length <= maxCacheFileNameLength) { + return sanitizedName + } + + val extension = sanitizedName + .substringAfterLast('.', missingDelimiterValue = "") + .takeIf { it.isNotBlank() && it.length <= 16 && sanitizedName.contains('.') } + ?.let { ".$it" } + .orEmpty() + val baseName = if (extension.isBlank()) { + sanitizedName + } else { + sanitizedName.dropLast(extension.length).trimEnd('.').ifBlank { "attachment" } + } + val maxBaseLength = (maxCacheFileNameLength - extension.length).coerceAtLeast(1) + return baseName.take(maxBaseLength) + extension + } + + private companion object { + const val StickerCanvasSizePx = 512 + } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNotePlayerService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNotePlayerService.kt index de3bb8cd..30b31c79 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNotePlayerService.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNotePlayerService.kt @@ -1,6 +1,13 @@ package com.seven.ikar.kotlin.feature.chat -import android.media.MediaPlayer +import android.content.Context +import android.net.Uri +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -11,27 +18,51 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import java.io.File import java.util.concurrent.Semaphore data class VoiceNotePlaybackEvent( val attachmentId: String, val isPlaying: Boolean, val positionMs: Long, - val durationMs: Long + val durationMs: Long, + val errorMessage: String? = null ) -class AndroidVoiceNotePlayerService { +class AndroidVoiceNotePlayerService( + private val context: Context +) { private val sync = Semaphore(1) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val _events = MutableSharedFlow(extraBufferCapacity = 32) val events: SharedFlow = _events.asSharedFlow() - private var player: MediaPlayer? = null + private var player: ExoPlayer? = null private var currentAttachmentId: String? = null private var durationMs: Long = 0L private var tickerJob: Job? = null - suspend fun toggle(attachmentId: String, localFilePath: String) { + suspend fun toggle(attachmentId: String, localFilePath: String, pauseExternalAudio: Boolean = false) { + toggleMedia( + attachmentId = attachmentId, + mediaItem = MediaItem.fromUri(Uri.fromFile(File(localFilePath))), + pauseExternalAudio = pauseExternalAudio + ) + } + + suspend fun toggleUri(attachmentId: String, uri: Uri, pauseExternalAudio: Boolean = false) { + toggleMedia( + attachmentId = attachmentId, + mediaItem = MediaItem.fromUri(uri), + pauseExternalAudio = pauseExternalAudio + ) + } + + private suspend fun toggleMedia( + attachmentId: String, + mediaItem: MediaItem, + pauseExternalAudio: Boolean + ) { sync.acquire() try { if (player != null && currentAttachmentId == attachmentId) { @@ -41,19 +72,46 @@ class AndroidVoiceNotePlayerService { stopCurrent() - val nextPlayer = MediaPlayer().apply { - setDataSource(localFilePath) - prepare() + val nextPlayer = ExoPlayer.Builder(context).build().apply { + setAudioAttributes(VoiceNoteAudioAttributes, pauseExternalAudio) } - player = nextPlayer currentAttachmentId = attachmentId - durationMs = nextPlayer.duration.toLong().coerceAtLeast(0L) + durationMs = 0L - nextPlayer.setOnCompletionListener { - scope.launch { stopCurrent(playbackCompleted = true) } - } - nextPlayer.start() + nextPlayer.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + val activeAttachmentId = currentAttachmentId ?: return + durationMs = safeDuration(nextPlayer) + when (playbackState) { + Player.STATE_READY -> emitEvent( + attachmentId = activeAttachmentId, + isPlaying = safeIsPlaying(nextPlayer), + positionMs = safePosition(nextPlayer), + durationMs = durationMs + ) + + Player.STATE_ENDED -> scope.launch { stopCurrent(playbackCompleted = true) } + } + } + + override fun onPlayerError(error: PlaybackException) { + val failedAttachmentId = currentAttachmentId ?: return + scope.launch { + stopCurrent() + emitEvent( + attachmentId = failedAttachmentId, + isPlaying = false, + positionMs = 0L, + durationMs = 0L, + errorMessage = "Не удалось воспроизвести голосовое сообщение." + ) + } + } + }) + nextPlayer.setMediaItem(mediaItem) + nextPlayer.prepare() + nextPlayer.play() startTicker() emitEvent(attachmentId, true, 0L, durationMs) } finally { @@ -81,16 +139,8 @@ class AndroidVoiceNotePlayerService { durationMs = 0L stopTicker() - existingPlayer?.let { mediaPlayer -> - try { - if (safeIsPlaying(mediaPlayer)) { - mediaPlayer.stop() - } - } catch (_: Throwable) { - } - - mediaPlayer.reset() - mediaPlayer.release() + existingPlayer?.let { exoPlayer -> + exoPlayer.release() } if (attachmentId != null) { @@ -134,17 +184,49 @@ class AndroidVoiceNotePlayerService { ) } - private fun safeIsPlaying(mediaPlayer: MediaPlayer?): Boolean = + private fun emitEvent( + attachmentId: String, + isPlaying: Boolean, + positionMs: Long, + durationMs: Long, + errorMessage: String? + ) { + _events.tryEmit( + VoiceNotePlaybackEvent( + attachmentId = attachmentId, + isPlaying = isPlaying, + positionMs = positionMs.coerceAtLeast(0L), + durationMs = durationMs.coerceAtLeast(0L), + errorMessage = errorMessage + ) + ) + } + + private fun safeIsPlaying(exoPlayer: ExoPlayer?): Boolean = try { - mediaPlayer?.isPlaying == true + exoPlayer?.isPlaying == true } catch (_: Throwable) { false } - private fun safePosition(mediaPlayer: MediaPlayer?): Long = + private fun safePosition(exoPlayer: ExoPlayer?): Long = try { - mediaPlayer?.currentPosition?.toLong()?.coerceAtLeast(0L) ?: 0L + exoPlayer?.currentPosition?.coerceAtLeast(0L) ?: 0L } catch (_: Throwable) { 0L } + + private fun safeDuration(exoPlayer: ExoPlayer?): Long = + try { + exoPlayer?.duration?.takeIf { it != C.TIME_UNSET }?.coerceAtLeast(0L) ?: 0L + } catch (_: Throwable) { + 0L + } + + private companion object { + val VoiceNoteAudioAttributes: AudioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .build() + } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNoteRecorderService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNoteRecorderService.kt index 4d6344c6..9f004159 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNoteRecorderService.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/AndroidVoiceNoteRecorderService.kt @@ -1,6 +1,8 @@ package com.seven.ikar.kotlin.feature.chat import android.content.Context +import android.media.AudioFocusRequest +import android.media.AudioManager import android.media.MediaRecorder import android.os.Build import kotlinx.coroutines.CancellationException @@ -28,11 +30,15 @@ class AndroidVoiceNoteRecorderService( private var currentFile: File? = null private var startedAtMs: Long = 0L private var tickerJob: Job? = null + private var activeAudioFocusRequest: AudioFocusRequest? = null + private var hasLegacyAudioFocus: Boolean = false + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { } val isRecording: Boolean get() = recorder != null - suspend fun start() { + suspend fun start(pauseExternalAudio: Boolean = false) { sync.acquire() try { if (recorder != null) { @@ -48,6 +54,7 @@ class AndroidVoiceNoteRecorderService( } try { + requestAudioFocusIfNeeded(pauseExternalAudio) nextRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) nextRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) nextRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) @@ -60,6 +67,7 @@ class AndroidVoiceNoteRecorderService( nextRecorder.reset() nextRecorder.release() file.delete() + abandonAudioFocus() throw error } @@ -87,6 +95,7 @@ class AndroidVoiceNoteRecorderService( if (currentRecorder == null) { _elapsedMs.value = 0L + abandonAudioFocus() return null } @@ -98,6 +107,7 @@ class AndroidVoiceNoteRecorderService( } finally { currentRecorder.reset() currentRecorder.release() + abandonAudioFocus() } _elapsedMs.value = 0L @@ -145,4 +155,40 @@ class AndroidVoiceNoteRecorderService( tickerJob?.cancel() tickerJob = null } + + private fun requestAudioFocusIfNeeded(enabled: Boolean) { + if (!enabled) { + return + } + + if (Build.VERSION.SDK_INT >= 26) { + val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build() + if (audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + activeAudioFocusRequest = request + } + } else { + @Suppress("DEPRECATION") + if (audioManager.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT + ) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + ) { + hasLegacyAudioFocus = true + } + } + } + + private fun abandonAudioFocus() { + if (Build.VERSION.SDK_INT >= 26) { + activeAudioFocusRequest?.let(audioManager::abandonAudioFocusRequest) + activeAudioFocusRequest = null + } else if (hasLegacyAudioFocus) { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(audioFocusChangeListener) + hasLegacyAudioFocus = false + } + } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatDraftStore.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatDraftStore.kt new file mode 100644 index 00000000..1e5176d0 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatDraftStore.kt @@ -0,0 +1,41 @@ +package com.seven.ikar.kotlin.feature.chat + +import android.content.Context +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +class ChatDraftStore(context: Context) { + private val preferences = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val _changes = MutableSharedFlow(extraBufferCapacity = 16) + + val changes: SharedFlow = _changes + + fun read(chatId: String): String = + preferences.getString(key(chatId), null).orEmpty() + + fun save(chatId: String, draft: String) { + val normalizedDraft = draft.take(MAX_DRAFT_LENGTH) + if (normalizedDraft.isBlank()) { + clear(chatId) + return + } + + preferences.edit() + .putString(key(chatId), normalizedDraft) + .apply() + _changes.tryEmit(Unit) + } + + fun clear(chatId: String) { + preferences.edit().remove(key(chatId)).apply() + _changes.tryEmit(Unit) + } + + private fun key(chatId: String): String = "draft_$chatId" + + private companion object { + private const val PREFERENCES_NAME = "ikar_kotlin_chat_drafts" + private const val MAX_DRAFT_LENGTH = 16_384 + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt index eec1dee8..ee4a35f1 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt @@ -2,10 +2,12 @@ package com.seven.ikar.kotlin.feature.chat import android.Manifest import android.content.ActivityNotFoundException +import android.content.ClipData import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.util.Base64 import android.util.Patterns import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -58,6 +60,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack @@ -70,7 +73,9 @@ import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Forum import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material.icons.outlined.KeyboardArrowUp import androidx.compose.material.icons.outlined.Menu @@ -95,6 +100,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -110,9 +116,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -121,17 +129,28 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import coil.compose.AsyncImage +import coil.request.ImageRequest import com.seven.ikar.kotlin.core.model.AttachmentDto -import com.seven.ikar.kotlin.core.model.AttachmentKind import com.seven.ikar.kotlin.core.model.BotCommandDto import com.seven.ikar.kotlin.core.model.ChatType import com.seven.ikar.kotlin.core.model.MessageDeliveryState @@ -183,6 +202,11 @@ private data class ChatImagePreviewEntry( val attachment: AttachmentDto ) +private data class ChatVideoPreviewEntry( + val message: MessageDto, + val attachment: AttachmentDto +) + private data class EmojiGridItem( val index: Int, val categoryKey: String, @@ -199,11 +223,25 @@ private sealed interface EmojiPickerMode { data class Reaction(val message: MessageDto) : EmojiPickerMode } +private enum class EmojiKeyboardTab { + Emoji, + Gif, + Stickers +} + private val EmojiKeyboardFallbackHeight = 300.dp private val EmojiKeyboardMinHeight = 220.dp private val EmojiKeyboardMaxHeight = 360.dp -private val EmojiGridMinCellWidth = 38.dp -private val EmojiGridCellHeight = 34.dp +private const val EmojiGridColumns = 8 +private const val EmojiGridVisibleRows = 4 +private val EmojiCategoryStripHeight = 47.dp +private val EmojiKeyboardBottomTabsHeight = 53.dp +private val EmojiGridCellHeight = 46.dp +private val PickerMediaGridMinCellWidth = 92.dp +private val PickerMediaGridCellHeight = 112.dp +private val PickerSuggestionPanelHeight = 176.dp +private val PickerSuggestionGridMinCellWidth = 68.dp +private val PickerSuggestionGridCellHeight = 76.dp private const val EmojiKeyboardAnimationMillis = 220 @OptIn(FlowPreview::class) @@ -212,13 +250,17 @@ fun ChatScreen( state: ChatUiState, viewModel: ChatViewModel, chatSettings: ChatSettingsSnapshot, + largeEmojiEnabled: Boolean, + animatedBackgroundsEnabled: Boolean, onBackClick: () -> Unit, onPeerProfileClick: (() -> Unit)? = null, onChatNotificationsClick: () -> Unit, onChatWallpaperClick: () -> Unit, + onOpenLinkedDiscussion: (String, String?) -> Unit = { _, _ -> }, + onOpenUrl: (String) -> Unit = {}, onForwardMessage: (MessageDto) -> Unit ) { - val clipboard = LocalClipboardManager.current + val clipboard = LocalClipboard.current val context = LocalContext.current val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() @@ -238,6 +280,13 @@ fun ChatScreen( .map { attachment -> ChatImagePreviewEntry(message = message, attachment = attachment) } } } + val videoPreviewEntries = remember(state.messages) { + state.messages.flatMap { message -> + message.attachments + .filter { attachment -> attachment.isVideoAttachment() } + .map { attachment -> ChatVideoPreviewEntry(message = message, attachment = attachment) } + } + } val currentImagePreviewIndex = remember(state.previewImageAttachmentId, imagePreviewEntries) { state.previewImageAttachmentId ?.let { attachmentId -> imagePreviewEntries.indexOfFirst { it.attachment.id == attachmentId } } @@ -246,9 +295,14 @@ fun ChatScreen( val currentImagePreviewEntry = remember(currentImagePreviewIndex, imagePreviewEntries) { currentImagePreviewIndex?.let(imagePreviewEntries::getOrNull) } - val hasProtectedContentVisible = remember(state.messages, currentImagePreviewEntry) { + val currentVideoPreviewEntry = remember(state.previewVideoAttachmentId, videoPreviewEntries) { + state.previewVideoAttachmentId + ?.let { attachmentId -> videoPreviewEntries.firstOrNull { it.attachment.id == attachmentId } } + } + val hasProtectedContentVisible = remember(state.messages, currentImagePreviewEntry, currentVideoPreviewEntry) { state.messages.any { it.hasProtectedContent && !it.isDeleted() } || - currentImagePreviewEntry?.message?.hasProtectedContent == true + currentImagePreviewEntry?.message?.hasProtectedContent == true || + currentVideoPreviewEntry?.message?.hasProtectedContent == true } val searchResultIds = remember(state.search.resultMessageIds) { state.search.resultMessageIds.toSet() } val activeSearchMessageId = state.search.activeMessageId @@ -263,8 +317,17 @@ fun ChatScreen( var emojiPickerMode by remember { mutableStateOf(null) } var displayedEmojiPickerMode by remember { mutableStateOf(null) } + var emojiKeyboardTab by remember { mutableStateOf(EmojiKeyboardTab.Emoji) } var frequentEmojis by remember(context) { mutableStateOf(loadFrequentEmojis(context)) } val emojiCategories = remember(frequentEmojis) { emojiPickerCategories(frequentEmojis) } + var suggestedPickerEmoji by remember(state.chatId) { mutableStateOf(null) } + val suggestedPickerItems = remember(suggestedPickerEmoji) { + suggestedPickerMediaForEmoji( + emoji = suggestedPickerEmoji, + gifPacks = BuiltInGifPacks, + stickerPacks = BuiltInStickerPacks + ) + } val density = LocalDensity.current val keyboardController = LocalSoftwareKeyboardController.current var rememberedKeyboardHeight by remember { mutableStateOf(EmojiKeyboardFallbackHeight) } @@ -280,6 +343,7 @@ fun ChatScreen( var showHeaderMenu by remember { mutableStateOf(false) } var showClearHistoryConfirm by remember { mutableStateOf(false) } var showDeleteChatConfirm by remember { mutableStateOf(false) } + var deleteChatForEveryone by remember { mutableStateOf(false) } var showBotCommandsDialog by remember { mutableStateOf(false) } var showBotThreadSelection by remember { mutableStateOf(false) } var messageMenuTarget by remember { mutableStateOf(null) } @@ -310,6 +374,12 @@ fun ChatScreen( LaunchedEffect(emojiPickerMode) { emojiPickerMode?.let { displayedEmojiPickerMode = it } + if (emojiPickerMode !is EmojiPickerMode.Composer) { + suggestedPickerEmoji = null + } + if (emojiPickerMode is EmojiPickerMode.Reaction) { + emojiKeyboardTab = EmojiKeyboardTab.Emoji + } } BackHandler(enabled = state.isMessageSelectionMode) { @@ -321,6 +391,7 @@ fun ChatScreen( } BackHandler(enabled = emojiPickerMode != null) { + suggestedPickerEmoji = null emojiPickerMode = null } @@ -559,7 +630,7 @@ fun ChatScreen( } LaunchedEffect(state.chatId, state.chatType, rows, isInitialScrollApplied) { - if (!isInitialScrollApplied || state.chatType != ChatType.Channel || rows.isEmpty()) { + if (!isInitialScrollApplied || rows.isEmpty()) { return@LaunchedEffect } @@ -610,6 +681,7 @@ fun ChatScreen( if (state.isMessageSelectionMode) { messageMenuTarget = null emojiPickerMode = null + suggestedPickerEmoji = null deleteCandidate = null showHeaderMenu = false } @@ -626,8 +698,12 @@ fun ChatScreen( fun handleEmojiSelected(pickerMode: EmojiPickerMode, emoji: String) { frequentEmojis = storeFrequentEmoji(context, frequentEmojis, emoji) when (pickerMode) { - EmojiPickerMode.Composer -> viewModel.appendEmoji(emoji) + EmojiPickerMode.Composer -> { + viewModel.appendEmoji(emoji) + suggestedPickerEmoji = emoji + } is EmojiPickerMode.Reaction -> { + suggestedPickerEmoji = null emojiPickerMode = null val target = pickerMode.message if (state.selectedMessageIds.contains(target.id)) { @@ -639,9 +715,24 @@ fun ChatScreen( } } + fun handlePickerMediaSelected(item: PickerMediaItem) { + suggestedPickerEmoji = null + emojiPickerMode = null + viewModel.sendPickerMedia(item) + } + + val openMessageUrl: (String) -> Unit = { url -> + if (chatSettings.inAppBrowserEnabled) { + onOpenUrl(url) + } else { + openExternalUrl(context, url) + } + } + Box(modifier = Modifier.fillMaxSize()) { ChatWallpaperBackground( snapshot = chatSettings, + animated = animatedBackgroundsEnabled && chatSettings.animationsEnabled, modifier = Modifier.fillMaxSize() ) @@ -660,7 +751,11 @@ fun ChatScreen( viewModel.beginReplyToMessage(message) }, onCopySelected = { message -> - clipboard.setText(AnnotatedString(message.text)) + coroutineScope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText("Ikar message", message.text)) + ) + } viewModel.clearMessageSelection() }, onForwardSelected = { message -> @@ -669,6 +764,7 @@ fun ChatScreen( }, onReactSelected = { message -> keyboardController?.hide() + suggestedPickerEmoji = null displayedEmojiPickerMode = EmojiPickerMode.Reaction(message) emojiPickerMode = EmojiPickerMode.Reaction(message) }, @@ -752,9 +848,16 @@ fun ChatScreen( isSearchMatch = searchResultIds.contains(row.item.id), isActiveSearchResult = activeSearchMessageId == row.item.id, isSelectionMode = state.isMessageSelectionMode, + largeEmojiEnabled = largeEmojiEnabled, localStatus = localStatus, accessToken = state.session?.accessToken, attachmentUrlResolver = viewModel::attachmentPreviewUrl, + onOpenUrl = openMessageUrl, + onDiscussionClick = state.linkedDiscussionChatId + ?.takeIf { it.isNotBlank() } + ?.let { discussionChatId -> + { discussionMessageId -> onOpenLinkedDiscussion(discussionChatId, discussionMessageId) } + }, onClick = { if (state.isMessageSelectionMode) { viewModel.toggleMessageSelection(row.item) @@ -769,6 +872,13 @@ fun ChatScreen( viewModel.openAttachment(attachment) } }, + onInlineAudioClick = { audio -> + if (state.isMessageSelectionMode) { + viewModel.toggleMessageSelection(row.item) + } else { + viewModel.toggleInlineAudioPlayback(audio.id, audio.url) + } + }, onQuickActionClick = { commandText -> if (state.isMessageSelectionMode) { viewModel.toggleMessageSelection(row.item) @@ -946,6 +1056,26 @@ fun ChatScreen( ) } + AnimatedVisibility( + visible = state.canSendMessages && + emojiPickerMode is EmojiPickerMode.Composer && + emojiKeyboardTab == EmojiKeyboardTab.Emoji && + suggestedPickerItems.isNotEmpty(), + enter = expandVertically( + expandFrom = Alignment.Bottom, + animationSpec = tween(EmojiKeyboardAnimationMillis) + ), + exit = shrinkVertically( + shrinkTowards = Alignment.Bottom, + animationSpec = tween(EmojiKeyboardAnimationMillis) + ) + ) { + EmojiStickerSuggestionPanel( + items = suggestedPickerItems, + onMediaSelected = ::handlePickerMediaSelected + ) + } + if (state.canSendMessages) { ChatComposer( draftText = state.draftText, @@ -955,17 +1085,22 @@ fun ChatScreen( isRecording = state.isRecordingVoiceNote, isSending = state.isSendingMessage, recordingElapsedText = state.voiceRecordingElapsedText, + sendByEnter = chatSettings.sendByEnterEnabled, onDraftChanged = viewModel::updateDraft, onEmojiClick = { keyboardController?.hide() emojiPickerMode = if (emojiPickerMode is EmojiPickerMode.Composer) { + suggestedPickerEmoji = null null } else { displayedEmojiPickerMode = EmojiPickerMode.Composer EmojiPickerMode.Composer } }, - onInputFocused = { emojiPickerMode = null }, + onInputFocused = { + suggestedPickerEmoji = null + emojiPickerMode = null + }, onBotMenuClick = { viewModel.ensureBotCommandsLoaded() showBotCommandsDialog = true @@ -986,6 +1121,7 @@ fun ChatScreen( canSubscribe = state.canSubscribe, isJoining = state.isJoiningChannel, publicUsername = state.publicUsername, + description = state.channelDescription, subscriberCount = state.subscriberCount, onSubscribeClick = viewModel::subscribeToChannel ) @@ -1007,7 +1143,17 @@ fun ChatScreen( EmojiKeyboardPanel( categories = emojiCategories, height = emojiKeyboardHeight, - onEmojiSelected = { emoji -> handleEmojiSelected(pickerMode, emoji) } + activeTab = emojiKeyboardTab, + allowMediaTabs = pickerMode is EmojiPickerMode.Composer, + gifPacks = BuiltInGifPacks, + stickerPacks = BuiltInStickerPacks, + onTabSelected = { tab -> emojiKeyboardTab = tab }, + onDismiss = { + suggestedPickerEmoji = null + emojiPickerMode = null + }, + onEmojiSelected = { emoji -> handleEmojiSelected(pickerMode, emoji) }, + onMediaSelected = ::handlePickerMediaSelected ) } } @@ -1021,6 +1167,18 @@ fun ChatScreen( title = "Меню чата", onDismiss = { showHeaderMenu = false }, actions = buildList { + onPeerProfileClick?.let { openInfo -> + add( + MessageActionItem( + label = "Информация", + icon = Icons.Outlined.Info, + onClick = { + showHeaderMenu = false + openInfo() + } + ) + ) + } add( MessageActionItem( label = "Уведомления", @@ -1076,26 +1234,54 @@ fun ChatScreen( ) ) } - if (state.chatType == ChatType.Channel && state.isSubscribed && !state.canManageChannel) { - add( - MessageActionItem( - label = if (state.isLeavingChannel) "Отписка..." else "Отписаться от канала", - icon = Icons.Outlined.Delete, - isDestructive = true, - onClick = { - showHeaderMenu = false - viewModel.unsubscribeFromChannel() - } + if (state.chatType == ChatType.Channel) { + state.linkedDiscussionChatId?.takeIf { it.isNotBlank() }?.let { discussionChatId -> + add( + MessageActionItem( + label = "Обсуждение", + icon = Icons.Outlined.Forum, + onClick = { + showHeaderMenu = false + onOpenLinkedDiscussion(discussionChatId, null) + } + ) ) - ) - } else if (state.chatType != ChatType.Channel || state.isSubscribed) { + } + if (state.canDeleteForEveryone) { + add( + MessageActionItem( + label = "Удалить канал навсегда", + icon = Icons.Outlined.Delete, + isDestructive = true, + onClick = { + showHeaderMenu = false + deleteChatForEveryone = true + showDeleteChatConfirm = true + } + ) + ) + } else if (state.isSubscribed) { + add( + MessageActionItem( + label = if (state.isLeavingChannel) "Выход..." else "Выйти из канала", + icon = Icons.Outlined.Delete, + isDestructive = true, + onClick = { + showHeaderMenu = false + viewModel.unsubscribeFromChannel() + } + ) + ) + } + } else if (state.isSubscribed) { add( MessageActionItem( - label = if (state.chatType == ChatType.Channel) "Удалить канал" else "Удалить чат", + label = "Удалить чат", icon = Icons.Outlined.Delete, isDestructive = true, onClick = { showHeaderMenu = false + deleteChatForEveryone = false showDeleteChatConfirm = true } ) @@ -1106,14 +1292,29 @@ fun ChatScreen( } if (showDeleteChatConfirm) { - val deleteTitle = if (state.chatType == ChatType.Channel) "Удалить канал?" else "Удалить чат?" - val deleteText = if (state.chatType == ChatType.Channel) { - "Канал исчезнет из вашего списка после подтверждения." + val deleteTitle = when { + state.chatType == ChatType.Channel && deleteChatForEveryone -> "Удалить канал навсегда?" + state.chatType == ChatType.Channel -> "Выйти из канала?" + else -> "Удалить чат?" + } + val deleteText = when { + state.chatType == ChatType.Channel && deleteChatForEveryone -> + "Канал будет удален для всех подписчиков и исчезнет из публичного поиска." + state.chatType == ChatType.Channel -> + "Канал исчезнет только из вашего списка." + else -> + "Чат исчезнет из вашего списка после подтверждения." + } + val confirmDeleteText = if (state.chatType == ChatType.Channel && deleteChatForEveryone) { + "Удалить навсегда" } else { - "Чат исчезнет из вашего списка после подтверждения." + "Удалить" } AlertDialog( - onDismissRequest = { showDeleteChatConfirm = false }, + onDismissRequest = { + showDeleteChatConfirm = false + deleteChatForEveryone = false + }, title = { Text(deleteTitle) }, text = { Text(deleteText) }, confirmButton = { @@ -1121,16 +1322,20 @@ fun ChatScreen( enabled = !state.isDeletingChat, onClick = { showDeleteChatConfirm = false - viewModel.deleteChat() + viewModel.deleteChat(forEveryone = deleteChatForEveryone) + deleteChatForEveryone = false } ) { - Text("Удалить", color = MaterialTheme.colorScheme.error) + Text(confirmDeleteText, color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton( enabled = !state.isDeletingChat, - onClick = { showDeleteChatConfirm = false } + onClick = { + showDeleteChatConfirm = false + deleteChatForEveryone = false + } ) { Text("Отмена") } @@ -1211,8 +1416,13 @@ fun ChatScreen( val actionItems = buildMessageMenuItems( message = target, currentUserId = currentUserId, + canPostAsChannel = state.canPostAsChannel, onCopy = { - clipboard.setText(AnnotatedString(target.text)) + coroutineScope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText("Ikar message", target.text)) + ) + } messageMenuTarget = null }, onForward = { @@ -1316,6 +1526,15 @@ fun ChatScreen( } ) } + + currentVideoPreviewEntry?.let { previewEntry -> + ChatVideoPreviewDialog( + entry = previewEntry, + accessToken = state.session?.accessToken, + videoUrl = viewModel.attachmentPreviewUrl(previewEntry.attachment), + onDismiss = viewModel::dismissVideoPreview + ) + } } @Composable @@ -1350,14 +1569,13 @@ private fun ChatHeader( ) } - Box( - modifier = if (onAvatarClick != null) { - Modifier - .clip(CircleShape) - .clickable(onClick = onAvatarClick) - } else { - Modifier - } + Row( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(18.dp)) + .then(if (onAvatarClick != null) Modifier.clickable(onClick = onAvatarClick) else Modifier), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically ) { AvatarView( imageUrl = avatarUrl, @@ -1365,26 +1583,26 @@ private fun ChatHeader( accessToken = avatarAccessToken, size = 42.dp ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(1.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.headlineLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (subtitle.isNotBlank()) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = TelegramInkMuted, + text = title, + style = MaterialTheme.typography.headlineLarge, maxLines = 1, overflow = TextOverflow.Ellipsis ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } @@ -1729,6 +1947,7 @@ private fun ChannelViewerBar( canSubscribe: Boolean, isJoining: Boolean, publicUsername: String?, + description: String?, subscriberCount: Int, onSubscribeClick: () -> Unit ) { @@ -1765,6 +1984,15 @@ private fun ChannelViewerBar( maxLines = 1, overflow = TextOverflow.Ellipsis ) + description?.takeIf { it.isNotBlank() }?.let { text -> + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } if (canSubscribe) { ActionChip( @@ -1913,6 +2141,7 @@ private fun ChatComposer( isRecording: Boolean, isSending: Boolean, recordingElapsedText: String, + sendByEnter: Boolean, onDraftChanged: (String) -> Unit, onEmojiClick: () -> Unit, onInputFocused: () -> Unit, @@ -1934,16 +2163,26 @@ private fun ChatComposer( horizontalArrangement = Arrangement.spacedBy(2.dp) ) { ChatComposerIcon( - icon = if (showBotMenuButton) Icons.Outlined.Menu else Icons.Outlined.Mood, - description = if (showBotMenuButton) "Команды бота" else "Эмодзи", - onClick = if (showBotMenuButton) onBotMenuClick else onEmojiClick + icon = Icons.Outlined.Mood, + description = "Эмодзи", + onClick = onEmojiClick ) BasicTextField( value = draftText, onValueChange = onDraftChanged, enabled = !isSending, - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = if (sendByEnter) ImeAction.Send else ImeAction.Default + ), + keyboardActions = KeyboardActions( + onSend = { + if (sendByEnter && draftText.isNotBlank() && !showVoiceButton && !isRecording && !isSending) { + onSendClick() + } + } + ), modifier = Modifier .weight(1f) .onFocusChanged { focusState -> @@ -1975,6 +2214,14 @@ private fun ChatComposer( } ) + if (showBotMenuButton) { + ChatComposerIcon( + icon = Icons.Outlined.Menu, + description = "Команды бота", + onClick = onBotMenuClick + ) + } + ChatComposerIcon( icon = Icons.Outlined.AttachFile, description = "Вложение", @@ -2094,11 +2341,15 @@ private fun MessageBubble( isSearchMatch: Boolean, isActiveSearchResult: Boolean, isSelectionMode: Boolean, + largeEmojiEnabled: Boolean, localStatus: LocalMessageStatus?, accessToken: String?, attachmentUrlResolver: (AttachmentDto) -> String, + onOpenUrl: (String) -> Unit, + onDiscussionClick: ((String) -> Unit)?, onClick: () -> Unit, onAttachmentClick: (AttachmentDto) -> Unit, + onInlineAudioClick: (InlineAudioUrl) -> Unit, onQuickActionClick: (String) -> Unit, onReactionClick: (String) -> Unit, onDoubleTap: () -> Unit, @@ -2119,17 +2370,26 @@ private fun MessageBubble( } val displayText = if (message.isDeleted()) "Сообщение удалено." else message.text val displayContent = remember(message.id, displayText) { buildMessageDisplayContent(displayText) } - val bubbleContentSpacing = if (!hasAttachments && displayContent.imageUrls.isEmpty() && botQuickActions.isEmpty()) 4.dp else 8.dp + val standaloneTelegramMediaAttachment = message.standaloneTelegramMediaAttachment(displayContent) + val discussionMessageId = message.discussionMessageId?.takeIf { it.isNotBlank() } + val canOpenDiscussion = !message.isDeleted() && + message.chatType == ChatType.Channel && + discussionMessageId != null && + onDiscussionClick != null + val bubbleContentSpacing = if (!hasAttachments && displayContent.imageUrls.isEmpty() && displayContent.audioUrls.isEmpty() && botQuickActions.isEmpty()) 4.dp else 8.dp val bubbleShape = RoundedCornerShape(bubbleCornerRadiusDp.dp) val selectionShape = RoundedCornerShape((bubbleCornerRadiusDp + 4).coerceIn(8, 28).dp) val density = LocalDensity.current - val context = LocalContext.current val maxSwipeOffsetPx = with(density) { 72.dp.toPx() } val swipeReplyThresholdPx = with(density) { 42.dp.toPx() } var swipeOffsetPx by remember(message.id, isSelectionMode) { mutableStateOf(0f) } val canSwipeToReply = !isSelectionMode && !message.isDeleted() BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val maxBubbleWidth = maxWidth * 0.8f + val maxBubbleWidth = when { + standaloneTelegramMediaAttachment?.isTelegramStickerAttachment() == true -> maxWidth * 0.58f + standaloneTelegramMediaAttachment?.isTelegramGifAttachment() == true -> maxWidth * 0.68f + else -> maxWidth * 0.8f + } Row( modifier = Modifier.fillMaxWidth(), @@ -2189,6 +2449,15 @@ private fun MessageBubble( ), verticalArrangement = Arrangement.spacedBy(bubbleContentSpacing) ) { + message.storyReply?.let { storyReply -> + StoryReplyPreviewCard( + title = "Ответ на сторис", + previewText = storyReply.previewText, + attachmentKind = storyReply.attachmentKind, + modifier = Modifier.padding(horizontal = if (hasAttachments) 6.dp else 0.dp) + ) + } + message.replyTo?.let { replyTo -> ReplyPreviewCard( replyTo = replyTo, @@ -2206,13 +2475,30 @@ private fun MessageBubble( ) } - val imageAttachments = message.attachments.filter { it.isImageAttachment() } + standaloneTelegramMediaAttachment?.let { attachment -> + TelegramMediaAttachment( + attachment = attachment, + attachmentUrl = attachmentUrlResolver(attachment), + accessToken = accessToken, + isOutgoing = isOutgoing, + modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start), + onClick = { onAttachmentClick(attachment) }, + onLongPress = onLongPress + ) + } + + val attachmentsForDefaultRendering = if (standaloneTelegramMediaAttachment == null) { + message.attachments + } else { + message.attachments.filterNot { it.id == standaloneTelegramMediaAttachment.id } + } + val imageAttachments = attachmentsForDefaultRendering.filter { it.isImageAttachment() } if (showImagesBeforeText) { displayContent.imageUrls.forEach { imageUrl -> InlineMessageImage( imageUrl = imageUrl, - onClick = { openExternalUrl(context, imageUrl) }, + onClick = { onOpenUrl(imageUrl) }, onLongPress = onLongPress ) } @@ -2239,11 +2525,30 @@ private fun MessageBubble( } } + displayContent.audioUrls.forEach { audio -> + InlineMessageAudio( + audio = audio, + playbackState = attachmentPlaybackState[audio.id], + onClick = { onInlineAudioClick(audio) }, + onLongPress = onLongPress + ) + } + if (displayContent.text.isNotBlank()) { + val useLargeEmojiText = largeEmojiEnabled && + !hasAttachments && + displayContent.imageUrls.isEmpty() && + displayContent.audioUrls.isEmpty() && + botQuickActions.isEmpty() && + message.storyReply == null && + message.replyTo == null && + message.forwardedFromDisplayName.isNullOrBlank() && + isLargeEmojiMessage(displayContent.text) MessageText( text = displayContent.text, modifier = Modifier.padding(horizontal = if (hasAttachments) 6.dp else 0.dp), - textSizeSp = messageTextSizeSp, + textSizeSp = if (useLargeEmojiText) 42 else messageTextSizeSp, + onUrlClick = onOpenUrl, onTap = onClick, onLongPress = onLongPress ) @@ -2253,16 +2558,16 @@ private fun MessageBubble( displayContent.imageUrls.forEach { imageUrl -> InlineMessageImage( imageUrl = imageUrl, - onClick = { openExternalUrl(context, imageUrl) }, + onClick = { onOpenUrl(imageUrl) }, onLongPress = onLongPress ) } } val remainingAttachments = if (showImagesBeforeText || imageAttachments.size > 1) { - message.attachments.filterNot { it.isImageAttachment() } + attachmentsForDefaultRendering.filterNot { it.isImageAttachment() } } else { - message.attachments + attachmentsForDefaultRendering } if (!showImagesBeforeText && imageAttachments.size > 1) { @@ -2315,6 +2620,14 @@ private fun MessageBubble( ) } + if (canOpenDiscussion) { + ChannelDiscussionLink( + commentCount = message.commentCount ?: 0, + modifier = Modifier.padding(horizontal = if (hasAttachments) 6.dp else 0.dp), + onClick = { onDiscussionClick(discussionMessageId) } + ) + } + MessageMeta( sentAt = message.sentAt, deliveryState = message.deliveryState, @@ -2335,6 +2648,43 @@ private fun MessageBubble( } } +@Composable +private fun ChannelDiscussionLink( + commentCount: Int, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + val label = if (commentCount > 0) { + "${formatCompactCount(commentCount)} комментариев" + } else { + "Комментировать" + } + + Surface( + color = Color(0xFFEAF4FB), + shape = RoundedCornerShape(16.dp), + modifier = modifier.clickable(onClick = onClick) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Forum, + contentDescription = "Комментарии", + tint = TelegramBlue, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = TelegramBlue + ) + } + } +} + @Composable private fun SelectionIndicator() { Icon( @@ -2345,15 +2695,115 @@ private fun SelectionIndicator() { ) } +@Composable +private fun EmojiStickerSuggestionPanel( + items: List, + onMediaSelected: (PickerMediaItem) -> Unit +) { + Surface( + color = Color(0xDDF2FBFF), + shape = RoundedCornerShape(18.dp), + shadowElevation = 2.dp, + modifier = Modifier + .fillMaxWidth() + .height(PickerSuggestionPanelHeight) + ) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = PickerSuggestionGridMinCellWidth), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(items = items, key = { item -> "suggestion-${item.key}" }) { item -> + PickerMediaSuggestionTile( + item = item, + onClick = { onMediaSelected(item) } + ) + } + } + } +} + +@Composable +private fun PickerMediaSuggestionTile( + item: PickerMediaItem, + onClick: () -> Unit +) { + val isGif = item.kind == PickerMediaKind.Gif + val gifPreviewRequest = rememberPickerGifPreviewRequest(item = item, isGif = isGif) + + Surface( + color = Color.Transparent, + shape = RoundedCornerShape(14.dp), + modifier = Modifier + .fillMaxWidth() + .height(PickerSuggestionGridCellHeight) + .clickable(onClick = onClick) + ) { + Box(contentAlignment = Alignment.Center) { + Surface( + color = if (isGif) Color.White.copy(alpha = 0.72f) else Color(item.backgroundColor), + shape = RoundedCornerShape(if (isGif) 14.dp else 18.dp), + modifier = Modifier.size(if (isGif) 62.dp else 66.dp) + ) { + Box(contentAlignment = Alignment.Center) { + if (gifPreviewRequest != null) { + AsyncImage( + model = gifPreviewRequest, + contentDescription = item.title, + modifier = Modifier + .fillMaxSize() + .padding(4.dp), + contentScale = ContentScale.Fit + ) + } else { + Text( + text = item.previewEmoji, + fontSize = if (isGif) 28.sp else 34.sp + ) + } + } + } + if (isGif) { + Surface( + color = TelegramBlue, + shape = RoundedCornerShape(7.dp), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 2.dp, bottom = 2.dp) + ) { + Text( + text = "GIF", + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + color = Color.White + ) + } + } + } + } +} + @Composable private fun EmojiKeyboardPanel( categories: List, height: Dp, - onEmojiSelected: (String) -> Unit + activeTab: EmojiKeyboardTab, + allowMediaTabs: Boolean, + gifPacks: List, + stickerPacks: List, + onTabSelected: (EmojiKeyboardTab) -> Unit, + onDismiss: () -> Unit, + onEmojiSelected: (String) -> Unit, + onMediaSelected: (PickerMediaItem) -> Unit ) { val gridState = rememberLazyGridState() val coroutineScope = rememberCoroutineScope() val categoryScrollState = rememberScrollState() + val resolvedActiveTab = if (allowMediaTabs) activeTab else EmojiKeyboardTab.Emoji + var selectedGifPackKey by remember(gifPacks) { mutableStateOf(gifPacks.firstOrNull()?.key.orEmpty()) } + var selectedStickerPackKey by remember(stickerPacks) { mutableStateOf(stickerPacks.firstOrNull()?.key.orEmpty()) } val emojiGridItems = remember(categories) { var globalIndex = 0 categories.flatMapIndexed { categoryIndex, category -> @@ -2384,6 +2834,12 @@ private fun EmojiKeyboardPanel( ?: 0 } } + val emojiGridViewportHeight = remember(height, allowMediaTabs) { + val tabHeight = if (allowMediaTabs) EmojiKeyboardBottomTabsHeight else 0.dp + val availableHeight = (height - EmojiCategoryStripHeight - tabHeight).coerceAtLeast(EmojiGridCellHeight * 2f) + val fourRowsHeight = EmojiGridCellHeight * EmojiGridVisibleRows.toFloat() + availableHeight.coerceAtMost(fourRowsHeight) + } Column( modifier = Modifier @@ -2391,6 +2847,158 @@ private fun EmojiKeyboardPanel( .height(height) .background(TelegramSurface) ) { + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + when (resolvedActiveTab) { + EmojiKeyboardTab.Emoji -> EmojiKeyboardEmojiPage( + categories = categories, + categoryScrollState = categoryScrollState, + gridState = gridState, + emojiGridItems = emojiGridItems, + categoryFirstIndexes = categoryFirstIndexes, + selectedCategoryIndex = selectedCategoryIndex, + gridViewportHeight = emojiGridViewportHeight, + coroutineScope = coroutineScope, + onEmojiSelected = onEmojiSelected + ) + + EmojiKeyboardTab.Gif -> { + val selectedPack = gifPacks.firstOrNull { it.key == selectedGifPackKey } ?: gifPacks.firstOrNull() + EmojiKeyboardMediaPage( + packs = gifPacks, + selectedPackKey = selectedPack?.key.orEmpty(), + onPackSelected = { selectedGifPackKey = it }, + items = selectedPack?.items.orEmpty(), + isGif = true, + onMediaSelected = onMediaSelected + ) + } + + EmojiKeyboardTab.Stickers -> { + val selectedPack = stickerPacks.firstOrNull { it.key == selectedStickerPackKey } ?: stickerPacks.firstOrNull() + EmojiKeyboardMediaPage( + packs = stickerPacks, + selectedPackKey = selectedPack?.key.orEmpty(), + onPackSelected = { selectedStickerPackKey = it }, + items = selectedPack?.items.orEmpty(), + isGif = false, + onMediaSelected = onMediaSelected + ) + } + } + } + + if (allowMediaTabs) { + HorizontalDivider(color = Color(0xFFE6EEF6)) + EmojiKeyboardTabRow( + activeTab = resolvedActiveTab, + onTabSelected = onTabSelected, + onDismiss = onDismiss + ) + } + } +} + +@Composable +private fun EmojiKeyboardTabRow( + activeTab: EmojiKeyboardTab, + onTabSelected: (EmojiKeyboardTab) -> Unit, + onDismiss: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 7.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.weight(1f)) + Surface( + color = Color(0xFFFFFFFF), + shape = RoundedCornerShape(19.dp), + shadowElevation = 2.dp + ) { + Row( + modifier = Modifier.padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + EmojiKeyboardTabChip( + text = "\u042d\u043c\u043e\u0434\u0437\u0438", + selected = activeTab == EmojiKeyboardTab.Emoji, + onClick = { onTabSelected(EmojiKeyboardTab.Emoji) } + ) + EmojiKeyboardTabChip( + text = "GIF", + selected = activeTab == EmojiKeyboardTab.Gif, + onClick = { onTabSelected(EmojiKeyboardTab.Gif) } + ) + EmojiKeyboardTabChip( + text = "\u0421\u0442\u0438\u043a\u0435\u0440\u044b", + selected = activeTab == EmojiKeyboardTab.Stickers, + onClick = { onTabSelected(EmojiKeyboardTab.Stickers) } + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + Surface( + color = Color(0xFFFFFFFF), + shape = CircleShape, + shadowElevation = 2.dp, + modifier = Modifier.size(38.dp).clickable(onClick = onDismiss) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Close emoji panel", + tint = TelegramInkMuted, + modifier = Modifier.size(22.dp) + ) + } + } + } +} + +@Composable +private fun EmojiKeyboardTabChip( + text: String, + selected: Boolean, + onClick: () -> Unit +) { + Surface( + color = if (selected) Color(0xFFECE8DF) else Color.Transparent, + shape = RoundedCornerShape(17.dp), + modifier = Modifier + .height(34.dp) + .widthIn(min = if (text == "GIF") 54.dp else 86.dp) + .clickable(onClick = onClick) + ) { + Box( + modifier = Modifier.padding(horizontal = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), + color = if (selected) TelegramInk else TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} +@Composable +private fun EmojiKeyboardEmojiPage( + categories: List, + categoryScrollState: androidx.compose.foundation.ScrollState, + gridState: androidx.compose.foundation.lazy.grid.LazyGridState, + emojiGridItems: List, + categoryFirstIndexes: List, + selectedCategoryIndex: Int, + gridViewportHeight: Dp, + coroutineScope: kotlinx.coroutines.CoroutineScope, + onEmojiSelected: (String) -> Unit +) { + Column(modifier = Modifier.fillMaxWidth().fillMaxHeight()) { Row( modifier = Modifier .fillMaxWidth() @@ -2414,14 +3022,14 @@ private fun EmojiKeyboardPanel( HorizontalDivider(color = Color(0xFFE6EEF6)) LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = EmojiGridMinCellWidth), + columns = GridCells.Fixed(EmojiGridColumns), state = gridState, modifier = Modifier .fillMaxWidth() - .weight(1f), - contentPadding = PaddingValues(start = 6.dp, top = 8.dp, end = 6.dp, bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalArrangement = Arrangement.spacedBy(2.dp) + .height(gridViewportHeight), + contentPadding = PaddingValues(start = 4.dp, top = 6.dp, end = 4.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { items( items = emojiGridItems, @@ -2476,7 +3084,21 @@ private fun EmojiPickerButton( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Text(text = emoji, fontSize = 22.sp) + Text( + text = emoji, + style = MaterialTheme.typography.headlineMedium.copy( + fontSize = 28.sp, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.32f), + offset = Offset(0f, 5f), + blurRadius = 6f + ) + ), + modifier = Modifier.graphicsLayer { + scaleX = 1.04f + scaleY = 1.04f + } + ) } } } @@ -2517,6 +3139,287 @@ private fun ReplyPreviewCard( } } +@Composable +private fun StoryReplyPreviewCard( + title: String, + previewText: String, + attachmentKind: String?, + modifier: Modifier = Modifier +) { + Surface( + color = Color(0xFFEAF4FF), + shape = RoundedCornerShape(12.dp), + shadowElevation = 0.dp, + modifier = modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + color = TelegramBlue, + shape = CircleShape, + modifier = Modifier.size(28.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = storyReplyKindIcon(attachmentKind), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(17.dp) + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = TelegramBlue, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = previewText, + style = MaterialTheme.typography.bodySmall, + color = TelegramInk, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun EmojiKeyboardMediaPage( + packs: List, + selectedPackKey: String, + onPackSelected: (String) -> Unit, + items: List, + isGif: Boolean, + onMediaSelected: (PickerMediaItem) -> Unit +) { + val packScrollState = rememberScrollState() + + Column(modifier = Modifier.fillMaxWidth().fillMaxHeight()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(packScrollState) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + packs.forEach { pack -> + PickerMediaPackChip( + pack = pack, + selected = pack.key == selectedPackKey, + onClick = { onPackSelected(pack.key) } + ) + } + } + + HorizontalDivider(color = Color(0xFFE6EEF6)) + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = PickerMediaGridMinCellWidth), + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items = items, key = { item -> item.key }) { item -> + PickerMediaButton( + item = item, + isGif = isGif, + onClick = { onMediaSelected(item) } + ) + } + } + } +} + +@Composable +private fun PickerMediaPackChip( + pack: PickerMediaPack, + selected: Boolean, + onClick: () -> Unit +) { + Surface( + color = if (selected) Color(0xFFE7F1FF) else Color(0xFFF4F7FA), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .height(34.dp) + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = pack.icon, fontSize = 16.sp) + Text( + text = pack.title, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = if (selected) TelegramBlue else TelegramInk + ) + } + } +} + +@Composable +private fun PickerMediaButton( + item: PickerMediaItem, + isGif: Boolean, + onClick: () -> Unit +) { + val gifPreviewRequest = rememberPickerGifPreviewRequest(item = item, isGif = isGif) + + Surface( + color = Color(0xFFF7FAFD), + shape = RoundedCornerShape(14.dp), + modifier = Modifier + .fillMaxWidth() + .height(PickerMediaGridCellHeight) + .clickable(onClick = onClick) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box(contentAlignment = Alignment.Center) { + Surface( + color = Color(item.backgroundColor).copy(alpha = if (isGif) 0.58f else 1f), + shape = RoundedCornerShape(if (isGif) 16.dp else 20.dp), + modifier = Modifier.size(58.dp) + ) { + Box(contentAlignment = Alignment.Center) { + if (gifPreviewRequest != null) { + AsyncImage( + model = gifPreviewRequest, + contentDescription = item.title, + modifier = Modifier + .fillMaxSize() + .padding(4.dp), + contentScale = ContentScale.Fit + ) + } else { + Text(text = item.previewEmoji, fontSize = if (isGif) 26.sp else 30.sp) + } + } + } + if (isGif) { + Surface( + color = TelegramBlue, + shape = RoundedCornerShape(7.dp), + modifier = Modifier.align(Alignment.BottomEnd) + ) { + Text( + text = "GIF", + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + color = Color.White + ) + } + } + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = item.title, + style = MaterialTheme.typography.labelSmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun rememberPickerGifPreviewRequest( + item: PickerMediaItem, + isGif: Boolean +): ImageRequest? { + val context = LocalContext.current + val gifPreviewBytes = remember(item.key, item.base64Data, isGif) { + if (!isGif || item.base64Data.isNullOrBlank()) { + null + } else { + runCatching { Base64.decode(item.base64Data, Base64.DEFAULT) }.getOrNull() + } + } + return remember(context, item.key, gifPreviewBytes) { + gifPreviewBytes?.let { bytes -> + ImageRequest.Builder(context) + .data(bytes) + .memoryCacheKey("picker-gif-${item.key}") + .diskCacheKey("picker-gif-${item.key}") + .crossfade(false) + .build() + } + } +} + +private val SuggestedPickerMediaDefaultKeys = listOf( + "gif_ikar_spark", + "gif_heart", + "gif_fire", + "gif_ikar_pulse", + "gif_ikar_launch", + "sticker_laugh", + "sticker_love", + "sticker_ok", + "sticker_ikar_wow", + "sticker_ikar_power", + "sticker_ikar_focus", + "sticker_ikar_hug" +) + +private fun suggestedPickerMediaForEmoji( + emoji: String?, + gifPacks: List, + stickerPacks: List +): List { + val emojiKey = emoji?.normalizedEmojiSuggestionKey().orEmpty() + if (emojiKey.isBlank()) { + return emptyList() + } + + val allItems = (gifPacks + stickerPacks).flatMap { pack -> pack.items } + val exactItems = allItems.filter { item -> item.matchesSuggestedEmoji(emojiKey) } + val defaultItems = SuggestedPickerMediaDefaultKeys.mapNotNull { key -> + allItems.firstOrNull { item -> item.key == key } + } + + return (exactItems + defaultItems + allItems) + .distinctBy { item -> item.key } + .take(12) +} + +private fun PickerMediaItem.matchesSuggestedEmoji(emojiKey: String): Boolean = + previewEmoji.normalizedEmojiSuggestionKey() == emojiKey || + stickerEmoji?.normalizedEmojiSuggestionKey() == emojiKey + +private fun String.normalizedEmojiSuggestionKey(): String = + replace("\uFE0F", "") + .replace("\uFE0E", "") + .trim() + +private fun storyReplyKindIcon(kind: String?): ImageVector = + when (kind) { + "image" -> Icons.Outlined.Image + "video" -> Icons.Outlined.Videocam + "audio" -> Icons.Outlined.PlayArrow + "media" -> Icons.Outlined.Image + else -> Icons.Outlined.Visibility + } + @Composable private fun ReactionRow( reactions: List, @@ -2549,10 +3452,10 @@ private fun MessageText( text: String, modifier: Modifier = Modifier, textSizeSp: Int, + onUrlClick: (String) -> Unit, onTap: () -> Unit, onLongPress: () -> Unit ) { - val context = LocalContext.current var revealedSpoilers by remember(text) { mutableStateOf>(emptySet()) } val annotatedText = remember(text, revealedSpoilers) { buildMessageAnnotatedText(text, revealedSpoilers) @@ -2572,7 +3475,7 @@ private fun MessageText( .getStringAnnotations(tag = MessageSpoilerAnnotationTag, start = offset, end = offset) .firstOrNull() when { - urlAnnotation != null -> openExternalUrl(context, urlAnnotation.item) + urlAnnotation != null -> onUrlClick(urlAnnotation.item) spoilerAnnotation != null -> { val key = spoilerAnnotation.item revealedSpoilers = if (revealedSpoilers.contains(key)) { @@ -2592,6 +3495,40 @@ private fun MessageText( ) } +private fun isLargeEmojiMessage(text: String): Boolean { + val trimmed = text.trim() + if (trimmed.isEmpty() || trimmed.length > 32) { + return false + } + + var emojiCount = 0 + var index = 0 + while (index < trimmed.length) { + val codePoint = trimmed.codePointAt(index) + when { + Character.isWhitespace(codePoint) -> Unit + isEmojiBaseCodePoint(codePoint) -> emojiCount += 1 + isEmojiJoinerOrModifier(codePoint) -> Unit + else -> return false + } + index += Character.charCount(codePoint) + } + return emojiCount in 1..3 +} + +private fun isEmojiBaseCodePoint(codePoint: Int): Boolean = + codePoint in 0x1F1E6..0x1F1FF || + codePoint in 0x1F300..0x1FAFF || + codePoint in 0x2600..0x27BF || + codePoint in 0x2B00..0x2BFF + +private fun isEmojiJoinerOrModifier(codePoint: Int): Boolean = + codePoint == 0x200D || + codePoint == 0x20E3 || + codePoint == 0xFE0E || + codePoint == 0xFE0F || + codePoint in 0x1F3FB..0x1F3FF + @Composable private fun AttachmentAlbumGrid( attachments: List, @@ -2758,6 +3695,188 @@ private fun InlineMessageImage( ) } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun InlineMessageAudio( + audio: InlineAudioUrl, + playbackState: VoiceAttachmentPlaybackUiState?, + onClick: () -> Unit, + onLongPress: () -> Unit +) { + Surface( + color = Color(0xFFF3F8FD), + shape = RoundedCornerShape(16.dp), + shadowElevation = 0.dp, + modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongPress) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface(color = TelegramBlue, shape = CircleShape) { + Box(modifier = Modifier.padding(10.dp), contentAlignment = Alignment.Center) { + Icon( + imageVector = if (playbackState?.isPlaying == true) Icons.Outlined.Stop else Icons.Outlined.PlayArrow, + contentDescription = "Voice message", + tint = Color.White + ) + } + } + val durationMs = playbackState?.durationMs?.takeIf { it > 0L } ?: 0L + val progress = when { + durationMs > 0L -> (playbackState?.positionMs ?: 0L).toFloat() / durationMs.toFloat() + else -> 0f + }.coerceIn(0f, 1f) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = audio.title, + style = MaterialTheme.typography.titleSmall, + color = TelegramInk, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + color = TelegramBlue, + trackColor = Color(0xFFD6E5F5) + ) + Text( + text = buildInlineAudioSecondaryText(playbackState), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TelegramMediaAttachment( + attachment: AttachmentDto, + attachmentUrl: String, + accessToken: String?, + isOutgoing: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, + onLongPress: () -> Unit +) { + val isSticker = attachment.isTelegramStickerAttachment() + val mediaSize = if (isSticker) 164.dp else 220.dp + val shape = if (isSticker) RoundedCornerShape(0.dp) else RoundedCornerShape(18.dp) + val backgroundColor = if (isSticker) Color.Transparent else Color(0xFFF7FAFD) + + Box( + modifier = modifier + .size(mediaSize) + .clip(shape) + .background(backgroundColor) + .combinedClickable(onClick = onClick, onLongClick = onLongPress), + contentAlignment = if (isOutgoing) Alignment.BottomEnd else Alignment.BottomStart + ) { + AuthenticatedAsyncImage( + imageUrl = attachmentUrl, + accessToken = accessToken, + contentDescription = if (isSticker) "Sticker" else "GIF", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun VideoAttachmentCard( + attachment: AttachmentDto, + onClick: () -> Unit, + onLongPress: () -> Unit +) { + Surface( + color = Color(0xFFF7FAFD), + shape = RoundedCornerShape(16.dp), + shadowElevation = 0.dp, + modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongPress) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(208.dp) + .background(Color(0xFF17212B)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Videocam, + contentDescription = null, + tint = Color.White.copy(alpha = 0.24f), + modifier = Modifier + .align(Alignment.TopStart) + .padding(14.dp) + .size(28.dp) + ) + Surface( + color = Color.White.copy(alpha = 0.92f), + shape = CircleShape, + shadowElevation = 2.dp + ) { + Icon( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = "Play video", + tint = TelegramBlue, + modifier = Modifier + .padding(16.dp) + .size(34.dp) + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = Icons.Outlined.Videocam, + contentDescription = null, + tint = TelegramBlue, + modifier = Modifier.size(20.dp) + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = attachment.fileName, + style = MaterialTheme.typography.titleSmall, + color = TelegramInk, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${attachment.contentType} \u2022 ${formatFileSize(attachment.fileSizeBytes)}", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + @Composable private fun AttachmentCard( attachment: AttachmentDto, @@ -2783,7 +3902,7 @@ private fun AttachmentCard( ) } - attachment.kind == AttachmentKind.VoiceNote -> { + attachment.isVoiceNoteAttachment() -> { Surface( color = Color(0xFFF3F8FD), shape = RoundedCornerShape(16.dp), @@ -2838,6 +3957,14 @@ private fun AttachmentCard( } } + attachment.isVideoAttachment() -> { + VideoAttachmentCard( + attachment = attachment, + onClick = onClick, + onLongPress = onLongPress + ) + } + else -> { Surface( color = Color(0xFFF7FAFD), @@ -2932,18 +4059,75 @@ private data class MessageUrlRange( private data class MessageDisplayContent( val text: String, - val imageUrls: List + val imageUrls: List, + val audioUrls: List ) +private data class InlineAudioUrl( + val id: String, + val url: String, + val title: String +) + +private fun MessageDto.standaloneTelegramMediaAttachment(displayContent: MessageDisplayContent): AttachmentDto? { + if (attachments.size != 1 || + displayContent.text.isNotBlank() || + displayContent.imageUrls.isNotEmpty() || + displayContent.audioUrls.isNotEmpty() + ) { + return null + } + + return attachments.firstOrNull()?.takeIf { it.isTelegramMediaAttachment() } +} + +private fun AttachmentDto.isTelegramMediaAttachment(): Boolean = + isTelegramGifAttachment() || isTelegramStickerAttachment() + +private fun AttachmentDto.isTelegramGifAttachment(): Boolean { + val normalizedContentType = contentType + .substringBefore(';') + .trim() + .lowercase(Locale.US) + val normalizedFileName = fileName + .substringBefore('?') + .substringBefore('#') + .trim() + .lowercase(Locale.US) + return normalizedContentType == "image/gif" || + normalizedFileName.endsWith(".gif") +} + +private fun AttachmentDto.isTelegramStickerAttachment(): Boolean { + val normalizedContentType = contentType + .substringBefore(';') + .trim() + .lowercase(Locale.US) + val normalizedFileName = fileName + .substringBefore('?') + .substringBefore('#') + .trim() + .lowercase(Locale.US) + val isBuiltInStickerName = normalizedFileName.startsWith("ikar_sticker_") || + normalizedFileName.startsWith("ikar-sticker-") || + normalizedFileName.contains("_sticker_") + val isImageFile = normalizedContentType in setOf("image/png", "image/webp") || + normalizedFileName.endsWith(".png") || + normalizedFileName.endsWith(".webp") + return isBuiltInStickerName && isImageFile +} + private val ImageLabelRegex = Regex("""(^|[\s:;,.!?()\[\]{}-])изображени[еяю]?([\s:;,.!?()\[\]{}-]|$)""", RegexOption.IGNORE_CASE) private val ImageFileExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp", "heic", "heif") +private val AudioFileExtensions = setOf("aac", "flac", "m4a", "mp3", "oga", "ogg", "opus", "wav", "webm") private fun buildMessageDisplayContent(text: String): MessageDisplayContent { if (text.isBlank()) { - return MessageDisplayContent(text = text, imageUrls = emptyList()) + return MessageDisplayContent(text = text, imageUrls = emptyList(), audioUrls = emptyList()) } val imageUrls = mutableListOf() + val audioUrls = mutableListOf() val visibleLines = mutableListOf() var pendingImageLabel = false @@ -2964,14 +4148,23 @@ private fun buildMessageDisplayContent(text: String): MessageDisplayContent { val imageRanges = ranges .filter { range -> isInlineImageUrl(line, range, pendingImageLabel || hasImageLabel) } - if (imageRanges.isEmpty()) { + val audioRanges = ranges + .filter { range -> isAudioUrl(range.url) } + if (imageRanges.isEmpty() && audioRanges.isEmpty()) { visibleLines += line pendingImageLabel = false return@forEach } imageUrls += imageRanges.map { it.url } - val cleanedLine = cleanInlineImageLine(line, imageRanges) + audioUrls += audioRanges.map { range -> + InlineAudioUrl( + id = range.url, + url = range.url, + title = buildInlineAudioTitle(line, range) + ) + } + val cleanedLine = cleanInlineMediaLine(line, imageRanges, audioRanges) if (cleanedLine.isNotBlank()) { visibleLines += cleanedLine } @@ -2980,7 +4173,8 @@ private fun buildMessageDisplayContent(text: String): MessageDisplayContent { return MessageDisplayContent( text = visibleLines.joinToString("\n").trim(), - imageUrls = imageUrls.distinct() + imageUrls = imageUrls.distinct(), + audioUrls = audioUrls.distinctBy { it.url } ) } @@ -2988,13 +4182,30 @@ private fun isInlineImageUrl(line: String, range: MessageUrlRange, forcedByLabel forcedByLabel || isImageUrl(range.url) || ImageLabelRegex.containsMatchIn(line) private fun cleanInlineImageLine(line: String, imageRanges: List): String { + return cleanInlineMediaLine(line, imageRanges, emptyList(), removeImageLabel = true) +} + +private fun cleanInlineMediaLine( + line: String, + imageRanges: List, + audioRanges: List, + removeImageLabel: Boolean = imageRanges.isNotEmpty() +): String { var result = line - imageRanges + (imageRanges + audioRanges) + .distinctBy { it.start to it.end } .sortedByDescending { it.start } .forEach { range -> result = result.removeRange(range.start, range.end) } - return ImageLabelRegex - .replace(result, " ") + if (removeImageLabel) { + result = ImageLabelRegex.replace(result, " ") + } + + if (audioRanges.isNotEmpty() && result.trim().looksLikeAudioFileName()) { + result = "" + } + + return result .replace(Regex("""[ \t]{2,}"""), " ") .trim() .trim(':', ';', ',', '.', '-', '–', '—', '•') @@ -3020,6 +4231,39 @@ private fun isImageUrl(url: String): Boolean { } } +private fun isAudioUrl(url: String): Boolean { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false + val path = uri.path.orEmpty() + if (path.looksLikeAudioFileName()) { + return true + } + + val lastPathSegment = uri.lastPathSegment.orEmpty() + if (lastPathSegment.looksLikeAudioFileName()) { + return true + } + + val query = uri.query.orEmpty().lowercase(Locale.US) + return AudioFileExtensions.any { extension -> + query.contains("format=$extension") || + query.contains("fm=$extension") || + query.contains("ext=$extension") || + query.contains("audio/$extension") + } +} + +private fun buildInlineAudioTitle(line: String, range: MessageUrlRange): String { + val titleFromLine = cleanInlineMediaLine(line, emptyList(), listOf(range)) + if (titleFromLine.isNotBlank() && !titleFromLine.equals(range.url, ignoreCase = true)) { + return titleFromLine + } + + return runCatching { Uri.parse(range.url).lastPathSegment } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?: "Voice message" +} + private fun buildMessageAnnotatedText(text: String, revealedSpoilers: Set = emptySet()): AnnotatedString { val builder = AnnotatedString.Builder(text) extractMessageUrlRanges(text).forEach { range -> @@ -3254,6 +4498,102 @@ private fun ImagePreviewDialog( ) } +@Composable +private fun ChatVideoPreviewDialog( + entry: ChatVideoPreviewEntry, + accessToken: String?, + videoUrl: String, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val player = remember(videoUrl, accessToken) { + val httpDataSourceFactory = DefaultHttpDataSource.Factory().apply { + accessToken?.takeIf { it.isNotBlank() }?.let { token -> + setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token")) + } + } + val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory) + ExoPlayer.Builder(context).build().apply { + repeatMode = Player.REPEAT_MODE_OFF + playWhenReady = true + setMediaSource( + ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(MediaItem.fromUri(videoUrl)) + ) + prepare() + } + } + + DisposableEffect(player) { + onDispose { player.release() } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.72f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + ImagePreviewHeaderButton( + icon = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + onClick = onDismiss + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = entry.message.sender.displayName, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = entry.attachment.fileName, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + AndroidView( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color.Black), + factory = { viewContext -> + PlayerView(viewContext).apply { + useController = true + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + this.player = player + } + }, + update = { view -> + view.player = player + } + ) + } + } + } +} + @Composable private fun ChatImagePreviewDialog( entry: ChatImagePreviewEntry, @@ -3480,6 +4820,7 @@ private fun ImagePreviewEdgeTapZone( private fun buildMessageMenuItems( message: MessageDto, currentUserId: String?, + canPostAsChannel: Boolean, onCopy: () -> Unit, onForward: () -> Unit, onReply: () -> Unit, @@ -3535,7 +4876,7 @@ private fun buildMessageMenuItems( ) } - if (message.canManage(currentUserId)) { + if (message.canManage(currentUserId, canPostAsChannel)) { actions.add( MessageActionItem( label = "Изменить", @@ -3553,7 +4894,7 @@ private fun buildMessageMenuItems( ) } - if (!message.canManage(currentUserId) && message.canDelete(currentUserId)) { + if (!message.canManage(currentUserId, canPostAsChannel) && message.canDelete(currentUserId)) { actions.add(buildDeleteMessageAction(onDelete)) } @@ -3721,3 +5062,13 @@ private fun buildVoiceNoteSecondaryText( return if (positionText != null) "$positionText / $durationText" else durationText } + +private fun buildInlineAudioSecondaryText(playbackState: VoiceAttachmentPlaybackUiState?): String { + val durationText = playbackState?.durationMs?.takeIf { it > 0L }?.let(::formatDurationMs) + val positionText = playbackState?.positionMs?.takeIf { it > 0L }?.let(::formatDurationMs) + return when { + durationText != null && positionText != null -> "$positionText / $durationText" + durationText != null -> durationText + else -> "Voice message" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt index b6f23102..ab7eb244 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope import com.seven.ikar.kotlin.app.AppContainer import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer import com.seven.ikar.kotlin.core.model.AttachmentDto +import com.seven.ikar.kotlin.core.model.AttachmentKind import com.seven.ikar.kotlin.core.model.AuthSessionDto import com.seven.ikar.kotlin.core.model.BotCommandDto import com.seven.ikar.kotlin.core.model.CallMediaType @@ -37,6 +38,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File import java.time.OffsetDateTime import java.time.ZoneId import java.time.ZoneOffset @@ -111,6 +113,7 @@ data class ChatUiState( val canSubscribe: Boolean = false, val canManageChannel: Boolean = false, val canPostAsChannel: Boolean = false, + val canDeleteForEveryone: Boolean = false, val publicUsername: String? = null, val linkedDiscussionChatId: String? = null, val channelDescription: String? = null, @@ -128,6 +131,7 @@ data class ChatUiState( val attachmentPlayback: Map = emptyMap(), val botCommands: List = emptyList(), val previewImageAttachmentId: String? = null, + val previewVideoAttachmentId: String? = null, val firstUnreadMessageId: String? = null, val selectedMessageIds: Set = emptySet(), val isDeleteSelectionDialogVisible: Boolean = false, @@ -139,7 +143,8 @@ data class ChatUiState( val scrollRequest: ChatScrollRequest? = null, val isSendingMessage: Boolean = false, val isDeletingChat: Boolean = false, - val isChatDeleted: Boolean = false + val isChatDeleted: Boolean = false, + val attachmentCacheVersion: Long = 0L ) { val isMessageSelectionMode: Boolean get() = selectedMessageIds.isNotEmpty() @@ -165,7 +170,13 @@ class ChatViewModel( private val container: AppContainer, private val chatId: String ) : ViewModel() { - private val _uiState = MutableStateFlow(ChatUiState(chatId = chatId, session = container.sessionStore.read())) + private val _uiState = MutableStateFlow( + ChatUiState( + chatId = chatId, + session = container.sessionStore.read(), + draftText = container.chatDraftStore.read(chatId) + ) + ) val uiState: StateFlow = _uiState.asStateFlow() private val cachedAttachmentFiles = mutableMapOf() @@ -179,6 +190,8 @@ class ChatViewModel( private var searchJob: Job? = null private var typingStateSent = false private var lastTypingSentAtMillis = 0L + private var lastLocallyReadMessageId: String? = null + private var lastDeliveredMessageId: String? = null init { attachRealtime() @@ -192,6 +205,9 @@ class ChatViewModel( fun updateDraft(value: String) { _uiState.value = _uiState.value.copy(draftText = value) + if (editingMessage == null) { + container.chatDraftStore.save(chatId, value) + } notifyTyping(value.isNotBlank()) } @@ -307,6 +323,56 @@ class ChatViewModel( _uiState.value = _uiState.value.copy(draftText = _uiState.value.draftText + emoji) } + internal fun sendPickerMedia(item: PickerMediaItem) { + val state = _uiState.value + if (!state.canSendMessages || state.isSendingMessage) { + return + } + + if (editingMessage != null) { + _uiState.value = state.copy(errorMessage = "Сначала завершите редактирование сообщения.") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSendingMessage = true, errorMessage = null) + runCatching { + val attachment = when (item.kind) { + PickerMediaKind.Gif -> container.attachmentFileService.createBuiltInGifAttachment( + fileName = item.fileName, + base64Data = item.base64Data ?: error("GIF data is missing.") + ) + + PickerMediaKind.Sticker -> container.attachmentFileService.createBuiltInStickerAttachment( + fileName = item.fileName, + emoji = item.stickerEmoji ?: item.previewEmoji, + label = item.stickerLabel ?: item.title, + backgroundColor = item.backgroundColor, + accentColor = item.accentColor + ) + } + + container.transferQueueRepository.enqueueUpload( + chatId = chatId, + text = null, + replyToMessageId = state.replyTo?.messageId, + files = listOf(attachment) + ) + }.onSuccess { + _uiState.value = _uiState.value.copy( + replyTo = null, + isSendingMessage = false, + errorMessage = null + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isSendingMessage = false, + errorMessage = error.message + ) + } + } + } + fun clearErrorMessage() { _uiState.value = _uiState.value.copy(errorMessage = null) } @@ -350,6 +416,12 @@ class ChatViewModel( if (messageId.isBlank()) { return } + val state = _uiState.value + if (state.messages.none { it.id == messageId }) { + requestScrollToMessageInternal(messageId) + loadMessagesAround(messageId) + return + } requestScrollToMessageInternal(messageId) } @@ -503,6 +575,57 @@ class ChatViewModel( } } + private fun loadMessagesAround(messageId: String) { + val state = _uiState.value + if (state.isLoadingOlderMessages || state.isLoading) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingOlderMessages = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + session to container.apiClient.getChatMessagesAround( + accessToken = session.accessToken, + chatId = chatId, + aroundMessageId = messageId + ) + } + }.onSuccess { (session, page) -> + val currentState = _uiState.value + if (page.messages.none { it.id == messageId }) { + _uiState.value = currentState.copy( + isLoadingOlderMessages = false, + scrollRequest = null, + errorMessage = "Сообщение не найдено." + ) + return@onSuccess + } + + val mergedMessages = (page.messages + currentState.messages) + .distinctBy { it.id } + .sortedBy { it.sentAt } + val selectedMessageIds = sanitizeSelectedMessageIds(currentState.selectedMessageIds, mergedMessages) + + _uiState.value = currentState.copy( + messages = mergedMessages, + hasMoreMessages = page.hasMoreMessages || currentState.hasMoreMessages, + isLoadingOlderMessages = false, + selectedMessageIds = selectedMessageIds, + isDeleteSelectionDialogVisible = currentState.isDeleteSelectionDialogVisible && selectedMessageIds.isNotEmpty(), + isDeletingSelectedMessages = currentState.isDeletingSelectedMessages && selectedMessageIds.isNotEmpty() + ) + cacheMessages(session.user.id, page.messages) + requestScrollToMessageInternal(messageId) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoadingOlderMessages = false, + errorMessage = error.message + ) + } + } + } + fun sendMessage() { val state = _uiState.value if (!state.canSendMessages || state.isSendingMessage) { @@ -531,6 +654,7 @@ class ChatViewModel( ) }.onSuccess { clearPendingAttachment() + clearStoredDraft() _uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false) }.onFailure { error -> _uiState.value = _uiState.value.copy(isSendingMessage = false, errorMessage = error.message) @@ -546,6 +670,7 @@ class ChatViewModel( } if (optimisticMessage != null) { + clearStoredDraft() val optimisticMessages = (state.messages + optimisticMessage) .distinctBy { it.id } .sortedBy { it.sentAt } @@ -607,6 +732,7 @@ class ChatViewModel( if (localMessageId != null) { replaceLocalMessage(localMessageId, message) } else { + clearStoredDraft() _uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false) replaceOrInsertMessage(message) } @@ -666,6 +792,7 @@ class ChatViewModel( } }.onSuccess { message -> if (commandText.startsWith("/turn ", ignoreCase = true)) { + clearStoredDraft() _uiState.value = _uiState.value.copy(draftText = "") } replaceOrInsertMessage(message) @@ -684,6 +811,7 @@ class ChatViewModel( } }.onSuccess { ownerUserId -> clearPendingAttachment() + clearStoredDraft() cancelEditMessageInternal(clearDraft = true) container.chatScrollStateStore.clear(chatId) clearCachedChat(ownerUserId) @@ -706,7 +834,7 @@ class ChatViewModel( } } - fun deleteChat() { + fun deleteChat(forEveryone: Boolean = false) { val state = _uiState.value if (state.isDeletingChat || state.isChatDeleted) { return @@ -719,12 +847,13 @@ class ChatViewModel( container.apiClient.deleteChat( accessToken = session.accessToken, chatId = chatId, - forEveryone = false + forEveryone = forEveryone ) session.user.id } }.onSuccess { ownerUserId -> clearPendingAttachment() + clearStoredDraft() cancelEditMessageInternal(clearDraft = true) container.chatScrollStateStore.clear(chatId) container.pushSettings.resetChatNotificationPreferences(chatId) @@ -779,6 +908,7 @@ class ChatViewModel( } }.onSuccess { ownerUserId -> clearPendingAttachment() + clearStoredDraft() cancelEditMessageInternal(clearDraft = true) container.chatScrollStateStore.clear(chatId) container.pushSettings.resetChatNotificationPreferences(chatId) @@ -960,11 +1090,16 @@ class ChatViewModel( return } - if (attachment.kind == com.seven.ikar.kotlin.core.model.AttachmentKind.VoiceNote) { + if (attachment.isVoiceNoteAttachment()) { toggleVoiceAttachmentPlayback(attachment) return } + if (attachment.isVideoAttachment()) { + showVideoPreviewAttachment(attachment.id) + return + } + viewModelScope.launch { runCatching { val local = ensureAttachmentDownloaded(attachment) @@ -987,6 +1122,14 @@ class ChatViewModel( _uiState.value = _uiState.value.copy(previewImageAttachmentId = attachmentId) } + fun dismissVideoPreview() { + _uiState.value = _uiState.value.copy(previewVideoAttachmentId = null) + } + + fun showVideoPreviewAttachment(attachmentId: String) { + _uiState.value = _uiState.value.copy(previewVideoAttachmentId = attachmentId) + } + fun shareAttachmentExternally(attachment: AttachmentDto) { if (isAttachmentInProtectedMessage(attachment)) { _uiState.value = _uiState.value.copy(errorMessage = "Защищённый контент нельзя пересылать.") @@ -1034,7 +1177,9 @@ class ChatViewModel( viewModelScope.launch { runCatching { - container.voiceNoteRecorderService.start() + container.voiceNoteRecorderService.start( + pauseExternalAudio = container.chatSettings.snapshot.value.pauseMusicOnRecordEnabled + ) }.onSuccess { _uiState.value = _uiState.value.copy( isRecordingVoiceNote = true, @@ -1079,7 +1224,8 @@ class ChatViewModel( fileName = file.name, contentType = "audio/mp4", contentLength = file.length() - ) { file.inputStream() } + ) { file.inputStream() }, + isVoiceNote = true ) } } finally { @@ -1165,9 +1311,31 @@ class ChatViewModel( container.callManager.endCurrentCall() } - fun attachmentPreviewUrl(attachment: AttachmentDto): String = when { - attachment.downloadPath.startsWith("http://") || attachment.downloadPath.startsWith("https://") -> attachment.downloadPath - else -> ServerConfig.absoluteMessengerUrl(attachment.downloadPath) + fun attachmentPreviewUrl(attachment: AttachmentDto): String { + val cachedFile = cachedAttachmentFiles[attachment.id] + ?.localPath + ?.let(::File) + ?.takeIf { it.exists() } + ?: container.attachmentFileService + .createAttachmentCacheFile(attachment.id, attachment.fileName) + .takeIf { it.exists() } + ?.also { file -> + cachedAttachmentFiles[attachment.id] = LocalAttachmentFile( + fileName = attachment.fileName, + contentType = attachment.contentType, + fileSizeBytes = file.length(), + localPath = file.absolutePath + ) + } + + if (cachedFile != null) { + return cachedFile.toURI().toString() + } + + return when { + attachment.downloadPath.startsWith("http://") || attachment.downloadPath.startsWith("https://") -> attachment.downloadPath + else -> ServerConfig.absoluteMessengerUrl(attachment.downloadPath) + } } fun showCallHeaderButton(state: ChatUiState = uiState.value): Boolean = @@ -1305,6 +1473,7 @@ class ChatViewModel( canSubscribe = chat.canSubscribe, canManageChannel = chat.canManageChannel, canPostAsChannel = chat.canPostAsChannel, + canDeleteForEveryone = chat.canDeleteForEveryone, publicUsername = chat.publicUsername, linkedDiscussionChatId = chat.linkedDiscussionChatId, channelDescription = chat.description, @@ -1326,6 +1495,11 @@ class ChatViewModel( } ) cacheMessages(session.user.id, chat.messages) + acknowledgeLatestIncomingDelivered(orderedMessages, session.user.id) + val pendingScrollMessageId = _uiState.value.scrollRequest?.messageId + if (pendingScrollMessageId != null && orderedMessages.none { it.id == pendingScrollMessageId }) { + loadMessagesAround(pendingScrollMessageId) + } scheduleAutoDownloads(orderedMessages) refreshCallBanner() } @@ -1335,9 +1509,8 @@ class ChatViewModel( container.realtimeService.messagesCreated.collect { message -> if (message.chatId == chatId) { replaceOrInsertMessage(message) - requestScrollToMessageInternal(message.id) if (message.sender.id != _uiState.value.session?.user?.id) { - acknowledgeRead(message.id) + acknowledgeDelivered(message.id) } } } @@ -1463,7 +1636,10 @@ class ChatViewModel( positionMs = event.positionMs, durationMs = event.durationMs ) - _uiState.value = _uiState.value.copy(attachmentPlayback = nextMap) + _uiState.value = _uiState.value.copy( + attachmentPlayback = nextMap, + errorMessage = event.errorMessage ?: _uiState.value.errorMessage + ) } } } @@ -1505,6 +1681,7 @@ class ChatViewModel( localPath = target.absolutePath ) cachedAttachmentFiles[attachment.id] = cached + notifyAttachmentCacheChanged() return cached } @@ -1517,10 +1694,27 @@ class ChatViewModel( val downloaded = container.apiClient.downloadAttachmentTo(session.accessToken, attachment, target) val cached = container.attachmentFileService.cacheDownloadedAttachment(downloaded) cachedAttachmentFiles[attachment.id] = cached + saveImageToGalleryIfEnabled(attachment, cached) + notifyAttachmentCacheChanged() cached } } + private suspend fun saveImageToGalleryIfEnabled(attachment: AttachmentDto, local: LocalAttachmentFile) { + val settings = container.appSettings.loadSnapshot() + if (!settings.saveMediaToGallery || !attachment.isImageAttachment()) { + return + } + + runCatching { + container.attachmentFileService.saveCachedImageToGalleryIfMissing( + localPath = local.localPath, + fileName = local.fileName, + contentType = local.contentType + ) + } + } + private fun scheduleAutoDownloads(messages: List) { val settings = container.appSettings.loadSnapshot() if (!settings.autoDownloadPhotos && !settings.autoDownloadVideosOnWifi && !settings.autoDownloadFilesOnWifi) { @@ -1549,7 +1743,7 @@ class ChatViewModel( isUnmetered: Boolean ): Boolean = when { attachment.isImageAttachment() -> settings.autoDownloadPhotos - attachment.contentType.startsWith("video/", ignoreCase = true) -> settings.autoDownloadVideosOnWifi && isUnmetered + attachment.isVideoAttachment() -> settings.autoDownloadVideosOnWifi && isUnmetered else -> settings.autoDownloadFilesOnWifi && isUnmetered } @@ -1572,7 +1766,22 @@ class ChatViewModel( val local = ensureAttachmentDownloaded(attachment) container.voiceNotePlayerService.toggle( attachmentId = attachment.id, - localFilePath = local.localPath + localFilePath = local.localPath, + pauseExternalAudio = container.chatSettings.snapshot.value.pauseMusicOnPlaybackEnabled + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun toggleInlineAudioPlayback(audioId: String, audioUrl: String) { + viewModelScope.launch { + runCatching { + container.voiceNotePlayerService.toggleUri( + attachmentId = audioId, + uri = Uri.parse(audioUrl), + pauseExternalAudio = container.chatSettings.snapshot.value.pauseMusicOnPlaybackEnabled ) }.onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) @@ -1585,12 +1794,16 @@ class ChatViewModel( _uiState.value = _uiState.value.copy(pendingAttachment = null) } + private fun clearStoredDraft() { + container.chatDraftStore.clear(chatId) + } + private fun cancelEditMessageInternal(clearDraft: Boolean) { editingMessage = null _uiState.value = _uiState.value.copy( editingMessageId = null, editingMessageBanner = "", - draftText = if (clearDraft) "" else _uiState.value.draftText + draftText = if (clearDraft) container.chatDraftStore.read(chatId) else _uiState.value.draftText ) } @@ -1708,6 +1921,11 @@ class ChatViewModel( } } + private fun notifyAttachmentCacheChanged() { + val currentState = _uiState.value + _uiState.value = currentState.copy(attachmentCacheVersion = currentState.attachmentCacheVersion + 1L) + } + private fun clearCachedChat(ownerUserId: String) { viewModelScope.launch(Dispatchers.IO) { container.chatMessageCache.clearChat(ownerUserId, chatId) @@ -1723,11 +1941,57 @@ class ChatViewModel( chatId = chatId, messageId = messageId ) + session.user.id + } + }.onSuccess { ownerUserId -> + markChatReadLocally(ownerUserId, messageId) + } + } + } + + private fun acknowledgeLatestIncomingDelivered(messages: List, currentUserId: String) { + val latestIncomingMessageId = messages + .asReversed() + .firstOrNull { message -> + message.sender.id != currentUserId && + !message.isDeleted() && + !message.isLocalMessage() + } + ?.id + ?: return + + acknowledgeDelivered(latestIncomingMessageId) + } + + private fun acknowledgeDelivered(messageId: String) { + if (lastDeliveredMessageId == messageId) { + return + } + lastDeliveredMessageId = messageId + viewModelScope.launch { + runCatching { + container.executeAuthorized { session -> + container.apiClient.acknowledgeDelivered( + accessToken = session.accessToken, + chatId = chatId, + messageId = messageId + ) } } } } + private fun markChatReadLocally(ownerUserId: String, readThroughMessageId: String) { + if (lastLocallyReadMessageId == readThroughMessageId) { + return + } + lastLocallyReadMessageId = readThroughMessageId + viewModelScope.launch(Dispatchers.IO) { + runCatching { container.chatListCache.markChatRead(ownerUserId, chatId) } + container.realtimeService.notifyChatsChangedLocally() + } + } + private fun resolvePeer(chat: ChatDetailsDto, session: AuthSessionDto): UserSummaryDto? { if (chat.type != ChatType.Direct) { return null @@ -1825,8 +2089,10 @@ class ChatViewModel( state.activeCall == null } -internal fun MessageDto.canManage(currentUserId: String?): Boolean = - currentUserId != null && sender.id == currentUserId && deletedAt == null +internal fun MessageDto.canManage(currentUserId: String?, canPostAsChannel: Boolean = false): Boolean = + currentUserId != null && + deletedAt == null && + (sender.id == currentUserId || (chatType == ChatType.Channel && canPostAsChannel)) internal fun MessageDto.canDelete(currentUserId: String?): Boolean = currentUserId != null && deletedAt == null @@ -1945,6 +2211,104 @@ internal fun AttachmentDto.isImageAttachment(): Boolean = fileName.endsWith(".webp", ignoreCase = true) || fileName.endsWith(".gif", ignoreCase = true) +internal fun AttachmentDto.isVideoAttachment(): Boolean { + if (kind == AttachmentKind.VoiceNote) { + return false + } + + val normalizedContentType = contentType + .substringBefore(';') + .trim() + .lowercase(Locale.US) + if (normalizedContentType in VoiceLikeContentTypes) { + return false + } + + if (normalizedContentType.startsWith("video/") || normalizedContentType in VideoLikeContentTypes) { + return true + } + + val cleanedFileName = fileName.substringBefore('?').substringBefore('#').trim() + val extension = cleanedFileName + .substringAfterLast('.', missingDelimiterValue = "") + .lowercase(Locale.US) + .takeIf { it.isNotBlank() && it != cleanedFileName.lowercase(Locale.US) } + if (extension in VoiceLikeFileSuffixes) { + return false + } + + return extension in VideoLikeFileSuffixes +} + +internal fun AttachmentDto.isVoiceNoteAttachment(): Boolean { + if (kind == AttachmentKind.VoiceNote) { + return true + } + + val normalizedContentType = contentType + .substringBefore(';') + .trim() + .lowercase(Locale.US) + if (normalizedContentType in VoiceLikeContentTypes) { + return true + } + + val cleanedFileName = fileName.substringBefore('?').substringBefore('#').trim() + val extension = cleanedFileName + .substringAfterLast('.', missingDelimiterValue = "") + .lowercase(Locale.US) + .takeIf { it.isNotBlank() && it != cleanedFileName.lowercase(Locale.US) } + if (extension in VoiceLikeFileSuffixes) { + return true + } + + val lastToken = cleanedFileName + .split(' ', '.', '_', '-', '(', ')', '[', ']') + .lastOrNull { it.isNotBlank() } + ?.lowercase(Locale.US) + return lastToken in VoiceLikeFileSuffixes +} + +private val VoiceLikeContentTypes = setOf( + "audio/ogg", + "audio/opus", + "application/ogg", + "application/opus", + "application/x-ogg", + "video/ogg" +) + +private val VideoLikeContentTypes = setOf( + "application/mp4", + "application/x-mp4", + "video/x-m4v" +) + +private val VideoLikeFileSuffixes = setOf("mp4", "m4v", "mov", "webm", "mkv") + +private val VoiceLikeFileSuffixes = setOf("oga", "ogg", "opus") + +internal fun String.looksLikeAudioFileName(): Boolean { + val cleaned = substringBefore('?').substringBefore('#').trim() + if (cleaned.isBlank()) { + return false + } + + val audioSuffixes = setOf("aac", "flac", "m4a", "mp3", "oga", "ogg", "opus", "wav", "webm") + val extension = cleaned.substringAfterLast('.', missingDelimiterValue = "") + .lowercase(Locale.US) + .takeIf { it != cleaned.lowercase(Locale.US) } + if (extension in audioSuffixes) { + return true + } + + val lastToken = cleaned + .split(' ', '.', '_', '-', '(', ')', '[', ']') + .lastOrNull { it.isNotBlank() } + ?.lowercase(Locale.US) + return lastToken in audioSuffixes +} + internal fun buildBotQuickActions(message: MessageDto): List { if (!message.sender.isBot || message.text.isBlank()) { return emptyList() diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/PickerMediaCatalog.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/PickerMediaCatalog.kt new file mode 100644 index 00000000..c9bfbf69 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/PickerMediaCatalog.kt @@ -0,0 +1,194 @@ +package com.seven.ikar.kotlin.feature.chat + +internal enum class PickerMediaKind { + Gif, + Sticker +} + +internal data class PickerMediaItem( + val key: String, + val title: String, + val previewEmoji: String, + val fileName: String, + val kind: PickerMediaKind, + val base64Data: String? = null, + val stickerEmoji: String? = null, + val stickerLabel: String? = null, + val backgroundColor: Int = 0xFFFFFFFF.toInt(), + val accentColor: Int = 0xFF2AABEE.toInt() +) + +internal data class PickerMediaPack( + val key: String, + val title: String, + val icon: String, + val items: List +) + +internal val BuiltInGifPacks = listOf( + PickerMediaPack( + key = "reactions", + title = "Реакции", + icon = "\u2764\uFE0F", + items = listOf( + PickerMediaItem( + key = "gif_heart", + title = "Сердце", + previewEmoji = "\u2764\uFE0F", + fileName = "ikar_gif_heart.gif", + kind = PickerMediaKind.Gif, + base64Data = GifHeartBase64 + ), + PickerMediaItem( + key = "gif_check", + title = "Готово", + previewEmoji = "\u2705", + fileName = "ikar_gif_check.gif", + kind = PickerMediaKind.Gif, + base64Data = GifCheckBase64 + ) + ) + ), + PickerMediaPack( + key = "energy", + title = "Акцент", + icon = "\uD83D\uDD25", + items = listOf( + PickerMediaItem( + key = "gif_fire", + title = "Огонь", + previewEmoji = "\uD83D\uDD25", + fileName = "ikar_gif_fire.gif", + kind = PickerMediaKind.Gif, + base64Data = GifFireFixedBase64 + ), + PickerMediaItem( + key = "gif_star", + title = "Звезда", + previewEmoji = "\u2B50", + fileName = "ikar_gif_star.gif", + kind = PickerMediaKind.Gif, + base64Data = GifStarBase64 + ) + ) + ), + PickerMediaPack( + key = "ikar_motion", + title = "Икар motion", + icon = "\uD83D\uDCA0", + items = listOf( + gif("gif_ikar_pulse", "Пульс", "\uD83D\uDCA0", GifIkarPulseBase64), + gif("gif_ikar_orbit", "Орбита", "\uD83E\uDE90", GifIkarOrbitBase64), + gif("gif_ikar_spark", "Искра", "\u2728", GifIkarSparkBase64) + ) + ), + PickerMediaPack( + key = "ikar_actions", + title = "Икар действия", + icon = "\uD83D\uDE80", + items = listOf( + gif("gif_ikar_launch", "Запуск", "\uD83D\uDE80", GifIkarLaunchBase64), + gif("gif_ikar_typing", "Пишу", "\uD83D\uDCAC", GifIkarTypingBase64), + gif("gif_ikar_shield", "Защита", "\uD83D\uDEE1\uFE0F", GifIkarShieldBase64) + ) + ) +) + +internal val BuiltInStickerPacks = listOf( + PickerMediaPack( + key = "daily", + title = "На каждый день", + icon = "\uD83D\uDC4B", + items = listOf( + sticker("sticker_hi", "Привет", "\uD83D\uDC4B", 0xFFE7F1FF.toInt(), 0xFF2AABEE.toInt()), + sticker("sticker_ok", "Ок", "\uD83D\uDC4D", 0xFFEAF8EF.toInt(), 0xFF2FA86B.toInt()), + sticker("sticker_thanks", "Спасибо", "\uD83D\uDE4F", 0xFFFFF3D8.toInt(), 0xFFFFA726.toInt()), + sticker("sticker_laugh", "Смешно", "\uD83D\uDE02", 0xFFFFEEF5.toInt(), 0xFFE91E63.toInt()), + sticker("sticker_love", "Люблю", "\uD83E\uDD70", 0xFFFFE5ED.toInt(), 0xFFE84370.toInt()), + sticker("sticker_sad", "Грустно", "\uD83D\uDE22", 0xFFEAF0FF.toInt(), 0xFF5B6DD6.toInt()) + ) + ), + PickerMediaPack( + key = "work", + title = "Дела", + icon = "\uD83D\uDCCC", + items = listOf( + sticker("sticker_done", "Готово", "\u2705", 0xFFEAF8EF.toInt(), 0xFF229E5D.toInt()), + sticker("sticker_wait", "Жду", "\u23F3", 0xFFFFF6E6.toInt(), 0xFFE09123.toInt()), + sticker("sticker_idea", "Идея", "\uD83D\uDCA1", 0xFFFFF9D7.toInt(), 0xFFF4B400.toInt()), + sticker("sticker_rocket", "Запуск", "\uD83D\uDE80", 0xFFEAF6FF.toInt(), 0xFF1E88E5.toInt()), + sticker("sticker_warning", "Важно", "\u26A0\uFE0F", 0xFFFFECE8.toInt(), 0xFFE25543.toInt()), + sticker("sticker_pin", "Закрепить", "\uD83D\uDCCC", 0xFFF2ECFF.toInt(), 0xFF7E57C2.toInt()) + ) + ), + PickerMediaPack( + key = "ikar_signals", + title = "Икар сигналы", + icon = "\uD83D\uDCE1", + items = listOf( + sticker("sticker_ikar_online", "На связи", "\uD83D\uDCE1", 0xFFEAF6FF.toInt(), 0xFF2AABEE.toInt()), + sticker("sticker_ikar_seen", "Вижу", "\uD83D\uDC40", 0xFFEFFAF2.toInt(), 0xFF22C55E.toInt()), + sticker("sticker_ikar_route", "В пути", "\uD83E\uDDED", 0xFFFFF4E0.toInt(), 0xFFFF8A00.toInt()), + sticker("sticker_ikar_done", "Принял", "\u2705", 0xFFE8F8F0.toInt(), 0xFF14A46C.toInt()), + sticker("sticker_ikar_time", "Нужно время", "\u23F3", 0xFFF6F0FF.toInt(), 0xFF7C3AED.toInt()), + sticker("sticker_ikar_quiet", "Тихо", "\uD83C\uDF19", 0xFFEAF0FF.toInt(), 0xFF4F46E5.toInt()) + ) + ), + PickerMediaPack( + key = "ikar_mood", + title = "Икар настроение", + icon = "\u2728", + items = listOf( + sticker("sticker_ikar_wow", "Вау", "\u2728", 0xFFFFF7D6.toInt(), 0xFFEAB308.toInt()), + sticker("sticker_ikar_power", "Погнали", "\u26A1", 0xFFFFECEB.toInt(), 0xFFEF4444.toInt()), + sticker("sticker_ikar_focus", "Фокус", "\uD83C\uDFAF", 0xFFEAF5FF.toInt(), 0xFF0284C7.toInt()), + sticker("sticker_ikar_fix", "Исправлю", "\uD83D\uDEE0\uFE0F", 0xFFF1F5F9.toInt(), 0xFF475569.toInt()), + sticker("sticker_ikar_hug", "Поддержка", "\uD83E\uDD17", 0xFFFFEAF2.toInt(), 0xFFE11D48.toInt()), + sticker("sticker_ikar_light", "Идея", "\uD83D\uDCA1", 0xFFFFF9DB.toInt(), 0xFFF59E0B.toInt()) + ) + ) +) + +private fun gif( + key: String, + title: String, + emoji: String, + base64Data: String +) = PickerMediaItem( + key = key, + title = title, + previewEmoji = emoji, + fileName = "ikar_$key.gif", + kind = PickerMediaKind.Gif, + base64Data = base64Data +) + +private fun sticker( + key: String, + title: String, + emoji: String, + backgroundColor: Int, + accentColor: Int +) = PickerMediaItem( + key = key, + title = title, + previewEmoji = emoji, + fileName = "ikar_$key.png", + kind = PickerMediaKind.Sticker, + stickerEmoji = emoji, + stickerLabel = title, + backgroundColor = backgroundColor, + accentColor = accentColor +) + +private const val GifIkarPulseBase64 = "R0lGODlhIAAgAIEAACqr7g8XKgAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIrQADCBxIsKDBgwgTKlxoEIDDhxAjAmAo0CHFgRYVTmwosWHCjQQzFhRZ8SDIiidNnkwZYOXFlhhHhnxZsqZNlhQ3urRJU+dMmCofIpy4EydRi0aBljQqkSRGlyyPRlT6dGbUpkJHQuWI9apVrlg5fpXZlCrKsVrLio1pdilSk1R9BnUac2fbnDxB4mQol+1djTzzvtQLt6pGwkO1qvz5MaXaszm7dqRJubLlAAEBACH5BAgKAAAALAAAAAAgACAAgSqr7g8XKgAAAAAAAAizAAMIHEiwoMGDCBMqXGgQgMOHECMCYCjQYUGJEwdaVJix4saLHzs2JCgyYceSFUlSTMlSZcuVGUueXHnxZUyEEXEGEDkR5U6ZPj/uHFpTJ0iVMkkKBap05M+cRF16LPoUokaqP4ti3JhUY9etN6VmdQn2ZdSxVz1CPXtWKEuMZtuy5bn06FWUPuPWDcq0Ic+4at3e1UoTqVO2DMNSzXtQ8WHGZBdyFfwUrWSwEgtr3sxZYEAAIfkECAoAAAAsAAAAACAAIACBKqvuDxcqAAAAAAAACLYAAwgcSLCgwYMIEypcaBCAw4cQIwJgKNBhRYsNLWJMOLEixY4dD4IMyVCjSI8UC04k6ZHlwIgIN6J0KTNATZs4X+Z86ZKnSpI1ezZUSTDoR6I+df6kWbSp04swlV6UuhMqRKQ2QwItKlGmVq1Ps3Z9unErz7FSy4YVe5Us2LBoUU6dK/fsUKpGS2LNupfj3pU/ObI0SnNw4aFCo2Y8KXThTZ2PY0b22ZhrZctdJaYUqXmzZ4YBAQAh+QQICgAAACwAAAAAIAAgAIEqq+4PFyoAAAAAAAAItgADCBxIsKDBgwgTAljIEEDChwEaSpzoEKLAhhYjLoTosGLGix4NdhQ5keTBkQQ3mkwZEmTBliJfvgypkiNNlik/Rsw58KbOnS4v9vw5VGNRoCRhCjXKVCnFkzs7VnRaMuZIlTAp1hx6dWrSqjm7LmX5VKZYpGQxWo3q9StDqCg9KtU4d6rcoz/t4p2rsKdcnxkBj2Vq0+9MmQ9bUj28Ei/jtG8RK+R7kjJkrWp1Rp5p2aZEiwEBADs=" +private const val GifIkarOrbitBase64 = "R0lGODlhIAAgAIIAAP/////RZiqr7otc9g8XKgAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIdQAJCBxIsKDBgwgTKlzIsKHDhxAJAJgYceHEixUTXqSY8eBGAB0RYgxJsqTJgwNSpjxJQKVLky5jkoxJk6CAmw5pyhR4s2dDnS959hTwE+hKoT4ZGj2K9KFRmDVP7mTJMoBVqgSsaqWq9WrVrli3Yh1LtuzYgAAh+QQICgAAACwAAAAAIAAgAIL/////0WYqq+6LXPYPFyoAAAAAAAAAAAAIdAAJCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaJFhAEyQgTAMWHGjw45isT4MUBIkQBIgiQ4oGVLgihTqizosibMjhBr6rSos2fFnjspArUpdOjLokZ5Dr1IAChTgUGfSp1KFaKAq1Svap2qFavUrgKyeq1KNmJAACH5BAgKAAAALAAAAAAgACAAgv/////RZiqr7otc9g8XKgAAAAAAAAAAAAh4AAkIHEiwoMGDCBMqXMiwocOHEBkGmBhx4cSLFRNepJjx4MYAHRFiDEmyZMcBKFGaFJiypcmWMB8KmEkQps2GM3MOtBmTYU6aLHmmxPlzp1CVRAXUPDqA5NGXN1f2XEmVIYCrVQlc3Vp1K1aqXgFk5Zq1rNmzFQMCACH5BAgKAAAALAAAAAAgACAAgv/////RZiqr7otc9g8XKgAAAAAAAAAAAAh6AAkIHEiwoMGDCBMqXMiwocOCAiIKeMhQokSKCi1GxJhQ40SOCC2CHEmypEmQA1KmPClQpcuTLmOWjEmTJE2ZI2++zKlzJc+eDwEIJQhUYICjCYUqJVrT6FGkB5UOZaqS4FOoBqUCcHg1QNKpXLGyHEu2rNmzaNOeDQgAOw==" +private const val GifIkarLaunchBase64 = "R0lGODlhIAAgAIEAAP/RZv9rNQ8XKgAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIaAAFCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzLgygsSDHjgIDiAQpQOTHjiZBmhypceVJjC5bunxZcSbNiTZZ1sx5MedNiD51Sgz60yFRikSFPjQJoKlTACkrPnWqcWpHq1ebOgwIACH5BAgKAAAALAAAAAAgACAAgf/RZv9rNQ8XKgAAAAhsAAUIHEiwoMGDCBMqXMiwocOHECNKnEixYsIAFi9mPBig48aCHTF+HBhypMCQHkeiFPlxpcqVLC3CjElxZsqKNmlKzCkzp86HPn86DFozqNCFIQEoXQqgZEWmS0dCNTlVKlOTAqJi1UpVqcOAACH5BAgKAAAALAAAAAAgACAAgf/RZv9rNQ8XKgAAAAhwAAUIHEiwoMGDCBMqXMiwocOHEBEGiOhwIsWFATJeVJjR4saDHT+C7OhRpECSJU2iNDkQpUaWLlNejPnyI02ZEW+KvFmTIk+cFXlu/NnzYUcASJMCCPlRaVKWAp1Cjap0KlUAVq9mjbqVa9evYCMGBAAh+QQICgAAACwAAAAAIAAgAIH/0Wb/azUPFyoAAAAIdwAFCBxIsKDBgwgTDgygsKFChg4jEgxAUaJFihAtOsSocSPGjB0PfgQZsuDIkiJHkkSpcmXIlhVRCoTpUiNNmQJoxiyps2bEnix7+nxIEYDRowA4ykR6FCdBpk6fIo06ECpVgU2vYjWqdSuArljBhhVLtqxZjQEBADs=" +private const val GifIkarSparkBase64 = "R0lGODlhIAAgAIEAAP/////RZg8XKgAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIXwAFCBxIsKDBgwgTKlzIsKHDhxAjPgQgsSHFigkBaNSIMWNHhRc/Pgwg8mCAkyUJnlyZUsDKlyVfyvwosybGmjhv4mTZcSdJkTlTzmzpEiVRgT+PKl3KtKnTp1CjNgwIACH5BAgKAAAALAAAAAAgACAAgf/////RZg8XKgAAAAhlAAUIHEiwoMGDCBMqXMiwocOHECM+BCCxIcWKCQFo1Igx48EAHQVeJBigZMiPJk+SLJlSpQCWLU/CZClz5kyMNnNGzMkzZsOeMHECrXnTZVGjQV0KpKl0qU+jTaNKnUq1qlWpAQEAIfkECAoAAAAsAAAAACAAIACB/////9FmDxcqAAAACG8ABQgcSLCgwYMIEypcyLChw4cQIz4EILEhRYMBKgoEwJFjwYwaN2IMAFLjRYIkS4ZEmVLlSgEtXYaMSfKlQJoyI+JsKXGnz4Y+g9ZkKJRmz6AvcdqEaXRpzKU3eUJlmnNlyqkDqybFyrWr169LAwIAIfkECAoAAAAsAAAAACAAIACB/////9FmDxcqAAAACHcABQgcSLCgwYMIEypcyLChw4cQEQaIaBDAwYkUCVosGKBjRgEAQoYk2BHjx40kPX4EybGkyZUDXb6EKUAmzZgyZ37MqRMiz5w+fwrtiXOoUZUSjwJ9qBQmz5s1l960CTVqyapWkUK9itVqV69fidIU+7WsWYYBAQA7" +private const val GifIkarTypingBase64 = "R0lGODlhIAAgAIEAAP///yLFXg8XKgAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIegAFCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzCgzAsaPHjx4ZBpg4MmFJkghPbuT4UOVAlR8FAJgpcCaAgi43vvxos6dNgjkFnATp0yfQg0N5Fv35EinQkEyZNjXosmPLlBWDTo2o9SnIryE1ih1LtqzZs2jTXgwIACH5BAgKAAAALAAAAAAgACAAgf///yLFXg8XKgAAAAh8AAUIHEiwoMGDCBMqXMiwocOHECNKnEixosWLGDMKDMCxo8ePHhkGmDgyYUmSCE8e7KhQ5UCXAGIK+CggJoCCLjcStAkAJM+bL1futOmTJ8GcM4fGLGrzqFClM0M2dWoQ6UuOLVNWtKpTIterIMOG1Ei2rNmzaNOqXXsxIAAh+QQICgAAACwAAAAAIAAgAIH///8ixV4PFyoAAAAIfAAFCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzCgzAsaPHjx4ZBpg4MmFJkghPiuRYUOVAlwBiCowJQMBHgi43EqTJMybIkzlt7uxJ8+fLgyqJFgV51CBMmQJo2gzZtOXDjlaRVgxaFSJXnD/DYtVItqzZs2jTql17MSAAIfkECAoAAAAsAAAAACAAIACB////IsVeDxcqAAAACHoABQgcSLCgwYMIEypcyLChw4cQI0qcSLGixYsYMwoMwLGjx48eGQaYODJhSZIIT27k+FDlQJUfBQCYKXAmgIIuN778aLOnTYI5BZwE6dMn0INDeRb9+RIp0JBMmTY16LJjy5QVg06NqPUpyK8hNYodS7as2bNo014MCAA7" +private const val GifIkarShieldBase64 = "R0lGODlhIAAgAIEAAP///xS4pg8XKgAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQICgAAACwAAAAAIAAgAAAIlwAFCBxIsKDBgwgTKlzIsKHDhxAjLgSwMIBEigkDaNQIEaPBjSA5MvQ4MKRJkApJnlyJ0iBJASxNAth48GVMkAByiiT4EubNADln7hzY02dMnUMrHkVq0eFSoUkV4sTJNGpGjUGxVrWKkOrMrVy7bgwKNmLIrCbNnl2p9mdTiG4lCrwpl+DJuh9b4i1Ic+/Bt34DCx6sMCAAIfkECAoAAAAsAAAAACAAIACB////FLimDxcqAAAACKAABQgcSLCgwYMIEypcyLChw4cQI0oUAGBggIkHKwoIwJEjRoIAOor0ODFAyJEoLzrseDIlyoYcW7o02REmzZkmAZyEKTOlzpYOe6LUWbOh0JE/izI8ypKoUoUVRcpMKtLoxphOqVZdqFEqUacjl1psqvUpQo0CkYLdCpXgUJdcC+IM21buXJUJ0dqdKTZhSqsL6fYNTHIwQ7wfEyteLDEgACH5BAgKAAAALAAAAAAgACAAgf///xS4pg8XKgAAAAiZAAUIHEiwoMGDCBMqXMiwocOHECNKLBhgIICJBgNo1HgR48CNIAFUnAiypEiQD0uqPFmyocqQMDW6fMlxI4CbMhnSDMDyJkuHNHv+BLrSJs6cM01yPIpUp02eS1k2XWgUp0+VKavylLoRYkifXEdmhXoVq9eaUF9GRLtTbMqwLSXCRTmxo1qMHQXGxUtxasS8BN16HEy4cMSAACH5BAgKAAAALAAAAAAgACAAgf///xS4pg8XKgAAAAicAAUIHEiwoMGDCBMqXMiwocOHECNKLBhgIYCIATJmRHjxocaPGwt2XAiy5EeBIxOaXPkxJUKWJQFkdPkSpkYAOAPQPGhzZk6ZDnvm1OkRJk6ZQIOyPDqzotKmN4fKdNow6lSmUIv6RDpVp0aILY92nYox7NivYEF29RpSq8m1EpeijRtz7kQBaqneHXhT716+RP8e3Cm4sOHDBgMCADs=" + +private const val GifHeartBase64 = "R0lGODlhYABgAIEAAP/0+OpAYAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCQAAACwAAAAAYABgAAAI/wABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmzhz6tzJs6fPn0CDCh1KtKjRo0iTPgzAtGkAjE6jPp0pterEqlJjYt3qcCvXll7DKgzrVSVZsgjPlj2pFq3BtmtJwo07cO7XkXbp5r0bci/Wun6tigz8FwBhwSAPKz7bd7FjuhwfS0bccbLlpokvW86sWTLnzo4/g1YsejTh0qb9Nk59GjXruatf58Ure/bg2rDl4m5bcrdatr4h6w5OuTfxrGaPY2apfCpY4lp908RtUzZO1jpN8+wMdHNQz0NDE0JdfLR10r1KAcNNT3A9e/Vu38MX/j6+/PZ87+NHrv8g//7+LQdgQgIOGKCBYzmH4FsKLujggxBGKOGEFFZo4YUCBQQAIfkECQkAAAAsDAAZAEgAQQCB//T46kBgAAAAAAAACP8AAQgcSJBggIMIDxZcyLChQYQOIzZMSJGixIsAKibEGFGjR4gcB37UGFLkyJEcT34sqfLkxZYoJcJU2XHmSoc2aTLM6XInz5gPf94MKtRjwaJDBSIFmnGpUZNOSSqNWhEqVYtNr2LVSpKrRa9gUYYdS7as2bNo06pdy7at27dms8JFOnVu0bp2eRLNO3Mv35ZH/8L0Kbhn4MJJDyPGOnFx1ZqOFcqMHCClY5aIS+LNq9nq3M5+24JeCHc04bWmG6tNjTMta8hlX78kKxvj2NqWteLGTHW35qi+QS8NPvou8eI/j6fOqZz14OavdUJ3znT68sTWTT/NHv0xd9nev3cRlyy+Nsjy4CujN69+fXfoAQEAIfkECQkAAAAsCgAYAEwARACB//T46kBgAAAAAAAACP8AAQgcSLBggIMIDxZcyLChwIQJHUqUCLEiwokYAViEmBHjxo8BOhoEaVEkQZIgRaJMaXIlSY8uWU6MiZIiTZkNb9ZkqBMnz54+NQL96HDoy5NGN/5MqnQgU6Ijn5Z8KLVi1KochWLNSnUrR69cwYo9OrbsVLNoL2pNW3Yt27Fu34KNK9cr3bpV7+J9qnevUad+sQIOLHUw4aRIDyNOrBjo1cY3l0KmmXNyzKKWV87MHFQy54gZP1tVKVqhyb6KT3fNrNow5NaMU8N+7He2Z7y2K+/Njbkub5tvf29GKzy02eId2yJPPnd5S8HOTxeO3pop9dl/r2PvqT23zu68KYMqD+9yPPmd5r0fTX9+KvvfTd8LHy2/OOj6yNXitx9yf/7+/s0HYIDkjRcQACH5BAkJAAAALAwAGQBIAEEAgf/0+OpAYAAAAAAAAAj/AAEIHEiQYICDCA8WXMiwoUGEDiM2TEiRosSLAComxBhRo0eIHAd+1BhS5MiRHE9+LKny5MWWKCXCVNlx5kqHNmkyzOlyJ8+YD3/eDCrUY8GiQwUiBZpxqVGTTkkqVVoRKlWLTa9i1UqSq0WvYFGGHUu2rNmzaNOqXcu2rdu3ZrPCRTp1btG6dnkSzTtzL9+WR//C9Cm4Z+DCSQ8jxjpxcdWajhXKjBwgpWOWiEvizavZ6tzOftuCXgh3NOG1phurTY0zLWvIZV+/JCsb49jalrXixkx1t+aovkEvDT76LvHiP4+nzqmc9eDmr3VCd850+vLE1k0/zR79MXfZ3r93EZcsvjbI8uArozevfn136AEBACH5BAkJAAAALBAAHABAADoAgf/0+OpAYAAAAAAAAAjuAAEIHEhQYICDCAMUXMhwYcKHChtKNAgR4kSJFS1eLJix40aKHSt+BBCy5MSSIU+iNMlwZcqWLllyjOlxJs2MDm/iJKjzJcieGn8CTThw6E6SRkUiTfpQKNOnOKFKnUq1qtWrWLNq3cq1q9evYL8uDatzLFmaZs+uLKo2Jtu2KG3Crfl2rlK5dhFizEu0IV+9KvN+FDxy7si6YQ/j7ao4p9fGMLlC9rt18t6rljdazTx4KufCUD+DZir6cNLSkIGinlx2NWu3rl+vjW3ZJe3McW/jpqu79t3eu/sC/yx8OPGDxksjTy56OXPOzl0HBAAh+QQJCQAAACwTAB4AOgA1AIH/9PjqQGAAAAAAAAAI2wABCBxIkGCAgggTKgzA8KDChwgbSmQI8eFEiRUXXryYceBGjh0BfPyYceTGjiZJakwJciXLkwVfjnQpE6PBmipv4mwpcifPnj5BBoUJdChGoy2RKk1ZdKnTo0+jSp1KtarVq1izap3YdGtNgV59gg371SPZlzrPzoypdm3atjYjwuVKs23FuQ7v2kWpNuTYsH7fXg3MFivhhIMPI56qGKLUxo6fQta7dHJJpJb5is0ccjPnzmU/+5Up+jDL0opNom6cc7Xpn64Dx42duiHtybZvs86r+3Xv3aUDAgAh+QQJCQAAACwVAB8ANgAyAIH/9PjqQGAAAAAAAAAIywABCBxIcGCAgggTJjyosCHCABAjRnS4UKJEig8tamRIcaNGjAA8imwocqPDkiMrorSocGVKgi49FowpEyZNkwZvfhSos2ZPnD93BmU59GPRo0iTKl3KtKnTp1CjSp0KNSTVm1avusypFSXXrjV5gn0pdizLmWbPok3LMaNZjGNBZp0q92vVumXv4s3rdK/Nvn7/Kg3cMilhkkcPnxyquOPPxnB1QgaJdTLlrZblrsy8lyxnzWo/4w0tevTE0n4vok7ddnVdiK5ZWw4IACH5BAkJAAAALBMAHgA6ADUAgf/0+OpAYAAAAAAAAAjbAAEIHEiQYICCCBMqDMDwoMKHCBtKZAjx4USJFRdevJhx4EaOHQF8/Jhx5MaOJklqTAlyJcuTBV+OdCkTo8GaKm/ibClyJ8+ePkEGhQl0KEajLZEqTVl0qdOjT6NKnUq1qtWrWLNqndh0a02BXn2CDfvVI9mXOs/OjKl2bdq2NiPC5UqzbcW5Du/aRak25Niwft9eDcwWK+GEgw8jnqoYotTGjp9C1rt0ckmklvmKzRxyM+fOZT/7lSn6MMvSik2ibpxztemfrgPHjZ26Ie3Jtm+zzqv7de/dpQMCADs=" +private const val GifCheckBase64 = "R0lGODlhYABgAIEAAPD69iGjZv///wAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCQAAACwAAAAAYABgAAAI/wABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmzhz6twJIIDPn0AD8KwYtKhRoUMbHl1aNClCplCPOh0YtWrTpFazXtWptStQrl7DIrUptizZsmZnol0rc61XAUFhuu0qoG7clnO11t17d2Veq3sDf/X7t2pgwT9VFjZ8GLHPlIuhNm6cGGXkpZMPDzZ5+Whmx5VLdi76me/WkaNJl4ZrVHTqn6tZtyb52mdspq5T38aN+vVuqL1H/wYu0vdqq8EvDyceEvDb41mTY7abdXlU6UY1My7tFXvQyVGtV6j1DjszU/Hji3vmvp59d/WqoX+X/x7+fPfl8XfNHf8zUPTI0TYdewAGSF5+sfnHFn/ZJahgWgw26KB2aFkm2YSmVWjheRjKpuFJ2yWYl2LhiTgXSyE++CGJJaq4IosXgnfiS4AJ5uGLeNUmlVo69sVjj2PVBCRYr/FUJFaRTSXQYkoWtGCTB4UFpUPMTWnllVhmqeWWXHbp5ZdghinmmGSWaeaZaKYZUkAAIfkECQkAAAAsEAAQAEEAQQCB8Pr2IaNm////AAAACP8AAQgcSLCgQYMBEipcGOCgw4cQIxZkSLFiQ4kYMw60yNGixo8HO4r0CPLjyJMkS0ZEyZKiyoctY7p8SVCmzYU0Bd7cmZAmz58qfwoFKbSoxqJIMSKVKSAlzKUsBUituBIqSqlYqTq0ehVr1pkIuY706hVsTbEiyZI1qxMtR7VqGU50axHuWrN0K9otq7VtXoV7v/b9yzDwVI5+CRtu2jFx3sUjHbuFHJlwAMqWxxrO3DUwZ82e0TJuiZmr4JOlrd4FvXdy3LSbRdvtmBpqbL23Vde+nNsmANq9eYfeKblw7t0xixsPjTz5b9aLZxtVvjw63OnUq1s/zfMs6u3cu3tRhw1+adjv1qGG7Bxc/Hn0w7G/h/46KUTS0u3fx591tP79nzlVVYBymUSgQkEd6FOAOT2XWYPZWQXheGJNOJ96Fq5nXoYAusehRL59+JJIGQYEACH5BAkJAAAALA4ADgBFAEUAgfD69iGjZv///wAAAAj/AAEIHEiwoMGDAAIoXMgwAMKHECNKJNiwokWHEzNqpHixo8WNIBF6HOkxpEmSKC+a1JiypcqVEF3KrAjz4MybNGsKxMmToc6eQBXCDEr0JNGiG48qZal06cSmTiNClSmgZMypLQVotSoSK0qtYDs+9PoVbNiXBcmWNbsV7UC1I9mydbsT7kW5cj9ytFsR71y3fPv6PQs48MLBhNEaZoi4rdXFChtXJVnXsOSUlflexrx4M+S1gz9nbSwatF/NMz2rTUxSNdm/rUnbxRsbsebTHV17lW1R91TJkxv6npkwLu/Dx3lmzn18OPHitUMHcP58NHDpR9+mvI496F7T3IMjX10enTvUtNbNNzVIVf169C6vY7XZPvl49vVxv++aH/Z+/vGZNd18EpU2UkYGclVggjkxxaBQIT2IkVEG/lSaTuTxheF3cG2IX4ce0kdgiABGReJVQJ0Y4XMq1nQgiQEBACH5BAkJAAAALBAAEABBAEEAgfD69iGjZv///wAAAAj/AAEIHEiwoEGDARIqXBjgoMOHECMWZEixYkOJGDMOtMjRosaPBzuK9Ajy48iTJEtGRMmSosqHLWO6fElQps2FNAXe3JmQJs+fKn8KBSm0qMaiSDEilSkgJcylLAVIrbgSKkqpWKk6tHoVa9aZCLmO9OoVbE2xIsmSNasTLUe1ahlOdGsR7lqzdCvaLau1bV6Fe7/2/csw8FSOfgkbbtoxcd7FIx27hRyZcADKlscaztw1MGfNntEybomZq+CTpa3eBb13cty0m0Xb7Zgaamy9t1XXvpzbJgDavXmH3im5cO7dMYsbD408+W/Wi2cbVb48Otzp1KtbP83zLOrt3Lt7UYcNfmnY79ahhuwcXPx59MOxv4f+OilE0tLt38efdbT+/Z85VVWAcplEoEJBHehTgDk9l1mD2VkF4XhiTTifehauZ16GALrHoUS+ffiSSBkGBAAh+QQJCQAAACwSABIAPQA9AIHw+vYho2b///8AAAAI/wABCBxIsKBBggESKlwY4KDDhxAjAmBIsWJDiRgzWtxIMaNHgxxDWvzoUaTJjiQhnlyJMmVBljAXukQYs+ZFlzZz4syp8yPPnyV/whTAEKPQoQKIylR5dGXSp0UfNj35tOrSg1OpVoWqEGtWkVu3Xh34NWTYsF1flrV4VmxasmsrtrUaVWBcinO51r2LN69SlHwX+v0LOHCAwRztBkac2DBjwxwfQ94oeXLfvF8Ja8WcVa/JykLdgvWbGa1Z0p3bRkY9lfXlualdKwS9cqJc2Ydx17Z9m3NC2rtXcwYeXPhg2EAVUz6OvKfy5cxFO3/ONrpnmyBPWz+aXfvxpl69+z5OHt54c55MR59Hn148XaFGP1vVPD2i5ZYa7yfcaXkmb8P+URdXgHDdRaBaXx1YHncKSsVegxLVBCF/Ix0YEAAh+QQJCQAAACwUABQAOQA5AIHw+vYho2b///8AAAAI/wABCBxIsKBBgQESKlwY4KDDhxAdMpxIsWHEixcrapyIsWPBjSA5eswYsiTDkQ9NqlyI0uDKlwlbIoRJ06LHmjhv4nwpQGHHnSsFCI1JEmhJoUiJpjR6FGlSmy6ZNnU6FCpBqSGpUrU6E6tGrVq5ev0K1qnSrmMZljXrc2DaiWuftgXwFm7cnifp1lV4F2/evQn7VkSbVvBgwIYBV0ysmCLjxgsf1/SrUjJNuVPXMt2auezmsCAtw9RMNu5n0o7vGu1Lma9qmHojv5ZtGnZs2qgDiA5J2HXt3bxvq2Vdu6bbxcQ9Gz+OPDnb5cxTO2/9Mmrz5EAPbnSeXft21kaXljMuDl3i99w0MYZWnlP9+qTUbbuHfHZ+Y5nC0+KPPnb/Vf3+fSRVgOKVR6B51R04EkgBBgQAIfkECQkAAAAsFgAWADUANQCB8Pr2IaNm////AAAACP8AAQgcSLCgwQAIEyoMYLChw4cPF0qUCLGixYkYMVrcODCjR40cIX4cSTFkQ5IoFZokmLKlSpMuYwYQEFKmSwE4N9psibPnxZ0kewoVCTSoUJ8ni448evSg0o9MmRZ86jFqU5ZUMVoVihBr1oVbeyb0+jVhWJxjBZYFe3ah2rUIzwooCXdmW4p15dbVenevRL1+2YbdOfdm35hiUwKWeRTlYsRMlx42HLXqZJ6D+WaGfNnuZpQAFD6O29njW7OHR5M8jTqz6tWsScv9nJJs69mVY9q+jTux7t2yexf+Ddxz751J/x63GXEibuRElZce+dM57ZY6NUdmzjFj4+HEu/sXXdlxLfmpX887fareYdH21UHDh2lafUAAIfkECQkAAAAsFAAUADkAOQCB8Pr2IaNm////AAAACP8AAQgcSLCgQYEBEipcGOCgw4cQHTKcSLFhxIsXK2qciLFjwY0gOXrMGLIkw5EPTapciNLgypcJWyKESdOix5o4b+J8KUBhx50rBQiNSRJoSaFIiaY0ehRpUpsumTZ1OhQqQakhqVK1OhOrRq1auXr9Ctap0q5jGZY163Ng2olrn7YF8BZu3J4n6dZVeBdv3r0J+1ZEm1bwYMCGAVdMrJgi48YLH9f0q1IyTblT1zLdmrns5rAgLcPUTDbuZ9KO7xrtS5mvaph6I7+WbRp2bNqoA4gOSdh17d28b6tlXbum28XEPRs/jjw52+XMUztv/TJq8+RAD250nl37dtZGl5YzLg5d4vfcNDGGVp5T/fqk1G27h3x2fmOZwtPijz52/1X9/n0kVYDilUegedUdOBJIAQYEADs=" +private const val GifFireFixedBase64 = "R0lGODlhQABAAIIAAP/46/9ZI//oWP+AJP/aQAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCQAAACwAAAAAQABAAAAI/wABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIUWGAjiAHBhgZEuTIjyU3niSZMuNKlC0tvmQZk+JMmDUj3qSZ8+FOnD0Z/uQZNOHQk0UXHiWalOBSpE0NPmXadKeAnVGd3rx6M6tAqwK4zsz6M6zYsVW3mj27Mq1asz+Tll0bN+jctWzb9rxLF2tOvnjrxgTct+tgwnAFhxyKt/HQlIwbFzZsMrLksEcXI3asWOPRy4Efc7QMOrFfz6RL5305erNk0a1nquaMtiPY2aZZa16JmzbUki97h/5dckBw4WwHxDTOG/lL5S0HMD8p/Dn0lNKnB6h+Mvvy7CcJ4Gkm0N37d+kjxc8mHyD79ejgAxBQD3r+SPc53Rufr9q+/p76yUdfY/7hB6B+/F02n37v1cRggngt+F9SCA5oloTmNVVhhARMmBWCEXroFYhhdWigVwSBB1eGKBqE3lXStdiQADLWaCNDAQEAIfkECRIAAAAsDgAIACUAOACC//jr/1kj/+hY/4Ak/9pAAAAAAAAAAAAACP8AAQgcSLDgwAAGEypcWDCAQ4YQIwJwiFCixYYUK168mFHjxogdH34EGXIkxJAUTS5EKVIlRpYeXYYUUNKlwJk1ZXYUwDOnyZk9O+rMyLOoz41AjQodibJo0KUckzpFiVSqUqgkdzqdenSlVa5YvX69GjZh061bqTJkiTatWoVs25LNKFar3LkpDcK8C7ZrXL54W978C5gmS4J7Cz/NmVjx4qWNHcOcSNixYaqVJb/NDPjwwbOWPX8eK1c0YtJt3xYcEJKAYgIhByhk3dF1YdgdZSccQJsiAdt3f+fWvbt3gN+AhVPkzZC3ceRylTt03ty5b+BopTsnPpt3RuhbpQdBoA5x+/W22smXt+4QPM/0zC2abx9+uXqJ23sDxz1++0j2DpF130cABnBZf9yZBOBlA9pk3Hg21RVThC9ReNJIAQEAIfkECQkAAAAsDgAGACUAOACC//jr/1kj/+hY/4Ak/9pAAAAAAAAAAAAACP8AAQgcSLDgwAAGEypcWDCAQ4YQIwJwiFCixYYUK168mFHjxogdH34EGXIkxJAUTS5EKVIlRpYeXcLM6PLgzJYqb9KUqRPnR5YCWOZEGRSlSaACipbcCDOp0qUWkToVGlVqUphViTp92lFi061XqTL8ChbrWKtlxSYkC5brzrVs06q1ibbtTIMz28o1SvCm3q03+8b9e1eg37+AC+dFvLfkYcZT7y6GHFZsXchqLzOeG5KyXqiCKXq2+7bggI6jI1McoPB0xtSVHbJOOMC16NEda7eu/Rr3at20a9sm4JnAb+DBeQcgTtl4AOGzdysnwBwxddnCGUI/TR3y9e0Qty9Cr972O/SI2wd0/0s9vcT068G2B29xe/yt87NvtE9eQH7k+0HXHwH0mWSfUwSeVxMAwgUwlX4LDsRbUABGWJAAIwUEACH5BAkSAAAALA4ABAAlADwAgv/46/9ZI//oWP+AJP/aQAAAAAAAAAAAAAj/AAEIHEiw4MAABhMqXFgwgEOGECMCcIhQosWGFCtevJhR48aIHR9+BBlyJMSQFE0uRClSJUaWHl3CzOjy4MyWKm/SlKkT58eeO0cCTSl0KFGORoNKnClgJlKYTZ0uhSogKsupVK1ePZm1qtSVTKt6/ZrwptixMMF2FXuzrNmzaNO+ZAn3bFuCOuuyvSswr16tWye+/QsYZd/Bf3UKRqy352K6hPfKfRwysmTDNlFathv4cOXNcZVmzgg6tM+BAzqWvhxggMLUpFcDdp1wAOwAsiXbfm3bYe6xtmkbDA47t0PivIPjlt0aefLipY8HZ0g89erm0xdWH0AANIHtEKt3Sd/83Tl14gTGE05fXSJ69X/ZZ48oHj5c+fPpB0+/vvzuje/phZ9wANrGX134ubSffQnWBICB9/3nIGrqfTehQmJdyJAAGm44UkAAOw==" +private const val GifStarBase64 = "R0lGODlhYABgAIEAAPX5///CLv/reAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCQAAACwAAAAAYABgAAAI/wABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjyqwYYCbLmjZTBtiZE+VOnD1J/gQaVORQokU/Hk0a8ihPph6dIoWKUepUqhatXsU6USvXql6/ZtW6VSxDsk/NRkRbVi1CtmndnoUr1yHcuHUP3sWbt+DetnX/8u0LQDDgoIYTL/WpuLHjoR0fS04McrJltCMvBxDAmfPlkpM7i/b8+OTj0agdq1SMujXlm4Jbyxb8Mrbs1Htj7r09m+5MuLxdY+7JNjjusIjJGh9NFqry5Z2RO7cKXYBVs9ShS1Wrdfl17N15S37HWrz3+Onlo7MVqxny1/Y/38OPT37+4OT2udq/n3M/faa0CfQXVQMSlBt6w+m1HoC+JdQgfuc52FxSE0JUIYSLdbVdUd+N5RSHGWb0IYZRuUccfyKi2NJ/Tam40mEcwUjYjDTWaOONOOao44489ujjj0AGKeSQRBZp5JEXBQQAIfkECQkAAAAsDQAQAEMAQwCB9fn//8Iu/+t4AAAACP8AAQgcSLAgwQAGEypcyLChw4EBED6cSLGiwYgSLWrcqBBjRo4gN3r8GLLkw5EkTapMiDLlypcCW8I8+LKly5AYa9q8adEjzJ0RQcrUCVSjzZlAc04s+jNp0IZJZwJwqpSlU6lUq8bMKnVq1qdfnyINS5ZnybJku25FS1WtV7ZR3cK9KnfuTrdr7aLE+3angL9/ufIFCrhw4LhqkxpezDQx4cWMj3Z1Crny0LGUK0ceiTmz5s0+V4b9DFkyTrKkQXPmyDa14cYU57oujPik3dkC2jrUGxG34I68MboOu1Qv6bRGW1suexo1bbiig3/FKh22yuq1TWLPfnb73ei/GdJG1T5d+/XwfG3rTh97PPvi3N+Ljy9/If369r/jh793f/vL/u0GYIBQDUjgfKEdqJ5YCgqoVYMImgXhQQ9OmJ+FxWF4EnsBAQAh+QQJCQAAACwTAA4AQQBEAIH1+f//wi7/63gAAAAI/wABCBxIsKDBgwgHBkjIsKHDhwgDLIRIsWJFiRMtatxoECPHjxwxZgRJ0qHIkSVTdjypsmXBkx5duoQpUWZLmjVtlsSZU+dHnj19bgSKcuhMoilFkiQa9CdNjUybOuX5MKrUqUgjWr1pNabArkVBgh3LkivZsUfPgpWpdi3btlFtwo0rdy5Qn2QF6NVL12zUvYD5Mk1LNLDhriqZGl7sVizQxZDJOsYJmfFZrCIrR24LlaZmy3Atev4c2K5XhpRJA7Z7cbRqwZdF83wtIHbn2a8bhyxMGvHSv5r7/rZaOWvivKup1jX99C3z5n6fl40u/fTO6tCvY4dJ3TgA3cO9v0PcmvSuSfLhlcoerD1sa/FGu08Xehsn/d3K79fnrn+o/f7+8QfgevMNSJGABhLoXoLnXcVgVQ4+2JB1EkIUYYWopRQQACH5BAkJAAAALA0ADQBDAEMAgfX5///CLv/reAAAAAj/AAEIHEiwoMGDAAIgXMiwocOHAwMohEixokWBEi9q3HhQYkaOIDV6nBiyJMSRJE2qRIhypcuOLV/KROlR5kuaH22qxJlTZ0ieKX1yBBpUqEWiRY1SRJpUqUOmTZ0uhCp1KdSoVQlexZoV41aTXE9+fYp059azPM2iXVtTLVu0Lt+uLSlX7sa6eMMWxCugb1+7ZPP6HfwXLsu8KAkrNjwVcQDFkMcGlgu5MlWrlCsvLnvxrWbLQEGu/Rw5Ld2tpDfTXIk69WCcM5m6fh3TpuzZsIUSnS3AtM/dqUMrBUqas27ioIkOR0pYsk7Hq49DHyl9es/Y1ttiz36dNXftN896Q3Xu/epe89uNG7wcl31j4e3VN1QeHz7m2m5zi4yeH/9Q8Kfxp1Z51NnWn15dredfgmIByGCDCD54XoQSakVhhRFhGBAAIfkECQkAAAAsDgATAEQAQQCB9fn//8Iu/+t4AAAACP8AAQgcSHBggIIIEypcWPAgw4cEAziESPGhxIoUJU7EyFEjR4gaN3606HHkwpAXTSpEKVKlQZYuG8KMiZBlSpU2W9IEkFNnxpw7a/b0ebJnUJlDKw69eVTgUqYJnzZFunTlU6I0r5Z8eXVqRK0pwUKdKrYs1p1mxXqlmrbqWq5t3b51GjfpXLh1Z97lmRfoXr5PBQgW3PUv4JyDExM2avgwS8WQGRvuCbmy37+UK1u2uXep5s2XyQ79rNlu08CkI5tGizp1YqlZtbpWrNWl2Nmva480i1tA2Y9pe5tVGnd2XJJ1U+ct2vdzX53PQ9KOvpG69arXs0tmqF0u5t+NQYJMD4+8MPnysM+jX63eavr2zL3Dd89+vtD69u+Hzh8VP3+8KP233lYC9qdXgQYGiGB8BC7I1lkLKuggfRAiGNKEyGHI3VgaRtQhd3cFBAAh+QQJCQAAACwQAA0AQwBDAIH1+f//wi7/63gAAAAI/wABCBxIsKDBgwgBBEjIsKHDhwwDLIRIsaJFgRIvatx4UGJGjiA1epwYsuTDkSRNqkSIcqVLgyhTvlwZ8+NMmjVv4syp02RNmz05/gQaVORQmUUpHiWa9ORSpE0bPoUaleVUqlULXsVakavSrSqfOr26c6vZmC7Pqm1Zdq3Zl27VzowbNyzduyDpCti7l67Rs3wD973rdaBawYgJs7V6FbFjxYUVTnVMGe/XpZQf17WIObPms0KHev5MNqTo0YLB2o2JOvVUuKxb8xU7t6ZsAUt1nm49NOhR3j99d84cvOjkysV7mnXdWzjkkc6fe4wuPfLG6otrY59+czt07aVBg0LPTfAtbPIdS6c96lD9ap5j2a9Hyxm9T/p/k99nmh9/Se5tAfifbv5lVV92BnaFYIKX8cdgfA9e52CE7VlHIUwaBQQAIfkECQkAAAAsDQAOAEAARACB9fn//8Iu/+t4AAAACP8AAQgcSLCgwYEBDipcyLChQ4MBEj6cSLEiwogWM2o8GFHixo8ZO3oESdKhyJElU0I8qbIlwZMdXbqEiVGmSpo1bZLEmVPnRp49fVoEilIoRaJFQSatiHTp0Zg7m4akWbJpUIVEU1p1ulVr14tbnTINS1bkzbJoz6IN23It27Zurc6Mi1Qm3bpqgQrYu7esV6R8A/eVG7Wp4MOENZI9zBjv1LCMIwN9XDZyY55j11qWTPXh3QCbL8M0+Rl0aMGdJ949jTr1U7SsA+P8CTv25I+Lbd9WapW146qANyf+y9Py27nFZZO1WRoz8uYsn0M3C3f66LzWoRLPrh34cL/bd689/M1bKmnnhbMOdU1bPe7u7dFrTc/eqGrx9u/Pzr++Pv/z0f2XWYACvgZfgZ5Rh+CACz7WoIMPjhXhUAIGBAAh+QQJCQAAACwQABAAQwBDAIH1+f//wi7/63gAAAAI/wABCBxIsKDBgwgTCgygsKHDhxALBmAYsaLFiBMpXtzIkWBGjR1DVvwIUqRJhSRLnlzpMeVKlS9dxoQZMuVElgA+nrSZEafNjjx7+vxpMahQlkYxGr2JM2fShkuPIo2aMKrUqVYlWmXadKtQr1y7gh1L0yTZsU0HnvWatuXapW3VvoUbd+HcoHXt3iWa16uAv3/p9o0KuHBgnnnlBjXM+Gldo4wjO47LM7JlwWkrW25MVWzKzZezzvwMmrNokZBLG0YLNLVqwGc3En4Ne+1Iq7QF3IXol/besnq3vv591W3vzXsfrkUe+/bb1awvEgdrdvrk6tbxjs7OFzV37djBo0hka3Yn+cRKT6NXvnW98+vuoXaOzxszfYf2749HrD+9zP7sdQegfP8NSCBJBtanU4IKhsXgfg4+WFVxEh60YIXyYciehso1FRAAOw==" diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoScreen.kt index 36d36ead..4b2c3c3c 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoScreen.kt @@ -1,7 +1,12 @@ package com.seven.ikar.kotlin.feature.chatinfo +import android.content.ClipData +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -10,21 +15,35 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.model.ChatInfoDto import com.seven.ikar.kotlin.core.model.ChatInviteLinkDto +import com.seven.ikar.kotlin.core.model.ChatSummaryDto import com.seven.ikar.kotlin.core.model.ChatTopicDto import com.seven.ikar.kotlin.core.model.ChatType import com.seven.ikar.kotlin.ui.IkarRowDivider @@ -36,6 +55,10 @@ import com.seven.ikar.kotlin.ui.IkarSimpleHeader import com.seven.ikar.kotlin.ui.theme.TelegramBlue import com.seven.ikar.kotlin.ui.theme.TelegramDanger import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.launch @Composable fun ChatInfoScreen( @@ -44,14 +67,26 @@ fun ChatInfoScreen( onRefreshClick: () -> Unit, onMembersClick: () -> Unit, onSearchClick: () -> Unit, + onProfileTitleChanged: (String) -> Unit, + onProfileDescriptionChanged: (String) -> Unit, + onProfilePublicUsernameChanged: (String) -> Unit, + onMaterialsClick: (ChatMaterialFilter) -> Unit, onInviteTitleChanged: (String) -> Unit, onInviteJoinRequestChanged: (Boolean) -> Unit, + onInviteExpiresInHoursChanged: (String) -> Unit, + onInviteMaxUsesChanged: (String) -> Unit, + onInviteMemberLimitChanged: (String) -> Unit, onCreateInviteClick: () -> Unit, onRevokeInviteClick: (ChatInviteLinkDto) -> Unit, onTopicTitleChanged: (String) -> Unit, - onCreateTopicClick: () -> Unit + onCreateTopicClick: () -> Unit, + onLinkDiscussionClick: (ChatSummaryDto) -> Unit, + onClearDiscussionClick: () -> Unit ) { val info = state.info + val clipboard = LocalClipboard.current + val clipboardScope = rememberCoroutineScope() + val context = LocalContext.current IkarScreenSurface(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier @@ -75,42 +110,145 @@ fun ChatInfoScreen( if (info != null) { IkarSectionCard { - IkarSectionTitle("Обзор", modifier = Modifier.padding(bottom = 8.dp)) - Text(info.chat.type.label(), style = MaterialTheme.typography.bodyMedium, color = TelegramInkMuted) - Text(info.channelAudienceText(), style = MaterialTheme.typography.titleMedium) - if (info.chat.type == ChatType.Channel && info.adminCount > 0) { - Text("${info.adminCount} ${russianPlural(info.adminCount, "администратор", "администратора", "администраторов")}", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + IkarSectionTitle("Обзор") + Text(info.chat.type.label(), style = MaterialTheme.typography.bodyMedium, color = TelegramInkMuted) + Text(info.channelAudienceText(), style = MaterialTheme.typography.titleMedium) + if (info.chat.type == ChatType.Channel && info.adminCount > 0) { + Text("${info.adminCount} ${russianPlural(info.adminCount, "администратор", "администратора", "администраторов")}", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + val description = info.description ?: info.chat.description + if (!description.isNullOrBlank()) { + Text(description, style = MaterialTheme.typography.bodyMedium) + } + if (!info.publicUsername.isNullOrBlank()) { + Text("@${info.publicUsername}", color = TelegramBlue) + } + if (info.chat.type == ChatType.Channel) { + ChannelIdRow( + chatId = info.chat.id, + onCopyClick = { + clipboardScope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText("Ikar channel id", info.chat.id)) + ) + } + Toast.makeText(context, "UUID канала скопирован", Toast.LENGTH_SHORT).show() + } + ) + } + OverviewActions( + onMembersClick = onMembersClick, + onSearchClick = onSearchClick, + onRefreshClick = onRefreshClick + ) } - val description = info.description ?: info.chat.description - if (!description.isNullOrBlank()) { - Text(description, style = MaterialTheme.typography.bodyMedium) - } - if (!info.publicUsername.isNullOrBlank()) { - Text("@${info.publicUsername}", color = TelegramBlue) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = onMembersClick) { Text("Участники") } - TextButton(onClick = onSearchClick) { Text("Поиск") } - TextButton(onClick = onRefreshClick) { Text("Обновить") } + } + + if (info.canEditProfile()) { + IkarSectionCard { + IkarSectionTitle("Профиль", modifier = Modifier.padding(bottom = 8.dp)) + IkarSearchCard( + value = state.profileTitle, + onValueChange = onProfileTitleChanged, + placeholder = "Название" + ) + IkarSearchCard( + value = state.profileDescription, + onValueChange = onProfileDescriptionChanged, + placeholder = "Описание" + ) + if (info.chat.type == ChatType.Channel) { + IkarSearchCard( + value = state.profilePublicUsername, + onValueChange = onProfilePublicUsernameChanged, + placeholder = "Публичное имя канала" + ) + } + if (state.isSaving) { + Text("Обновление...", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } } } IkarSectionCard { IkarSectionTitle("Общие материалы", modifier = Modifier.padding(bottom = 8.dp)) - InfoStatRow("Медиа", info.mediaCount) - InfoStatRow("Файлы", info.fileCount) - InfoStatRow("Ссылки", info.linkCount) - InfoStatRow("Голосовые", info.voiceCount) - InfoStatRow("Закреплённые", info.pinnedCount) + InfoStatRow("Медиа", info.mediaCount, onClick = { onMaterialsClick(ChatMaterialFilter.Media) }) + InfoStatRow("Файлы", info.fileCount, onClick = { onMaterialsClick(ChatMaterialFilter.Files) }) + InfoStatRow("Ссылки", info.linkCount, onClick = { onMaterialsClick(ChatMaterialFilter.Links) }) + InfoStatRow("Голосовые", info.voiceCount, onClick = { onMaterialsClick(ChatMaterialFilter.Voice) }) + InfoStatRow("Закреплённые", info.pinnedCount, onClick = { onMaterialsClick(ChatMaterialFilter.Pinned) }) } - IkarSectionCard { - IkarSectionTitle("Пригласительные ссылки", modifier = Modifier.padding(bottom = 8.dp)) + if (info.chat.type == ChatType.Channel && info.canManageChannel) { + IkarSectionCard { + IkarSectionTitle("Обсуждение канала", modifier = Modifier.padding(bottom = 8.dp)) + val linkedDiscussionId = info.linkedDiscussionChatId ?: info.chat.linkedDiscussionChatId + val linkedGroup = state.discussionGroups.firstOrNull { it.id == linkedDiscussionId } + Text( + text = linkedGroup?.let { "Привязана группа: ${it.title}" } + ?: linkedDiscussionId?.let { "Привязана группа: $it" } + ?: "Группа обсуждения не привязана.", + style = MaterialTheme.typography.bodyMedium, + color = if (linkedDiscussionId == null) TelegramInkMuted else MaterialTheme.colorScheme.onSurface + ) + if (linkedDiscussionId != null) { + TextButton(enabled = !state.isSaving, onClick = onClearDiscussionClick) { + Text(if (state.isSaving) "Обновление..." else "Отвязать обсуждение", color = TelegramDanger) + } + } + if (state.discussionGroups.isEmpty()) { + Text( + "Доступных групп пока нет. Создайте группу и добавьте туда администраторов канала.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } else { + state.discussionGroups.forEachIndexed { index, group -> + DiscussionGroupRow( + group = group, + isLinked = group.id == linkedDiscussionId, + isSaving = state.isSaving, + onLinkClick = { onLinkDiscussionClick(group) } + ) + if (index != state.discussionGroups.lastIndex) { + IkarRowDivider() + } + } + } + } + } + + if (info.canInviteUsers) { + IkarSectionCard { + IkarSectionTitle("Пригласительные ссылки", modifier = Modifier.padding(bottom = 8.dp)) IkarSearchCard( value = state.inviteTitle, onValueChange = onInviteTitleChanged, placeholder = "Название ссылки" ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IkarSearchCard( + value = state.inviteExpiresInHours, + onValueChange = onInviteExpiresInHoursChanged, + placeholder = "Срок, часов", + modifier = Modifier.weight(1f) + ) + IkarSearchCard( + value = state.inviteMaxUses, + onValueChange = onInviteMaxUsesChanged, + placeholder = "Входов", + modifier = Modifier.weight(1f) + ) + } + IkarSearchCard( + value = state.inviteMemberLimit, + onValueChange = onInviteMemberLimitChanged, + placeholder = "Лимит участников" + ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -142,6 +280,7 @@ fun ChatInfoScreen( IkarRowDivider() } } + } } IkarSectionCard { @@ -167,9 +306,101 @@ fun ChatInfoScreen( } @Composable -private fun InfoStatRow(label: String, value: Int) { +private fun DiscussionGroupRow( + group: ChatSummaryDto, + isLinked: Boolean, + isSaving: Boolean, + onLinkClick: () -> Unit +) { Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(group.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + group.secondaryText ?: "${group.participants.size} участников", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton(enabled = !isSaving && !isLinked, onClick = onLinkClick) { + Text(if (isLinked) "Привязана" else "Привязать", color = if (isLinked) TelegramInkMuted else TelegramBlue) + } + } +} + +@Composable +private fun ChannelIdRow( + chatId: String, + onCopyClick: () -> Unit +) { + Surface( modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, top = 10.dp, end = 8.dp, bottom = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text("UUID канала", style = MaterialTheme.typography.labelMedium, color = TelegramInkMuted) + SelectionContainer { + Text( + text = chatId, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Visible + ) + } + } + IconButton(onClick = onCopyClick, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = "Скопировать UUID канала", + tint = TelegramBlue, + modifier = Modifier.size(20.dp) + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun OverviewActions( + onMembersClick: () -> Unit, + onSearchClick: () -> Unit, + onRefreshClick: () -> Unit +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + TextButton(onClick = onMembersClick) { Text("Участники") } + TextButton(onClick = onSearchClick) { Text("Поиск") } + TextButton(onClick = onRefreshClick) { Text("Обновить") } + } +} + +@Composable +private fun InfoStatRow(label: String, value: Int, onClick: (() -> Unit)? = null) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -204,6 +435,13 @@ private fun InviteLinkRow( style = MaterialTheme.typography.bodySmall, color = if (link.isRevoked) TelegramDanger else TelegramInkMuted ) + inviteLinkLimitsText(link)?.let { limits -> + Text( + text = limits, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } if (!link.isRevoked) { TextButton(onClick = onRevokeClick) { Text("Отозвать") } } @@ -233,7 +471,32 @@ private fun ChatType.label(): String = when (this) { ChatType.Channel -> "Канал" } -private fun com.seven.ikar.kotlin.core.model.ChatInfoDto.channelAudienceText(): String { +private fun inviteLinkLimitsText(link: ChatInviteLinkDto): String? { + val parts = buildList { + link.maxUses?.let { maxUses -> + add("${link.usesCount}/$maxUses использований") + } + link.memberLimit?.let { memberLimit -> + add("до $memberLimit участников") + } + link.expiresAt?.let { expiresAt -> + add("до ${formatInviteDate(expiresAt)}") + } + } + return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") +} + +private fun formatInviteDate(value: String): String = + runCatching { + OffsetDateTime.parse(value) + .atZoneSameInstant(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")) + }.getOrElse { value } + +private fun ChatInfoDto.canEditProfile(): Boolean = + chat.type != ChatType.Direct && (canManageChannel || chat.currentUserPermissions?.canManageMembers == true) + +private fun ChatInfoDto.channelAudienceText(): String { if (chat.type != ChatType.Channel) { return "${chat.participants.size} участников" } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoViewModel.kt index efe9f57e..b401f6fb 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatInfoViewModel.kt @@ -3,17 +3,32 @@ package com.seven.ikar.kotlin.feature.chatinfo import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.ChatStateRequest import com.seven.ikar.kotlin.core.model.ChatInfoDto import com.seven.ikar.kotlin.core.model.ChatInviteLinkDto +import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import com.seven.ikar.kotlin.core.model.ChatType +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter data class ChatInfoUiState( val info: ChatInfoDto? = null, + val discussionGroups: List = emptyList(), val inviteTitle: String = "", val inviteCreatesJoinRequest: Boolean = false, + val inviteExpiresInHours: String = "", + val inviteMaxUses: String = "", + val inviteMemberLimit: String = "", + val profileTitle: String = "", + val profileDescription: String = "", + val profilePublicUsername: String = "", val topicTitle: String = "", val isLoading: Boolean = false, val isSaving: Boolean = false, @@ -26,6 +41,7 @@ class ChatInfoViewModel( ) : ViewModel() { private val _uiState = MutableStateFlow(ChatInfoUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var profileAutosaveJob: Job? = null init { refresh() @@ -36,10 +52,25 @@ class ChatInfoViewModel( _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) runCatching { container.executeAuthorized { session -> - container.apiClient.getChatInfo(session.accessToken, chatId) + val info = container.apiClient.getChatInfo(session.accessToken, chatId) + val discussionGroups = if (info.chat.type == ChatType.Channel && info.canManageChannel) { + container.apiClient.getChats(session.accessToken) + .filter { chat -> chat.type == ChatType.Group } + .sortedBy { chat -> chat.title.lowercase() } + } else { + emptyList() + } + info to discussionGroups } - }.onSuccess { info -> - _uiState.value = _uiState.value.copy(info = info, isLoading = false) + }.onSuccess { (info, discussionGroups) -> + _uiState.value = _uiState.value.copy( + info = info, + discussionGroups = discussionGroups, + profileTitle = info.chat.title, + profileDescription = info.description ?: info.chat.description.orEmpty(), + profilePublicUsername = info.publicUsername ?: info.chat.publicUsername.orEmpty(), + isLoading = false + ) }.onFailure { error -> _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) } @@ -54,13 +85,111 @@ class ChatInfoViewModel( _uiState.value = _uiState.value.copy(inviteCreatesJoinRequest = value) } + fun updateInviteExpiresInHours(value: String) { + _uiState.value = _uiState.value.copy(inviteExpiresInHours = value.digitsOnly(maxLength = 5)) + } + + fun updateInviteMaxUses(value: String) { + _uiState.value = _uiState.value.copy(inviteMaxUses = value.digitsOnly(maxLength = 6)) + } + + fun updateInviteMemberLimit(value: String) { + _uiState.value = _uiState.value.copy(inviteMemberLimit = value.digitsOnly(maxLength = 6)) + } + fun updateTopicTitle(value: String) { _uiState.value = _uiState.value.copy(topicTitle = value.take(80)) } + fun updateProfileTitle(value: String) { + _uiState.value = _uiState.value.copy(profileTitle = value.take(120), errorMessage = null) + scheduleProfileAutosave() + } + + fun updateProfileDescription(value: String) { + _uiState.value = _uiState.value.copy(profileDescription = value.take(255), errorMessage = null) + scheduleProfileAutosave() + } + + fun updateProfilePublicUsername(value: String) { + _uiState.value = _uiState.value.copy( + profilePublicUsername = value + .trim() + .trimStart('@') + .filter { it.isLetterOrDigit() || it == '_' } + .take(32) + .lowercase(), + errorMessage = null + ) + scheduleProfileAutosave() + } + + private fun scheduleProfileAutosave() { + profileAutosaveJob?.cancel() + profileAutosaveJob = viewModelScope.launch { + delay(ProfileAutosaveDelayMillis) + profileAutosaveJob = null + saveProfileFields() + } + } + + private fun saveProfileFields() { + val state = _uiState.value + val info = state.info ?: return + if (!info.canEditProfile()) return + if (state.isSaving) { + scheduleProfileAutosave() + return + } + + val title = state.profileTitle.trim() + if (title.isBlank()) { + _uiState.value = state.copy(errorMessage = "Название не может быть пустым.") + return + } + + val description = state.profileDescription.trim() + val publicUsername = state.profilePublicUsername.trim().trimStart('@') + if (profileMatchesInfo(info, title, description, publicUsername)) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.updateChatState( + accessToken = session.accessToken, + chatId = chatId, + request = ChatStateRequest( + title = title, + description = description.takeIf { it.isNotBlank() }, + publicUsername = publicUsername.takeIf { info.chat.type == ChatType.Channel && it.isNotBlank() }, + clearDescription = description.isBlank(), + clearPublicUsername = info.chat.type == ChatType.Channel && publicUsername.isBlank() + ) + ) + } + }.onSuccess { + val current = _uiState.value + val changedAgain = current.profileTitle.trim() != title || + current.profileDescription.trim() != description || + current.profilePublicUsername.trim().trimStart('@') != publicUsername + _uiState.value = current.copy(isSaving = false) + if (changedAgain) { + scheduleProfileAutosave() + } else { + refresh() + } + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } + fun createInviteLink() { val state = _uiState.value - if (state.isSaving) return + if (state.isSaving || state.info?.canInviteUsers != true) return viewModelScope.launch { _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) runCatching { @@ -69,13 +198,23 @@ class ChatInfoViewModel( accessToken = session.accessToken, chatId = chatId, title = state.inviteTitle, - createsJoinRequest = state.inviteCreatesJoinRequest + createsJoinRequest = state.inviteCreatesJoinRequest, + expiresAt = state.inviteExpiresInHours.toPositiveIntOrNull()?.let { hours -> + OffsetDateTime.now(ZoneOffset.UTC) + .plusHours(hours.toLong()) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + }, + maxUses = state.inviteMaxUses.toPositiveIntOrNull(), + memberLimit = state.inviteMemberLimit.toPositiveIntOrNull() ) } }.onSuccess { _uiState.value = _uiState.value.copy( inviteTitle = "", inviteCreatesJoinRequest = false, + inviteExpiresInHours = "", + inviteMaxUses = "", + inviteMemberLimit = "", isSaving = false ) refresh() @@ -86,7 +225,7 @@ class ChatInfoViewModel( } fun revokeInviteLink(link: ChatInviteLinkDto) { - if (_uiState.value.isSaving) return + if (_uiState.value.isSaving || _uiState.value.info?.canInviteUsers != true) return viewModelScope.launch { _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) runCatching { @@ -120,4 +259,64 @@ class ChatInfoViewModel( } } } + + fun setLinkedDiscussion(group: ChatSummaryDto) { + updateLinkedDiscussion(ChatStateRequest(linkedDiscussionChatId = group.id)) + } + + fun clearLinkedDiscussion() { + updateLinkedDiscussion(ChatStateRequest(clearLinkedDiscussionChat = true)) + } + + private fun updateLinkedDiscussion(request: ChatStateRequest) { + val state = _uiState.value + val info = state.info ?: return + if (state.isSaving || info.chat.type != ChatType.Channel || !info.canManageChannel) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.updateChatState( + accessToken = session.accessToken, + chatId = chatId, + request = request + ) + } + }.onSuccess { + _uiState.value = _uiState.value.copy(isSaving = false) + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } + } + } +} + +private const val ProfileAutosaveDelayMillis = 600L + +private fun String.digitsOnly(maxLength: Int): String = + filter { it.isDigit() }.take(maxLength) + +private fun String.toPositiveIntOrNull(): Int? = + toIntOrNull()?.takeIf { it > 0 } + +private fun ChatInfoDto.canEditProfile(): Boolean = + chat.type != ChatType.Direct && (canManageChannel || chat.currentUserPermissions?.canManageMembers == true) + +private fun profileMatchesInfo( + info: ChatInfoDto, + title: String, + description: String, + publicUsername: String +): Boolean { + val currentDescription = (info.description ?: info.chat.description.orEmpty()).trim() + val currentPublicUsername = (info.publicUsername ?: info.chat.publicUsername.orEmpty()) + .trim() + .trimStart('@') + return info.chat.title.trim() == title && + currentDescription == description && + (info.chat.type != ChatType.Channel || currentPublicUsername == publicUsername) } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatMaterialsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatMaterialsScreen.kt new file mode 100644 index 00000000..efc3b2de --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatMaterialsScreen.kt @@ -0,0 +1,175 @@ +package com.seven.ikar.kotlin.feature.chatinfo + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.core.model.MessageDto +import com.seven.ikar.kotlin.ui.IkarRowDivider +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSectionCard +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.formatLastActivity +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun ChatMaterialsScreen( + state: ChatMaterialsUiState, + onBackClick: () -> Unit, + onLoadMore: () -> Unit, + onMessageClick: (MessageDto) -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + IkarSimpleHeader(title = state.filter.title, onBackClick = onBackClick) + + state.errorMessage?.let { + Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall) + } + + IkarSectionCard(modifier = Modifier.weight(1f)) { + when { + state.isLoading -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(color = TelegramBlue) + } + } + + state.messages.isEmpty() -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text("Пока пусто", style = MaterialTheme.typography.titleMedium) + Text( + emptySubtitle(state.filter), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + } + + else -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.messages, key = { it.id }) { message -> + ChatMaterialRow( + message = message, + filter = state.filter, + onClick = { onMessageClick(message) } + ) + IkarRowDivider(startIndent = 0.dp) + } + if (state.hasMore) { + item(key = "load-more") { + TextButton( + enabled = !state.isLoadingMore, + onClick = onLoadMore, + modifier = Modifier.fillMaxWidth() + ) { + Text(if (state.isLoadingMore) "Загрузка..." else "Показать ещё", color = TelegramBlue) + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun ChatMaterialRow( + message: MessageDto, + filter: ChatMaterialFilter, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = message.displayAuthorName ?: message.sender.displayName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = materialPreview(message, filter), + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + message.attachments.takeIf { it.isNotEmpty() }?.let { attachments -> + Text( + text = attachments.joinToString(limit = 3) { it.fileName }, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + Text( + text = formatLastActivity(message.sentAt), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } +} + +private fun materialPreview(message: MessageDto, filter: ChatMaterialFilter): String = + when { + message.text.isNotBlank() -> message.text + filter == ChatMaterialFilter.Pinned -> "Закреплённое сообщение" + message.attachments.isNotEmpty() -> message.attachments.first().fileName + else -> "Сообщение" + } + +private fun emptySubtitle(filter: ChatMaterialFilter): String = + when (filter) { + ChatMaterialFilter.Media -> "В этом чате пока нет фото или видео." + ChatMaterialFilter.Files -> "В этом чате пока нет файлов." + ChatMaterialFilter.Links -> "В этом чате пока нет ссылок." + ChatMaterialFilter.Voice -> "В этом чате пока нет голосовых сообщений." + ChatMaterialFilter.Pinned -> "В этом чате пока нет закреплённых сообщений." + } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatMaterialsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatMaterialsViewModel.kt new file mode 100644 index 00000000..c2cbe281 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chatinfo/ChatMaterialsViewModel.kt @@ -0,0 +1,97 @@ +package com.seven.ikar.kotlin.feature.chatinfo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.MessageDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +enum class ChatMaterialFilter(val routeValue: String, val wireValue: String, val title: String) { + Media("media", "media", "Медиа"), + Files("files", "files", "Файлы"), + Links("links", "links", "Ссылки"), + Voice("voice", "voice", "Голосовые"), + Pinned("pinned", "pinned", "Закреплённые"); + + companion object { + fun fromRoute(value: String?): ChatMaterialFilter = + entries.firstOrNull { it.routeValue == value } ?: Media + } +} + +data class ChatMaterialsUiState( + val filter: ChatMaterialFilter, + val messages: List = emptyList(), + val page: Int = 1, + val hasMore: Boolean = false, + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val errorMessage: String? = null +) + +class ChatMaterialsViewModel( + private val container: AppContainer, + private val chatId: String, + private val filter: ChatMaterialFilter +) : ViewModel() { + private val _uiState = MutableStateFlow(ChatMaterialsUiState(filter = filter, isLoading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + loadPage(page = 1, append = false) + } + + fun loadMore() { + val state = _uiState.value + if (!state.hasMore || state.isLoading || state.isLoadingMore) { + return + } + + loadPage(page = state.page + 1, append = true) + } + + private fun loadPage(page: Int, append: Boolean) { + viewModelScope.launch { + val current = _uiState.value + _uiState.value = current.copy( + isLoading = !append, + isLoadingMore = append, + errorMessage = null + ) + runCatching { + container.executeAuthorized { session -> + container.apiClient.searchChatMessagesPage( + accessToken = session.accessToken, + chatId = chatId, + query = " ", + filter = filter.wireValue, + page = page, + limit = 40 + ) + } + }.onSuccess { result -> + val existing = if (append) _uiState.value.messages else emptyList() + _uiState.value = _uiState.value.copy( + messages = (existing + result.messages).distinctBy { it.id }, + page = result.page, + hasMore = result.hasMore, + isLoading = false, + isLoadingMore = false + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoadingMore = false, + errorMessage = error.message + ) + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt index 5acbb650..dc969c8c 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt @@ -39,10 +39,14 @@ import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -71,6 +75,7 @@ import com.seven.ikar.kotlin.core.model.StoryDto import com.seven.ikar.kotlin.core.network.ServerConfig import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot +import com.seven.ikar.kotlin.core.settings.ChatSwipeLeftActionOption import com.seven.ikar.kotlin.feature.share.PendingExternalShare import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage import com.seven.ikar.kotlin.ui.AvatarView @@ -99,6 +104,11 @@ fun ChatsScreen( onPinSelectedChatsClick: () -> Unit, onArchiveSelectedChatsClick: () -> Unit, onMuteSelectedChatsClick: () -> Unit, + onTogglePinChat: (ChatSummaryDto) -> Unit, + onToggleArchiveChat: (ChatSummaryDto) -> Unit, + onToggleMuteChat: (ChatSummaryDto) -> Unit, + onMarkChatRead: (ChatSummaryDto) -> Unit, + onSwipeDeleteChat: (ChatSummaryDto) -> Unit, onDismissDeleteDialog: () -> Unit, onDeleteForEveryoneChanged: (Boolean) -> Unit, onConfirmDeleteSelected: () -> Unit, @@ -112,6 +122,7 @@ fun ChatsScreen( onSettingsClick: () -> Unit, onNotificationsClick: () -> Unit, onGlobalSearchClick: () -> Unit, + onFilterChanged: (ChatListFilter) -> Unit, onQueryChanged: (String) -> Unit ) { val isRealtimeConnected = !state.isLoading || state.chats.isNotEmpty() @@ -229,6 +240,13 @@ fun ChatsScreen( placeholder = "Поиск чатов" ) + if (!state.isSelectionMode && !state.isShareMode) { + ChatFilterTabs( + selectedFilter = state.filter, + onFilterChanged = onFilterChanged + ) + } + TextButton(onClick = onGlobalSearchClick) { Text("Глобальный поиск: сообщения, медиа, файлы и ссылки") } @@ -275,20 +293,38 @@ fun ChatsScreen( } } else { items(state.chats, key = { it.chat.id }) { chatItem -> - ChatListRow( - chatItem = chatItem, - currentUserId = state.session?.user?.id, - accessToken = state.session?.accessToken, - previewMaxLines = previewMaxLines, - onClick = { - if (state.isSelectionMode) { - onChatSelectionClick(chatItem.chat) - } else { - onChatClick(chatItem.chat) - } - }, - onLongClick = { onChatLongPress(chatItem.chat) } - ) + val rowClick = { + if (state.isSelectionMode) { + onChatSelectionClick(chatItem.chat) + } else { + onChatClick(chatItem.chat) + } + } + if (state.isSelectionMode || state.isShareMode) { + ChatListRow( + chatItem = chatItem, + currentUserId = state.session?.user?.id, + accessToken = state.session?.accessToken, + previewMaxLines = previewMaxLines, + onClick = rowClick, + onLongClick = { onChatLongPress(chatItem.chat) } + ) + } else { + SwipeableChatListRow( + chatItem = chatItem, + currentUserId = state.session?.user?.id, + accessToken = state.session?.accessToken, + previewMaxLines = previewMaxLines, + swipeLeftAction = chatSettings.swipeLeftAction, + onClick = rowClick, + onLongClick = { onChatLongPress(chatItem.chat) }, + onTogglePin = { onTogglePinChat(chatItem.chat) }, + onToggleArchive = { onToggleArchiveChat(chatItem.chat) }, + onToggleMute = { onToggleMuteChat(chatItem.chat) }, + onMarkRead = { onMarkChatRead(chatItem.chat) }, + onDelete = { onSwipeDeleteChat(chatItem.chat) } + ) + } } } item { Box(modifier = Modifier.padding(bottom = 132.dp)) } @@ -323,6 +359,7 @@ fun ChatsScreen( selectedChatsCount = state.selectedChatsCount, showDeleteForEveryoneOption = state.showDeleteForEveryoneOption, deleteForEveryone = state.deleteForEveryone, + deleteForEveryoneLabel = state.deleteForEveryoneLabel, isDeletingSelected = state.isDeletingSelected, onDismiss = onDismissDeleteDialog, onDeleteForEveryoneChanged = onDeleteForEveryoneChanged, @@ -332,6 +369,40 @@ fun ChatsScreen( } } +@Composable +private fun ChatFilterTabs( + selectedFilter: ChatListFilter, + onFilterChanged: (ChatListFilter) -> Unit +) { + val filters = listOf( + ChatListFilter.All to "Все", + ChatListFilter.Unread to "Непрочитанные", + ChatListFilter.Pinned to "Закреплённые", + ChatListFilter.Archive to "Архив" + ) + + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(filters, key = { it.first.name }) { (filter, label) -> + val isSelected = filter == selectedFilter + Text( + text = label, + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .background(if (isSelected) TelegramBlue else TelegramBlue.copy(alpha = 0.08f)) + .clickable { onFilterChanged(filter) } + .padding(horizontal = 14.dp, vertical = 8.dp), + color = if (isSelected) Color.White else TelegramBlue, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + } + } +} + @Composable private fun ChatsBrandHeader( isRealtimeConnected: Boolean, @@ -370,7 +441,7 @@ private fun StoriesClusterButton( currentUserId: String?, onClick: () -> Unit ) { - val previewStories = stories.take(3) + val previewStories = remember(stories) { stories.toAuthorGroups().take(3) } val placeholderColors = listOf( TelegramBlue, Color(0xFF7C4DFF), @@ -385,7 +456,8 @@ private fun StoriesClusterButton( contentAlignment = Alignment.CenterStart ) { repeat(3) { index -> - val story = previewStories.getOrNull(index) + val group = previewStories.getOrNull(index) + val story = group?.latestStory Box( modifier = Modifier .padding(start = (index * 13).dp) @@ -432,6 +504,7 @@ private fun StoriesTray( onComposeStoryClick: () -> Unit, onStoryClick: (StoryDto) -> Unit ) { + val storyGroups = remember(stories) { stories.toAuthorGroups() } Column( modifier = Modifier .fillMaxWidth() @@ -454,7 +527,8 @@ private fun StoriesTray( ) } - items(stories, key = { it.id }) { story -> + items(storyGroups, key = { it.authorId }) { group -> + val story = group.latestStory StoryBubble( title = if (story.author.id == currentUserId) "Моя" else story.author.displayName, avatarUrl = ServerConfig.normalizeMediaUrl(story.author.avatarPath), @@ -462,8 +536,9 @@ private fun StoriesTray( story.attachments.firstOrNull { it.contentType.startsWith("image/") }?.downloadPath ), accessToken = accessToken, - viewed = story.viewedByMe || story.author.id == currentUserId, - onClick = { onStoryClick(story) } + viewed = group.isViewedByMe || story.author.id == currentUserId, + badgeText = group.stories.size.takeIf { it > 1 }?.toString(), + onClick = { onStoryClick(group.openStory) } ) } @@ -481,6 +556,7 @@ private fun StoryBubble( previewUrl: String?, accessToken: String?, viewed: Boolean, + badgeText: String? = null, onClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -520,6 +596,24 @@ private fun StoryBubble( size = 56.dp ) } + badgeText?.let { text -> + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(20.dp) + .clip(CircleShape) + .background(TelegramBlue) + .border(1.dp, Color.White, CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = Color.White, + maxLines = 1 + ) + } + } } Text( title, @@ -530,6 +624,30 @@ private fun StoryBubble( } } +private data class StoryAuthorGroup( + val authorId: String, + val stories: List +) { + val latestStory: StoryDto + get() = stories.first() + + val openStory: StoryDto + get() = stories.firstOrNull { !it.viewedByMe } ?: latestStory + + val isViewedByMe: Boolean + get() = stories.all { it.viewedByMe } +} + +private fun List.toAuthorGroups(): List = + groupBy { it.author.id } + .map { (authorId, authorStories) -> + StoryAuthorGroup( + authorId = authorId, + stories = authorStories.sortedByDescending { it.createdAt } + ) + } + .sortedByDescending { it.latestStory.createdAt } + @Composable private fun ShareSelectionHeader( selectedChatsCount: Int, @@ -718,6 +836,7 @@ private fun DeleteChatsDialog( selectedChatsCount: Int, showDeleteForEveryoneOption: Boolean, deleteForEveryone: Boolean, + deleteForEveryoneLabel: String, isDeletingSelected: Boolean, onDismiss: () -> Unit, onDeleteForEveryoneChanged: (Boolean) -> Unit, @@ -749,7 +868,7 @@ private fun DeleteChatsDialog( onCheckedChange = onDeleteForEveryoneChanged ) Text( - text = "Также удалить чат для собеседника", + text = deleteForEveryoneLabel, style = MaterialTheme.typography.bodyMedium ) } @@ -775,6 +894,94 @@ private fun DeleteChatsDialog( ) } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun SwipeableChatListRow( + chatItem: ChatListItemUiState, + currentUserId: String?, + accessToken: String?, + previewMaxLines: Int, + swipeLeftAction: ChatSwipeLeftActionOption, + onClick: () -> Unit, + onLongClick: () -> Unit, + onTogglePin: () -> Unit, + onToggleArchive: () -> Unit, + onToggleMute: () -> Unit, + onMarkRead: () -> Unit, + onDelete: () -> Unit +) { + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + when (value) { + SwipeToDismissBoxValue.StartToEnd -> onTogglePin() + SwipeToDismissBoxValue.EndToStart -> when (swipeLeftAction) { + ChatSwipeLeftActionOption.Delete -> onDelete() + ChatSwipeLeftActionOption.Archive -> onToggleArchive() + ChatSwipeLeftActionOption.Mute -> onToggleMute() + ChatSwipeLeftActionOption.Read -> onMarkRead() + } + SwipeToDismissBoxValue.Settled -> Unit + } + false + } + ) + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = true, + enableDismissFromEndToStart = true, + backgroundContent = { + Row( + modifier = Modifier + .fillMaxSize() + .background(TelegramBlue.copy(alpha = 0.08f)) + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.PushPin, contentDescription = null, tint = TelegramBlue) + Text( + if (chatItem.chat.isPinned) "Открепить" else "Закрепить", + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + val leftSwipeIcon = when (swipeLeftAction) { + ChatSwipeLeftActionOption.Delete -> Icons.Outlined.Delete + ChatSwipeLeftActionOption.Archive -> Icons.Outlined.Archive + ChatSwipeLeftActionOption.Mute -> Icons.Outlined.NotificationsOff + ChatSwipeLeftActionOption.Read -> Icons.Outlined.CheckCircle + } + Text( + chatItem.chat.leftSwipeActionLabel(swipeLeftAction), + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) + Icon(leftSwipeIcon, contentDescription = null, tint = TelegramBlue) + } + } + } + ) { + ChatListRow( + chatItem = chatItem, + currentUserId = currentUserId, + accessToken = accessToken, + previewMaxLines = previewMaxLines, + onClick = onClick, + onLongClick = onLongClick + ) + } +} + +private fun ChatSummaryDto.leftSwipeActionLabel(action: ChatSwipeLeftActionOption): String = + when (action) { + ChatSwipeLeftActionOption.Delete -> "Удалить" + ChatSwipeLeftActionOption.Archive -> if (isArchived) "Вернуть" else "В архив" + ChatSwipeLeftActionOption.Mute -> if (isMuted) "Включить звук" else "Без звука" + ChatSwipeLeftActionOption.Read -> if (unreadCount > 0) "Прочитать" else "Прочитано" + } + @Composable @OptIn(ExperimentalFoundationApi::class) private fun ChatListRow( @@ -795,7 +1002,7 @@ private fun ChatListRow( val rowBackground = if (chatItem.isSelected) { TelegramBlue.copy(alpha = 0.12f) } else { - Color.Transparent + MaterialTheme.colorScheme.background } Column { @@ -848,6 +1055,7 @@ private fun ChatListRow( ) { ChatLastMessagePreview( chat = chat, + draftText = chatItem.draftText, accessToken = accessToken, modifier = Modifier.weight(1f), maxLines = previewMaxLines @@ -887,12 +1095,37 @@ private fun ChatListRow( @Composable private fun ChatLastMessagePreview( chat: ChatSummaryDto, + draftText: String, accessToken: String?, modifier: Modifier = Modifier, maxLines: Int ) { val attachment = chat.lastMessageAttachment when { + draftText.isNotBlank() -> { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Черновик:", + style = MaterialTheme.typography.bodySmall, + color = TelegramBlue, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + Text( + text = draftText, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis + ) + } + } + attachment?.isChatPreviewImage() == true -> { Row( modifier = modifier, @@ -980,8 +1213,7 @@ private fun AttachmentDto.isChatPreviewImage(): Boolean = fileName.endsWith(".gif", ignoreCase = true) private fun AttachmentDto.isChatPreviewVoiceNote(): Boolean = - kind == AttachmentKind.VoiceNote || - contentType.startsWith("audio/", ignoreCase = true) + kind == AttachmentKind.VoiceNote private fun selectionCountLabel(selectedChatsCount: Int): String = when { selectedChatsCount % 10 == 1 && selectedChatsCount % 100 != 11 -> "Выбран $selectedChatsCount чат" diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt index a2539dfe..87a361aa 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt @@ -31,6 +31,7 @@ data class ChatListItemUiState( val chat: ChatSummaryDto, val resolvedTitle: String, val lastActivityLabel: String, + val draftText: String, val isSelected: Boolean ) @@ -48,6 +49,8 @@ enum class ChatListFilter { Archive } +private const val DefaultDeleteForEveryoneLabel = "Также удалить чат для собеседника" + data class ChatsUiState( val session: AuthSessionDto? = null, val query: String = "", @@ -64,7 +67,9 @@ data class ChatsUiState( val isDeleteDialogVisible: Boolean = false, val deleteForEveryone: Boolean = false, val showDeleteForEveryoneOption: Boolean = false, - val isDeletingSelected: Boolean = false + val deleteForEveryoneLabel: String = DefaultDeleteForEveryoneLabel, + val isDeletingSelected: Boolean = false, + val selectionStartedFromSwipeDelete: Boolean = false ) { val isShareMode: Boolean get() = pendingShare != null @@ -81,11 +86,13 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() { private val _allChats = mutableListOf() private var contactNamesByNormalizedPhone: Map = emptyMap() private var refreshJob: Job? = null + private var hadActiveStoryTransfers = false val uiState: StateFlow = _uiState.asStateFlow() init { attachIncomingShare() attachRealtime() + attachStoryTransferCompletion() loadCachedChats() refresh() refreshLatestRelease() @@ -128,7 +135,9 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() { isDeleteDialogVisible = false, deleteForEveryone = false, showDeleteForEveryoneOption = false, - isDeletingSelected = false + deleteForEveryoneLabel = DefaultDeleteForEveryoneLabel, + isDeletingSelected = false, + selectionStartedFromSwipeDelete = false ) applyFilter() } @@ -143,15 +152,23 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() { _uiState.value = state.copy( isDeleteDialogVisible = true, deleteForEveryone = state.deleteForEveryone && showDeleteForEveryoneOption, - showDeleteForEveryoneOption = showDeleteForEveryoneOption + showDeleteForEveryoneOption = showDeleteForEveryoneOption, + deleteForEveryoneLabel = deleteForEveryoneLabel(state.selectedChatIds), + selectionStartedFromSwipeDelete = false ) } fun dismissDeleteDialog() { - _uiState.value = _uiState.value.copy( + val state = _uiState.value + _uiState.value = state.copy( + selectedChatIds = if (state.selectionStartedFromSwipeDelete) emptySet() else state.selectedChatIds, isDeleteDialogVisible = false, - deleteForEveryone = false + deleteForEveryone = false, + showDeleteForEveryoneOption = false, + deleteForEveryoneLabel = DefaultDeleteForEveryoneLabel, + selectionStartedFromSwipeDelete = false ) + applyFilter() } fun updateDeleteForEveryone(value: Boolean) { @@ -227,6 +244,82 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() { ) } + fun togglePinChat(chat: ChatSummaryDto) { + updateSingleChatState(chat.id, ChatStateRequest(isPinned = !chat.isPinned)) + } + + fun toggleArchiveChat(chat: ChatSummaryDto) { + updateSingleChatState(chat.id, ChatStateRequest(isArchived = !chat.isArchived)) + } + + fun toggleMuteChat(chat: ChatSummaryDto) { + val shouldMute = !chat.isMuted + updateSingleChatState( + chat.id, + ChatStateRequest( + mutedUntil = if (shouldMute) { + OffsetDateTime.now(ZoneOffset.UTC).plusYears(1).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + } else { + null + }, + isMuted = shouldMute + ) + ) + } + + fun markChatRead(chat: ChatSummaryDto) { + if (chat.unreadCount <= 0) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.markChatRead(session.accessToken, chat.id) + } + }.onSuccess { + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + + fun showSwipeDeleteDialog(chat: ChatSummaryDto) { + val state = _uiState.value + if (state.isShareMode || state.isDeletingSelected) { + return + } + + val selectedChatIds = setOf(chat.id) + val showDeleteForEveryoneOption = canDeleteSelectedForEveryone(selectedChatIds) + _uiState.value = state.copy( + selectedChatIds = selectedChatIds, + isDeleteDialogVisible = true, + deleteForEveryone = false, + showDeleteForEveryoneOption = showDeleteForEveryoneOption, + deleteForEveryoneLabel = deleteForEveryoneLabel(selectedChatIds), + selectionStartedFromSwipeDelete = true + ) + applyFilter() + } + + private fun updateSingleChatState(chatId: String, request: ChatStateRequest) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(errorMessage = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.updateChatState(session.accessToken, chatId, request) + } + }.onSuccess { + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy(errorMessage = error.message) + } + } + } + private fun updateSelectedChatState(request: ChatStateRequest) { val selectedChatIds = _uiState.value.selectedChatIds.toList() if (selectedChatIds.isEmpty()) { @@ -403,15 +496,21 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() { } else { chatItems.filter { item -> item.resolvedTitle.contains(query, ignoreCase = true) || - item.chat.title.contains(query, ignoreCase = true) || + item.chat.title.contains(query, ignoreCase = true) || item.chat.secondaryText.orEmpty().contains(query, ignoreCase = true) || - item.chat.lastMessagePreview.orEmpty().contains(query, ignoreCase = true) + item.chat.lastMessagePreview.orEmpty().contains(query, ignoreCase = true) || + item.draftText.contains(query, ignoreCase = true) } } _uiState.value = currentState.copy( chats = filtered, selectedChatIds = selectedChatIds, showDeleteForEveryoneOption = showDeleteForEveryoneOption, + deleteForEveryoneLabel = if (showDeleteForEveryoneOption) { + deleteForEveryoneLabel(selectedChatIds) + } else { + DefaultDeleteForEveryoneLabel + }, deleteForEveryone = deleteForEveryone, isDeleteDialogVisible = !currentState.isShareMode && currentState.isDeleteDialogVisible && @@ -428,6 +527,7 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() { chat = chat, resolvedTitle = resolveChatTitle(chat, currentUserId), lastActivityLabel = formatLastActivity(chat.lastActivityAt), + draftText = container.chatDraftStore.read(chat.id).trim(), isSelected = selectedChatIds.contains(chat.id) ) } @@ -469,7 +569,16 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() { } val chat = _allChats.firstOrNull { selectedChatIds.contains(it.id) } ?: return false - return chat.type == ChatType.Direct + return chat.canDeleteForEveryone + } + + private fun deleteForEveryoneLabel(selectedChatIds: Set): String { + val chat = _allChats.firstOrNull { selectedChatIds.contains(it.id) } + return when (chat?.type) { + ChatType.Channel -> "Удалить канал для всех подписчиков" + ChatType.Group -> "Удалить чат для всех участников" + else -> DefaultDeleteForEveryoneLabel + } } private fun attachRealtime() { @@ -485,11 +594,30 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() { viewModelScope.launch { container.realtimeService.messagesUpdated.collect { scheduleRefresh() } } + viewModelScope.launch { + container.realtimeService.storiesChanged.collect { scheduleRefresh() } + } viewModelScope.launch { container.realtimeService.appUpdateAvailable.collect { release -> _uiState.value = _uiState.value.copy(latestRelease = release) } } + viewModelScope.launch { + container.chatDraftStore.changes.collect { + applyFilter() + } + } + } + + private fun attachStoryTransferCompletion() { + viewModelScope.launch { + container.transferQueueRepository.observeActiveStoryUploads().collect { transfers -> + if (hadActiveStoryTransfers && transfers.isEmpty()) { + scheduleRefresh() + } + hadActiveStoryTransfers = transfers.isNotEmpty() + } + } } private fun scheduleRefresh() { @@ -510,7 +638,9 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() { isDeleteDialogVisible = if (pendingShare == null) currentState.isDeleteDialogVisible else false, deleteForEveryone = if (pendingShare == null) currentState.deleteForEveryone else false, showDeleteForEveryoneOption = if (pendingShare == null) currentState.showDeleteForEveryoneOption else false, + deleteForEveryoneLabel = if (pendingShare == null) currentState.deleteForEveryoneLabel else DefaultDeleteForEveryoneLabel, isDeletingSelected = if (pendingShare == null) currentState.isDeletingSelected else false, + selectionStartedFromSwipeDelete = if (pendingShare == null) currentState.selectionStartedFromSwipeDelete else false, isSendingShare = currentState.isSendingShare && pendingShare != null ) applyFilter() diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesScreen.kt index bb034951..fb24025d 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesScreen.kt @@ -22,7 +22,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.seven.ikar.kotlin.core.model.UserSessionDto +import com.seven.ikar.kotlin.core.model.PushDeviceDto +import com.seven.ikar.kotlin.core.model.PushPlatform import com.seven.ikar.kotlin.ui.IkarRowDivider import com.seven.ikar.kotlin.ui.IkarScreenSurface import com.seven.ikar.kotlin.ui.IkarSectionCard @@ -36,8 +37,7 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted fun DevicesScreen( state: DevicesUiState, onBackClick: () -> Unit, - onRefreshClick: () -> Unit, - onRevokeClick: (UserSessionDto) -> Unit + onRefreshClick: () -> Unit ) { IkarScreenSurface(modifier = Modifier.fillMaxSize()) { Column( @@ -52,14 +52,25 @@ fun DevicesScreen( state.errorMessage?.let { Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall) } - if (state.isLoading && state.sessions.isEmpty()) { + if (state.isLoading && state.devices.isEmpty()) { CircularProgressIndicator(color = TelegramBlue) } IkarSectionCard { - IkarSectionTitle("Активные сеансы", modifier = Modifier.padding(bottom = 8.dp)) - state.sessions.forEachIndexed { index, session -> - DeviceRow(session = session, enabled = !state.isSaving, onRevokeClick = { onRevokeClick(session) }) - if (index != state.sessions.lastIndex) { + IkarSectionTitle("Устройства с Икаром", modifier = Modifier.padding(bottom = 8.dp)) + if (state.devices.isEmpty() && !state.isLoading) { + Text( + text = "Зарегистрированных устройств нет.", + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted, + modifier = Modifier.padding(vertical = 12.dp) + ) + } + state.devices.forEachIndexed { index, device -> + DeviceRow( + device = device, + isCurrent = device.installationId == state.currentInstallationId + ) + if (index != state.devices.lastIndex) { IkarRowDivider() } } @@ -71,9 +82,8 @@ fun DevicesScreen( @Composable private fun DeviceRow( - session: UserSessionDto, - enabled: Boolean, - onRevokeClick: () -> Unit + device: PushDeviceDto, + isCurrent: Boolean ) { Row( modifier = Modifier @@ -83,17 +93,34 @@ private fun DeviceRow( verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { - Text(session.deviceName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text( - text = "Последняя активность: ${session.lastSeenAt}", + text = device.deviceName?.takeIf { it.isNotBlank() } ?: formatPlatform(device.platform), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "${formatPlatform(device.platform)} · ${formatNotificationState(device.notificationsEnabled)}", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + Text( + text = "Последняя регистрация: ${device.updatedAt}", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted ) } - if (session.isCurrent) { - Text("Текущий", color = TelegramBlue) - } else { - TextButton(enabled = enabled, onClick = onRevokeClick) { Text("Завершить") } + if (isCurrent) { + Text("Это устройство", color = TelegramBlue) } } } + +private fun formatPlatform(platform: PushPlatform): String = + when (platform) { + PushPlatform.Android -> "Android" + PushPlatform.Windows -> "Windows" + PushPlatform.Unknown -> "Устройство" + } + +private fun formatNotificationState(enabled: Boolean): String = + if (enabled) "уведомления включены" else "уведомления выключены" diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesViewModel.kt index ab90ee44..cb8eb67f 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/devices/DevicesViewModel.kt @@ -3,16 +3,16 @@ package com.seven.ikar.kotlin.feature.devices import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.seven.ikar.kotlin.app.AppContainer -import com.seven.ikar.kotlin.core.model.UserSessionDto +import com.seven.ikar.kotlin.core.model.PushDeviceDto import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch data class DevicesUiState( - val sessions: List = emptyList(), + val devices: List = emptyList(), + val currentInstallationId: String = "", val isLoading: Boolean = false, - val isSaving: Boolean = false, val errorMessage: String? = null ) @@ -29,30 +29,17 @@ class DevicesViewModel(private val container: AppContainer) : ViewModel() { _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) runCatching { container.executeAuthorized { session -> - container.apiClient.getMySessions(session.accessToken) + container.apiClient.getPushDevices(session.accessToken) } - }.onSuccess { sessions -> - _uiState.value = _uiState.value.copy(sessions = sessions, isLoading = false) + }.onSuccess { devices -> + _uiState.value = _uiState.value.copy( + devices = devices, + currentInstallationId = container.pushSettings.getInstallationId(), + isLoading = false + ) }.onFailure { error -> _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) } } } - - fun revoke(session: UserSessionDto) { - if (session.isCurrent || _uiState.value.isSaving) return - viewModelScope.launch { - _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null) - runCatching { - container.executeAuthorized { auth -> - container.apiClient.revokeMySession(auth.accessToken, session.id) - } - }.onSuccess { - _uiState.value = _uiState.value.copy(isSaving = false) - refresh() - }.onFailure { error -> - _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) - } - } - } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverScreen.kt index db96bb58..cf776904 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverScreen.kt @@ -5,21 +5,29 @@ import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Group @@ -44,6 +52,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import com.seven.ikar.kotlin.core.model.ChatSummaryDto import com.seven.ikar.kotlin.ui.AvatarView import com.seven.ikar.kotlin.ui.IkarCenteredHeader import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble @@ -65,9 +74,13 @@ fun DiscoverScreen( onQueryChanged: (String) -> Unit, onRefreshClick: () -> Unit, onUserClick: (DiscoverUserItem) -> Unit, + onChannelClick: (ChatSummaryDto) -> Unit, onBeginGroupSelection: () -> Unit, onBeginChannelSelection: () -> Unit, onCancelSelection: () -> Unit, + onGroupBackClick: () -> Unit, + onGroupTitleChanged: (String) -> Unit, + onContinueGroupWizard: () -> Unit, onChannelBackClick: () -> Unit, onChannelTitleChanged: (String) -> Unit, onChannelDescriptionChanged: (String) -> Unit, @@ -85,8 +98,11 @@ fun DiscoverScreen( } val hasContactsPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED + val isGroupWizard = state.groupWizardStep != null val isChannelWizard = state.channelWizardStep != null - val showContacts = state.selectionMode != null || state.channelWizardStep == null + val showContacts = state.selectionMode != null || (state.groupWizardStep == null && state.channelWizardStep == null) + val showPublicChannels = state.selectionMode == null && state.groupWizardStep == null && state.channelWizardStep == null + val headerScrollState = rememberScrollState() LaunchedEffect(hasContactsPermission) { if (hasContactsPermission) { @@ -95,20 +111,40 @@ fun DiscoverScreen( } IkarScreenSurface(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + ) { + val headerModifier = if (showContacts) { + Modifier + .weight(1f, fill = false) + .verticalScroll(headerScrollState) + } else { + Modifier + .weight(1f) + .verticalScroll(headerScrollState) + } Column( - modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), + modifier = headerModifier + .fillMaxWidth() + .padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { IkarCenteredHeader( title = when { + isGroupWizard -> "Новая группа" isChannelWizard -> "Новый канал" state.selectionMode == DiscoverSelectionMode.Group -> "Новая группа" else -> "Новое сообщение" }, left = { IkarHeaderIconBubble( - onClick = if (isChannelWizard) onChannelBackClick else onBackClick + onClick = when { + isGroupWizard -> onGroupBackClick + isChannelWizard -> onChannelBackClick + else -> onBackClick + } ) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад", tint = MaterialTheme.colorScheme.onSurface) } @@ -134,8 +170,25 @@ fun DiscoverScreen( ) } - when (state.channelWizardStep) { - ChannelWizardStep.Details -> ChannelDetailsStep( + when { + state.groupWizardStep == GroupWizardStep.Details -> GroupDetailsStep( + state = state, + onTitleChanged = onGroupTitleChanged, + onCancelClick = onCancelSelection, + onNextClick = onContinueGroupWizard + ) + + state.groupWizardStep == GroupWizardStep.Members -> SelectionBanner( + title = "Участники группы", + subtitle = "Выберите контакты, которые войдут в новый общий чат.", + selectedCount = state.selectedUserIds.size, + actionText = if (state.isLoading) "Создание..." else "Создать", + onBackClick = onGroupBackClick, + onActionClick = onContinueGroupWizard, + enabled = !state.isLoading + ) + + state.channelWizardStep == ChannelWizardStep.Details -> ChannelDetailsStep( state = state, onTitleChanged = onChannelTitleChanged, onDescriptionChanged = onChannelDescriptionChanged, @@ -143,7 +196,7 @@ fun DiscoverScreen( onNextClick = onContinueChannelWizard ) - ChannelWizardStep.Type -> ChannelTypeStep( + state.channelWizardStep == ChannelWizardStep.Type -> ChannelTypeStep( state = state, onVisibilityChanged = onChannelVisibilityChanged, onPublicUsernameChanged = onChannelPublicUsernameChanged, @@ -152,7 +205,7 @@ fun DiscoverScreen( onNextClick = onContinueChannelWizard ) - ChannelWizardStep.Subscribers -> SelectionBanner( + state.channelWizardStep == ChannelWizardStep.Subscribers -> SelectionBanner( title = "Добавить подписчиков", subtitle = "Можно создать канал сейчас и пригласить людей позже.", selectedCount = state.selectedUserIds.size, @@ -162,7 +215,7 @@ fun DiscoverScreen( enabled = !state.isLoading ) - null -> { + else -> { if (state.selectionMode == null) { IkarSectionCard(padding = PaddingValues(0.dp)) { DiscoverActionRow( @@ -193,6 +246,15 @@ fun DiscoverScreen( } } + if (showPublicChannels) { + PublicChannelsSection( + channels = state.publicChannels, + query = state.query, + isLoading = state.isLoading, + onChannelClick = onChannelClick + ) + } + state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) } @@ -209,6 +271,146 @@ fun DiscoverScreen( } } +@Composable +private fun PublicChannelsSection( + channels: List, + query: String, + isLoading: Boolean, + onChannelClick: (ChatSummaryDto) -> Unit +) { + if (channels.isEmpty() && query.isBlank() && !isLoading) { + return + } + + IkarSectionCard(padding = PaddingValues(0.dp)) { + Text( + "Публичные каналы", + modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = TelegramBlue + ) + when { + isLoading && channels.isEmpty() -> { + Text( + "Загрузка...", + modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + + channels.isEmpty() -> { + Text( + "Публичные каналы не найдены", + modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + + else -> { + LazyColumn(modifier = Modifier.heightIn(max = 260.dp)) { + items(channels, key = { it.id }) { channel -> + PublicChannelRow(channel = channel, onClick = { onChannelClick(channel) }) + } + } + } + } + } +} + +@Composable +private fun PublicChannelRow( + channel: ChatSummaryDto, + onClick: () -> Unit +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(48.dp), + color = TelegramBluePanel, + shape = CircleShape + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Speaker, contentDescription = null, tint = TelegramBlue, modifier = Modifier.size(24.dp)) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(channel.title, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + channel.publicUsername?.let { "@$it" } ?: channel.secondaryText.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + channel.description?.takeIf { it.isNotBlank() }?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + TextButton(onClick = onClick) { + Text("Открыть", color = TelegramBlue) + } + } + IkarRowDivider(startIndent = 80.dp) + } +} + +@Composable +private fun GroupDetailsStep( + state: DiscoverUiState, + onTitleChanged: (String) -> Unit, + onCancelClick: () -> Unit, + onNextClick: () -> Unit +) { + IkarSectionCard { + WizardStepIndicator(currentStep = 1, totalSteps = 2) + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(64.dp), + color = TelegramBluePanel, + shape = CircleShape + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Group, contentDescription = null, tint = TelegramBlue, modifier = Modifier.size(30.dp)) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.groupTitle, + onValueChange = onTitleChanged, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Название группы") } + ) + } + } + StepActions( + backText = "Отмена", + nextText = "Далее", + onBackClick = onCancelClick, + onNextClick = onNextClick, + nextEnabled = state.groupTitle.isNotBlank() + ) + } +} + @Composable private fun ChannelDetailsStep( state: DiscoverUiState, @@ -401,13 +603,13 @@ private fun StepActions( } @Composable -private fun WizardStepIndicator(currentStep: Int) { +private fun WizardStepIndicator(currentStep: Int, totalSteps: Int = 3) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - (1..3).forEach { step -> + (1..totalSteps).forEach { step -> Surface( modifier = Modifier .height(4.dp) @@ -417,7 +619,7 @@ private fun WizardStepIndicator(currentStep: Int) { ) {} } Text( - "$currentStep/3", + "$currentStep/$totalSteps", style = MaterialTheme.typography.labelMedium, color = TelegramInkMuted, fontWeight = FontWeight.SemiBold diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverViewModel.kt index 84288810..112b69f5 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/discover/DiscoverViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.seven.ikar.kotlin.app.AppContainer import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem +import com.seven.ikar.kotlin.core.model.ChatSummaryDto import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -23,6 +24,11 @@ enum class ChannelWizardStep { Subscribers } +enum class GroupWizardStep { + Details, + Members +} + enum class ChannelVisibility { Public, Private @@ -31,11 +37,14 @@ enum class ChannelVisibility { data class DiscoverUiState( val query: String = "", val items: List = emptyList(), + val publicChannels: List = emptyList(), val isLoading: Boolean = false, val errorMessage: String? = null, val pendingChatId: String? = null, val selectionMode: DiscoverSelectionMode? = null, val selectedUserIds: Set = emptySet(), + val groupWizardStep: GroupWizardStep? = null, + val groupTitle: String = "", val channelWizardStep: ChannelWizardStep? = null, val channelTitle: String = "", val channelDescription: String = "", @@ -65,22 +74,44 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() { fun refresh() { viewModelScope.launch { - val query = _uiState.value.query + val state = _uiState.value + val query = state.query + val shouldLoadPublicChannels = + state.selectionMode == null && state.groupWizardStep == null && state.channelWizardStep == null _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) - runCatching { container.resolvedContactsRepository.loadMergedCandidates(query) } - .onSuccess { items -> - _uiState.value = _uiState.value.copy(items = items, isLoading = false, errorMessage = null) - } - .onFailure { error -> - _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) + val contactsResult = runCatching { container.resolvedContactsRepository.loadMergedCandidates(query) } + val channelsResult = if (shouldLoadPublicChannels) { + runCatching { + container.executeAuthorized { session -> + container.apiClient.getPublicChannels(session.accessToken, query.trim(), limit = 50) + } } + } else { + Result.success(emptyList()) + } + val items = contactsResult.getOrDefault(emptyList()) + val publicChannels = channelsResult.getOrDefault(emptyList()) + val errorMessage = when { + shouldLoadPublicChannels && channelsResult.isFailure -> channelsResult.exceptionOrNull()?.message + !shouldLoadPublicChannels && contactsResult.isFailure -> contactsResult.exceptionOrNull()?.message + contactsResult.isFailure && publicChannels.isEmpty() -> contactsResult.exceptionOrNull()?.message + else -> null + } + _uiState.value = _uiState.value.copy( + items = items, + publicChannels = publicChannels, + isLoading = false, + errorMessage = errorMessage + ) } } fun beginGroupSelection() { _uiState.value = _uiState.value.copy( - selectionMode = DiscoverSelectionMode.Group, + selectionMode = null, selectedUserIds = emptySet(), + groupWizardStep = GroupWizardStep.Details, + groupTitle = "", channelWizardStep = null, errorMessage = null ) @@ -90,6 +121,8 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() { _uiState.value = _uiState.value.copy( selectionMode = null, selectedUserIds = emptySet(), + groupWizardStep = null, + groupTitle = "", channelWizardStep = ChannelWizardStep.Details, channelTitle = "", channelDescription = "", @@ -104,11 +137,26 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() { _uiState.value = _uiState.value.copy( selectionMode = null, selectedUserIds = emptySet(), + groupWizardStep = null, + groupTitle = "", channelWizardStep = null, errorMessage = null ) } + fun previousGroupWizardStep() { + val state = _uiState.value + when (state.groupWizardStep) { + GroupWizardStep.Details, null -> cancelSelection() + GroupWizardStep.Members -> _uiState.value = state.copy( + groupWizardStep = GroupWizardStep.Details, + selectionMode = null, + selectedUserIds = emptySet(), + errorMessage = null + ) + } + } + fun previousChannelWizardStep() { val state = _uiState.value when (state.channelWizardStep) { @@ -123,6 +171,10 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() { } } + fun updateGroupTitle(value: String) { + _uiState.value = _uiState.value.copy(groupTitle = value.take(80), errorMessage = null) + } + fun updateChannelTitle(value: String) { _uiState.value = _uiState.value.copy(channelTitle = value.take(80), errorMessage = null) } @@ -173,6 +225,27 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() { } } + fun continueGroupWizard() { + val state = _uiState.value + when (state.groupWizardStep) { + GroupWizardStep.Details -> { + if (state.groupTitle.isBlank()) { + _uiState.value = state.copy(errorMessage = "Введите название группы.") + return + } + _uiState.value = state.copy( + groupWizardStep = GroupWizardStep.Members, + selectionMode = DiscoverSelectionMode.Group, + selectedUserIds = emptySet(), + errorMessage = null + ) + } + + GroupWizardStep.Members -> completeSelection() + null -> Unit + } + } + fun handleUserTap(user: DiscoverUserItem) { val selectionMode = _uiState.value.selectionMode if (selectionMode == null) { @@ -203,7 +276,7 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() { when (mode) { DiscoverSelectionMode.Group -> container.apiClient.createGroupChat( accessToken = session.accessToken, - title = "Новая группа", + title = state.groupTitle.trim(), memberIds = selectedIds ) @@ -224,6 +297,8 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() { isLoading = false, selectionMode = null, selectedUserIds = emptySet(), + groupWizardStep = null, + groupTitle = "", channelWizardStep = null, errorMessage = null ) @@ -247,6 +322,10 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() { } } + fun openPublicChannel(channel: ChatSummaryDto) { + _uiState.value = _uiState.value.copy(pendingChatId = channel.id) + } + fun consumePendingChatNavigation() { _uiState.value = _uiState.value.copy(pendingChatId = null) } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/forward/ForwardScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/forward/ForwardScreen.kt index 8b2c3117..976eee60 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/forward/ForwardScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/forward/ForwardScreen.kt @@ -4,9 +4,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -37,7 +42,11 @@ fun ForwardScreen( onUserClick: (DiscoverUserItem) -> Unit ) { IkarScreenSurface(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + ) { Column( modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/join/JoinInviteScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/join/JoinInviteScreen.kt new file mode 100644 index 00000000..91748b0c --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/join/JoinInviteScreen.kt @@ -0,0 +1,92 @@ +package com.seven.ikar.kotlin.feature.join + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.seven.ikar.kotlin.ui.IkarScreenSurface +import com.seven.ikar.kotlin.ui.IkarSimpleHeader +import com.seven.ikar.kotlin.ui.theme.TelegramBlue +import com.seven.ikar.kotlin.ui.theme.TelegramDanger +import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted + +@Composable +fun JoinInviteScreen( + state: JoinInviteUiState, + onBackClick: () -> Unit, + onRetryClick: () -> Unit +) { + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IkarSimpleHeader(title = "Приглашение", onBackClick = onBackClick) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + when { + state.isLoading -> { + CircularProgressIndicator(color = TelegramBlue) + Text( + text = "Открываем приглашение...", + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + } + + state.errorMessage != null -> { + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodyLarge, + color = TelegramDanger + ) + Button( + onClick = onRetryClick, + modifier = Modifier.padding(top = 16.dp) + ) { + Text("Повторить") + } + } + + state.message != null -> { + Text( + text = state.message, + style = MaterialTheme.typography.bodyLarge, + color = TelegramInkMuted + ) + OutlinedButton( + onClick = onBackClick, + modifier = Modifier.padding(top = 16.dp) + ) { + Text("К чатам") + } + } + } + } + } + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/join/JoinInviteViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/join/JoinInviteViewModel.kt new file mode 100644 index 00000000..f344a9a5 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/join/JoinInviteViewModel.kt @@ -0,0 +1,72 @@ +package com.seven.ikar.kotlin.feature.join + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.seven.ikar.kotlin.app.AppContainer +import com.seven.ikar.kotlin.core.model.JoinChatResultStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class JoinInviteUiState( + val isLoading: Boolean = false, + val message: String? = null, + val errorMessage: String? = null, + val pendingChatId: String? = null, + val isPendingApproval: Boolean = false +) + +class JoinInviteViewModel( + private val container: AppContainer, + private val inviteToken: String +) : ViewModel() { + private val _uiState = MutableStateFlow(JoinInviteUiState(isLoading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + join() + } + + fun join() { + if (inviteToken.isBlank()) { + _uiState.value = JoinInviteUiState(errorMessage = "Ссылка недействительна.") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, message = null) + runCatching { + container.executeAuthorized { session -> + container.apiClient.joinChatByInviteLink(session.accessToken, inviteToken) + } + }.onSuccess { result -> + when (result.status) { + JoinChatResultStatus.Joined -> { + val chatId = result.chat?.id + _uiState.value = if (chatId.isNullOrBlank()) { + JoinInviteUiState(errorMessage = "Чат недоступен после вступления.") + } else { + JoinInviteUiState(pendingChatId = chatId) + } + } + + JoinChatResultStatus.PendingApproval -> { + _uiState.value = JoinInviteUiState( + message = "Заявка на вступление отправлена. Дождитесь одобрения администратора.", + isPendingApproval = true + ) + } + } + }.onFailure { error -> + _uiState.value = JoinInviteUiState( + errorMessage = error.message ?: "Не удалось открыть пригласительную ссылку." + ) + } + } + } + + fun consumePendingChatNavigation() { + _uiState.value = _uiState.value.copy(pendingChatId = null) + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt index 430b60f8..26dceae1 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt @@ -1,7 +1,7 @@ package com.seven.ikar.kotlin.feature.login -import androidx.compose.foundation.background import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -28,17 +29,17 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.KeyboardOptions @Composable fun LoginScreen( state: LoginUiState, + onEmailChanged: (String) -> Unit, onPhoneNumberChanged: (String) -> Unit, onDisplayNameChanged: (String) -> Unit, onVerificationCodeChanged: (String) -> Unit, onRequestCodeClick: () -> Unit, onVerifyCodeClick: () -> Unit, - onChangePhoneNumberClick: () -> Unit + onChangeEmailClick: () -> Unit ) { Box( modifier = Modifier @@ -116,7 +117,16 @@ fun LoginScreen( fontWeight = FontWeight.SemiBold ) - if (state.showPhoneStep) { + if (state.showEmailStep) { + OutlinedTextField( + value = state.email, + onValueChange = onEmailChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text("Электронная почта") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true + ) + OutlinedTextField( value = state.phoneNumber, onValueChange = onPhoneNumberChanged, @@ -128,7 +138,7 @@ fun LoginScreen( Button( onClick = onRequestCodeClick, - enabled = !state.isLoading, + enabled = !state.isLoading && state.email.isNotBlank() && state.phoneNumber.isNotBlank(), contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp) ) { if (state.isLoading) { @@ -137,7 +147,7 @@ fun LoginScreen( modifier = Modifier.padding(horizontal = 8.dp) ) } else { - Text("Получить тестовый код") + Text("Получить код") } } } @@ -154,25 +164,16 @@ fun LoginScreen( verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - text = "Подтверждение номера", + text = "Подтверждение email", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) Text( - text = "Для тестового режима код показывается прямо в приложении. После подключения SMS-провайдера это сообщение исчезнет.", + text = "Код отправлен на указанную электронную почту. Номер телефона будет привязан к аккаунту.", style = MaterialTheme.typography.bodyMedium, color = Color(0xFF617A8F) ) - if (state.hasTestCodeHint) { - Text( - text = "Тестовый код: ${state.testCodeHint}", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF229AF0), - fontWeight = FontWeight.SemiBold - ) - } - OutlinedTextField( value = state.verificationCode, onValueChange = onVerificationCodeChanged, @@ -194,7 +195,9 @@ fun LoginScreen( Button( onClick = onVerifyCodeClick, - enabled = !state.isLoading, + enabled = !state.isLoading && + state.verificationCode.isNotBlank() && + (!state.isNewAccount || state.displayName.isNotBlank()), contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp) ) { if (state.isLoading) { @@ -208,7 +211,7 @@ fun LoginScreen( } Button( - onClick = onChangePhoneNumberClick, + onClick = onChangeEmailClick, enabled = !state.isLoading, colors = ButtonDefaults.buttonColors( containerColor = Color.Transparent, @@ -216,7 +219,7 @@ fun LoginScreen( ), contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp) ) { - Text("Изменить номер") + Text("Изменить email или телефон") } } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt index 8e105e6f..f3ba592d 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt @@ -10,11 +10,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch data class LoginUiState( + val email: String = "", val phoneNumber: String = "", val displayName: String = "", val verificationCode: String = "", val challengeId: String = "", - val testCodeHint: String = "", val isAwaitingCode: Boolean = false, val isNewAccount: Boolean = false, val isLoading: Boolean = false, @@ -22,16 +22,16 @@ data class LoginUiState( val isAuthenticated: Boolean = false ) { val pageTitle: String - get() = if (isAwaitingCode) "Подтвердите номер" else "Вход в ИКАР" + get() = if (isAwaitingCode) "Подтвердите email" else "Вход в ИКАР" val subtitle: String get() = if (isAwaitingCode) { - "Введите тестовый код. Когда подключим SMS-провайдера, здесь будет код из сообщения." + "Введите код, который отправлен на указанную электронную почту." } else { - "Введите номер телефона, чтобы получить тестовый код входа." + "Введите электронную почту и номер телефона. Код придёт только на email." } - val showPhoneStep: Boolean + val showEmailStep: Boolean get() = !isAwaitingCode val showCodeStep: Boolean @@ -39,15 +39,16 @@ data class LoginUiState( val showDisplayName: Boolean get() = isAwaitingCode && isNewAccount - - val hasTestCodeHint: Boolean - get() = testCodeHint.isNotBlank() } class LoginViewModel(private val container: AppContainer) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() + fun updateEmail(value: String) { + _uiState.value = _uiState.value.copy(email = value, errorMessage = null) + } + fun updatePhoneNumber(value: String) { _uiState.value = _uiState.value.copy(phoneNumber = value, errorMessage = null) } @@ -62,19 +63,26 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() { fun requestCode() { val state = _uiState.value - if (state.phoneNumber.isBlank() || state.isLoading) { + if (state.isLoading) { + return + } + + if (state.email.isBlank() || state.phoneNumber.isBlank()) { + _uiState.value = state.copy(errorMessage = "Введите email и номер телефона.") return } viewModelScope.launch(Dispatchers.IO) { _uiState.value = state.copy(isLoading = true, errorMessage = null) runCatching { - container.apiClient.requestPhoneCode(state.phoneNumber.trim()) + container.apiClient.requestEmailCode( + email = state.email.trim(), + phoneNumber = state.phoneNumber.trim() + ) }.onSuccess { challenge -> _uiState.value = _uiState.value.copy( isLoading = false, challengeId = challenge.challengeId, - testCodeHint = challenge.testCode, isAwaitingCode = true, isNewAccount = !challenge.isRegistered, verificationCode = "", @@ -83,7 +91,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() { }.onFailure { error -> _uiState.value = _uiState.value.copy( isLoading = false, - errorMessage = error.message ?: "Не удалось запросить код." + errorMessage = error.message ?: "Не удалось отправить код на email." ) } } @@ -93,6 +101,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() { val state = _uiState.value if (state.isLoading || state.challengeId.isBlank() || + state.email.isBlank() || state.phoneNumber.isBlank() || state.verificationCode.isBlank() || (state.isNewAccount && state.displayName.isBlank()) @@ -103,8 +112,9 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _uiState.value = state.copy(isLoading = true, errorMessage = null) runCatching { - container.apiClient.verifyPhoneCode( + container.apiClient.verifyEmailCode( challengeId = state.challengeId, + email = state.email.trim(), phoneNumber = state.phoneNumber.trim(), code = state.verificationCode.trim(), displayName = state.displayName.trim().ifBlank { null } @@ -116,16 +126,15 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() { }.onFailure { error -> _uiState.value = _uiState.value.copy( isLoading = false, - errorMessage = error.message ?: "Не удалось подтвердить код." + errorMessage = error.message ?: "Не удалось подтвердить email-код." ) } } } - fun changePhoneNumber() { + fun changeEmail() { _uiState.value = _uiState.value.copy( challengeId = "", - testCodeHint = "", verificationCode = "", isAwaitingCode = false, isNewAccount = false, diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/mainmenu/MainMenuViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/mainmenu/MainMenuViewModel.kt index 119f6b97..a1321cd5 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/mainmenu/MainMenuViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/mainmenu/MainMenuViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.seven.ikar.kotlin.app.AppContainer import com.seven.ikar.kotlin.core.model.ChatSummaryDto +import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -22,8 +23,10 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() { private var debounceJob: Job? = null private var refreshJob: Job? = null + private var latestChats: List = emptyList() init { + attachNotificationSettings() attachRealtime() loadCachedUnreadChatsCount() refresh() @@ -47,7 +50,8 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() { if (container.sessionStore.read()?.user?.id != ownerUserId) { return@onSuccess } - _uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats)) + latestChats = chats + updateUnreadChatsCount(chats) cacheChatList(ownerUserId, chats) }.onFailure { loadCachedUnreadChatsCount() @@ -58,6 +62,7 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() { fun clear() { debounceJob?.cancel() refreshJob?.cancel() + latestChats = emptyList() _uiState.value = MainMenuUiState() } @@ -69,7 +74,8 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() { container.chatListCache.loadChats(ownerUserId) }.getOrDefault(emptyList()) if (container.sessionStore.read()?.user?.id == ownerUserId) { - _uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats)) + latestChats = chats + updateUnreadChatsCount(chats) } } } @@ -95,6 +101,14 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() { } } + private fun attachNotificationSettings() { + viewModelScope.launch { + container.pushSettings.snapshot.collect { + updateUnreadChatsCount(latestChats) + } + } + } + private fun scheduleRefresh() { debounceJob?.cancel() debounceJob = viewModelScope.launch { @@ -103,6 +117,31 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() { } } + private fun updateUnreadChatsCount(chats: List) { + _uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats)) + } + private fun countUnreadChats(chats: List): Int = - chats.count { chat -> chat.unreadCount > 0 } + countUnreadChats(chats, container.pushSettings.snapshot.value) + + private fun countUnreadChats( + chats: List, + settings: NotificationSettingsSnapshot + ): Int { + if (!settings.showBadge) { + return 0 + } + + val eligibleChats = if (settings.includeMutedChatsInBadge) { + chats + } else { + chats.filterNot { it.isMuted } + } + + return if (settings.countMessagesInBadge) { + eligibleChats.sumOf { it.unreadCount.coerceAtLeast(0) } + } else { + eligibleChats.count { it.unreadCount > 0 } + } + } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersScreen.kt index 8b310897..023b2acd 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersScreen.kt @@ -2,6 +2,8 @@ package com.seven.ikar.kotlin.feature.members import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -52,6 +54,8 @@ fun ChatMembersScreen( onToggleCanPin: (ChatMemberDto) -> Unit, onToggleCanDelete: (ChatMemberDto) -> Unit, onToggleCanInvite: (ChatMemberDto) -> Unit, + onTransferOwnership: (ChatMemberDto) -> Unit, + onRemoveMember: (ChatMemberDto) -> Unit, onMemberSearchChanged: (String) -> Unit, onAddMember: (UserSummaryDto) -> Unit, onAddAdmin: (UserSummaryDto) -> Unit, @@ -153,12 +157,19 @@ fun ChatMembersScreen( MemberPermissionRow( member = member, canManage = state.canManageMembers && !member.isOwner, + canTransferOwnership = state.canTransferOwnership && + !member.isOwner && + member.role != ChatMemberRole.Owner && + !member.user.isBot && + member.user.id != state.currentUserId, isUpdating = state.updatingUserIds.contains(member.user.id), onToggleAdmin = { onToggleAdmin(member) }, onToggleCanPost = { onToggleCanPost(member) }, onToggleCanPin = { onToggleCanPin(member) }, onToggleCanDelete = { onToggleCanDelete(member) }, - onToggleCanInvite = { onToggleCanInvite(member) } + onToggleCanInvite = { onToggleCanInvite(member) }, + onTransferOwnership = { onTransferOwnership(member) }, + onRemoveMember = { onRemoveMember(member) } ) if (index != members.lastIndex) { IkarRowDivider(startIndent = 58.dp) @@ -298,15 +309,19 @@ private fun JoinRequestRow( } @Composable +@OptIn(ExperimentalLayoutApi::class) private fun MemberPermissionRow( member: ChatMemberDto, canManage: Boolean, + canTransferOwnership: Boolean, isUpdating: Boolean, onToggleAdmin: () -> Unit, onToggleCanPost: () -> Unit, onToggleCanPin: () -> Unit, onToggleCanDelete: () -> Unit, - onToggleCanInvite: () -> Unit + onToggleCanInvite: () -> Unit, + onTransferOwnership: () -> Unit, + onRemoveMember: () -> Unit ) { Column( modifier = Modifier @@ -345,7 +360,18 @@ private fun MemberPermissionRow( } if (canManage) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (canTransferOwnership) { + TextButton(enabled = !isUpdating, onClick = onTransferOwnership) { + Text("Передать владельца", color = TelegramBlue) + } + } + TextButton(enabled = !isUpdating, onClick = onRemoveMember) { + Text("Удалить", color = TelegramDanger) + } PermissionChip("публикации", member.permissions.canPostMessages, isUpdating, onToggleCanPost) PermissionChip("закрепление", member.permissions.canPinMessages, isUpdating, onToggleCanPin) PermissionChip("удаление", member.permissions.canDeleteMessages, isUpdating, onToggleCanDelete) @@ -436,6 +462,7 @@ private fun moderationActionLabel(action: String): String = when (action) { "member.added" -> "Участник добавлен" "member.admin.added" -> "Администратор добавлен" "member.permissions.updated" -> "Права участника обновлены" + "member.owner.transferred" -> "Владелец передан" "message.deleted" -> "Сообщение удалено" "message.pinned" -> "Сообщение закреплено" "message.unpinned" -> "Сообщение откреплено" diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersViewModel.kt index 2374cbcb..ba2bdcd7 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/members/ChatMembersViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.launch data class ChatMembersUiState( val chat: ChatDetailsDto? = null, + val currentUserId: String? = null, val joinRequests: List = emptyList(), val moderationLog: List = emptyList(), val isLoading: Boolean = false, @@ -36,8 +37,26 @@ data class ChatMembersUiState( val canManageMembers: Boolean get() = chat?.currentUserPermissions?.canManageMembers == true + val isCurrentUserOwner: Boolean + get() = currentUserId != null && + chat?.members.orEmpty().any { member -> + member.user.id == currentUserId && + (member.isOwner || member.role == ChatMemberRole.Owner) + } + + val canTransferOwnership: Boolean + get() = isCurrentUserOwner && when (chat?.type) { + ChatType.Group, + ChatType.Channel -> true + else -> false + } + val canAddMembers: Boolean - get() = canManageMembers && chat?.type == ChatType.Channel + get() = canManageMembers && when (chat?.type) { + ChatType.Group, + ChatType.Channel -> true + else -> false + } } class ChatMembersViewModel( @@ -61,6 +80,7 @@ class ChatMembersViewModel( val canManageMembers = chat.currentUserPermissions?.canManageMembers == true ChatMembersPayload( chat = chat, + currentUserId = session.user.id, joinRequests = if (canManageMembers) { container.apiClient.getChatJoinRequests(session.accessToken, chatId) } else { @@ -76,6 +96,7 @@ class ChatMembersViewModel( }.onSuccess { payload -> _uiState.value = _uiState.value.copy( chat = payload.chat, + currentUserId = payload.currentUserId, joinRequests = payload.joinRequests, moderationLog = payload.moderationLog, memberSearchResults = filterMemberSearchResults(_uiState.value.memberSearchResults, payload.chat), @@ -182,6 +203,79 @@ class ChatMembersViewModel( updateMember(member, canInviteUsers = !member.permissions.canInviteUsers) } + fun removeMember(member: ChatMemberDto) { + if (member.isOwner || _uiState.value.updatingUserIds.contains(member.user.id)) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + updatingUserIds = _uiState.value.updatingUserIds + member.user.id, + errorMessage = null + ) + runCatching { + container.executeAuthorized { session -> + container.apiClient.removeChatMember( + accessToken = session.accessToken, + chatId = chatId, + memberUserId = member.user.id + ) + } + }.onSuccess { chat -> + _uiState.value = _uiState.value.copy( + chat = chat, + updatingUserIds = _uiState.value.updatingUserIds - member.user.id + ) + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + updatingUserIds = _uiState.value.updatingUserIds - member.user.id, + errorMessage = error.message + ) + } + } + } + + fun transferOwnership(member: ChatMemberDto) { + val state = _uiState.value + if (!state.canTransferOwnership || + member.isOwner || + member.role == ChatMemberRole.Owner || + member.user.isBot || + state.currentUserId == member.user.id || + state.updatingUserIds.contains(member.user.id) + ) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + updatingUserIds = _uiState.value.updatingUserIds + member.user.id, + errorMessage = null + ) + runCatching { + container.executeAuthorized { session -> + container.apiClient.transferChatOwnership( + accessToken = session.accessToken, + chatId = chatId, + memberUserId = member.user.id + ) + } + }.onSuccess { chat -> + _uiState.value = _uiState.value.copy( + chat = chat, + updatingUserIds = _uiState.value.updatingUserIds - member.user.id + ) + refresh() + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + updatingUserIds = _uiState.value.updatingUserIds - member.user.id, + errorMessage = error.message + ) + } + } + } + private fun updateMember( member: ChatMemberDto, role: ChatMemberRole = member.role, @@ -289,6 +383,7 @@ class ChatMembersViewModel( private data class ChatMembersPayload( val chat: ChatDetailsDto, + val currentUserId: String, val joinRequests: List, val moderationLog: List ) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityScreen.kt index 382498df..92600ac0 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityScreen.kt @@ -2,23 +2,47 @@ package com.seven.ikar.kotlin.feature.privacy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Phone +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Security +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material3.Icon +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.unit.dp import com.seven.ikar.kotlin.core.model.PrivacySettingsDto +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import com.seven.ikar.kotlin.ui.AvatarView import com.seven.ikar.kotlin.ui.IkarRowDivider import com.seven.ikar.kotlin.ui.IkarScreenSurface import com.seven.ikar.kotlin.ui.IkarSectionCard @@ -33,11 +57,19 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted fun PrivacySecurityScreen( state: PrivacySecurityUiState, onBackClick: () -> Unit, - onRefreshClick: () -> Unit, - onBlockedUsersClick: () -> Unit, - onExceptionsClick: () -> Unit, - onUpdate: ((PrivacySettingsDto) -> PrivacySettingsDto) -> Unit + onUpdate: ((PrivacySettingsDto) -> PrivacySettingsDto) -> Unit, + onUserSearchQueryChanged: (String) -> Unit, + onBlockUser: (UserSummaryDto) -> Unit, + onUnblockUser: (String) -> Unit, + onAlwaysAllowUser: (UserSummaryDto) -> Unit, + onNeverAllowUser: (UserSummaryDto) -> Unit, + onRemoveAlwaysAllow: (String) -> Unit, + onRemoveNeverAllow: (String) -> Unit, + onEnablePasscode: (String, String) -> Unit, + onDisablePasscode: (String) -> Unit ) { + var passcodeDialogMode by remember { mutableStateOf(null) } + var listDialogMode by remember { mutableStateOf(null) } IkarScreenSurface(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier @@ -58,41 +90,351 @@ fun PrivacySecurityScreen( if (settings != null) { IkarSectionCard { IkarSectionTitle("Конфиденциальность", modifier = Modifier.padding(bottom = 8.dp)) - PrivacyRow("Номер телефона", privacyValueLabel(settings.phoneNumberVisibility)) { + PrivacyRow("Номер телефона", privacyValueLabel(settings.phoneNumberVisibility), Icons.Outlined.Phone) { onUpdate { it.copy(phoneNumberVisibility = nextVisibility(it.phoneNumberVisibility)) } } IkarRowDivider() - PrivacyRow("Последняя активность и онлайн", privacyValueLabel(settings.lastSeenVisibility)) { + PrivacyRow("Последняя активность и онлайн", privacyValueLabel(settings.lastSeenVisibility), Icons.Outlined.Schedule) { onUpdate { it.copy(lastSeenVisibility = nextVisibility(it.lastSeenVisibility)) } } IkarRowDivider() - PrivacyRow("Фотографии профиля", privacyValueLabel(settings.profilePhotoVisibility)) { + PrivacyRow("Фотографии профиля", privacyValueLabel(settings.profilePhotoVisibility), Icons.Outlined.Image) { onUpdate { it.copy(profilePhotoVisibility = nextVisibility(it.profilePhotoVisibility)) } } IkarRowDivider() - PrivacyRow("Заблокированные пользователи", "${settings.blockedUserIds.size}") { - onBlockedUsersClick() + PrivacyRow("Заблокированные пользователи", "${settings.blockedUserIds.size}", Icons.Outlined.Block) { + listDialogMode = PrivacyListDialogMode.Blocked } IkarRowDivider() - PrivacyRow("Исключения", "${settings.alwaysAllowUserIds.size + settings.neverAllowUserIds.size}") { - onExceptionsClick() + PrivacyRow("Исключения", "${settings.alwaysAllowUserIds.size + settings.neverAllowUserIds.size}", Icons.Outlined.Security) { + listDialogMode = PrivacyListDialogMode.Exceptions } } IkarSectionCard { IkarSectionTitle("Безопасность", modifier = Modifier.padding(bottom = 8.dp)) - PrivacyRow("Код-пароль", if (settings.passcodeLockEnabled) "включено" else "выключено") { - onUpdate { it.copy(passcodeLockEnabled = !it.passcodeLockEnabled) } + PrivacyRow("Код-пароль", if (settings.passcodeLockEnabled) "включено" else "выключено", Icons.Outlined.Lock) { + passcodeDialogMode = if (settings.passcodeLockEnabled) { + PasscodeDialogMode.Disable + } else { + PasscodeDialogMode.Enable + } } IkarRowDivider() - PrivacyRow("Предпросмотр сообщений", if (settings.showMessagePreview) "включено" else "выключено") { + PrivacyRow("Предпросмотр сообщений", if (settings.showMessagePreview) "включено" else "выключено", Icons.Outlined.Visibility) { onUpdate { it.copy(showMessagePreview = !it.showMessagePreview) } } } } - TextButton(enabled = !state.isSaving, onClick = onRefreshClick) { - Text(if (state.isSaving) "Сохранение..." else "Обновить") + } + } + + passcodeDialogMode?.let { mode -> + PasscodeDialog( + mode = mode, + isSaving = state.isSaving, + onDismiss = { passcodeDialogMode = null }, + onEnable = { passcode, confirmation -> + onEnablePasscode(passcode, confirmation) + passcodeDialogMode = null + }, + onDisable = { passcode -> + onDisablePasscode(passcode) + passcodeDialogMode = null } + ) + } + + listDialogMode?.let { mode -> + PrivacyUsersDialog( + mode = mode, + state = state, + onDismiss = { listDialogMode = null }, + onUserSearchQueryChanged = onUserSearchQueryChanged, + onBlockUser = onBlockUser, + onUnblockUser = onUnblockUser, + onAlwaysAllowUser = onAlwaysAllowUser, + onNeverAllowUser = onNeverAllowUser, + onRemoveAlwaysAllow = onRemoveAlwaysAllow, + onRemoveNeverAllow = onRemoveNeverAllow + ) + } +} + +private enum class PasscodeDialogMode { + Enable, + Disable +} + +private enum class PrivacyListDialogMode { + Blocked, + Exceptions +} + +@Composable +private fun PasscodeDialog( + mode: PasscodeDialogMode, + isSaving: Boolean, + onDismiss: () -> Unit, + onEnable: (String, String) -> Unit, + onDisable: (String) -> Unit +) { + var passcode by remember(mode) { mutableStateOf("") } + var confirmation by remember(mode) { mutableStateOf("") } + val isEnable = mode == PasscodeDialogMode.Enable + val canConfirm = if (isEnable) { + passcode.length >= 4 && confirmation.length >= 4 + } else { + passcode.length >= 4 + } + + AlertDialog( + onDismissRequest = { + if (!isSaving) { + onDismiss() + } + }, + title = { Text(if (isEnable) "Включить код-пароль" else "Отключить код-пароль") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = passcode, + onValueChange = { passcode = it.filter(Char::isDigit).take(12) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + label = { Text(if (isEnable) "Новый код" else "Текущий код") } + ) + if (isEnable) { + OutlinedTextField( + value = confirmation, + onValueChange = { confirmation = it.filter(Char::isDigit).take(12) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + label = { Text("Повторите код") } + ) + } + Text( + text = "Используйте 4-12 цифр.", + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted + ) + } + }, + confirmButton = { + TextButton( + enabled = !isSaving && canConfirm, + onClick = { + if (isEnable) { + onEnable(passcode, confirmation) + } else { + onDisable(passcode) + } + } + ) { + Text(if (isEnable) "Включить" else "Отключить") + } + }, + dismissButton = { + TextButton(enabled = !isSaving, onClick = onDismiss) { + Text("Отмена") + } + } + ) +} + +@Composable +private fun PrivacyUsersDialog( + mode: PrivacyListDialogMode, + state: PrivacySecurityUiState, + onDismiss: () -> Unit, + onUserSearchQueryChanged: (String) -> Unit, + onBlockUser: (UserSummaryDto) -> Unit, + onUnblockUser: (String) -> Unit, + onAlwaysAllowUser: (UserSummaryDto) -> Unit, + onNeverAllowUser: (UserSummaryDto) -> Unit, + onRemoveAlwaysAllow: (String) -> Unit, + onRemoveNeverAllow: (String) -> Unit +) { + val settings = state.settings ?: return + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + when (mode) { + PrivacyListDialogMode.Blocked -> "Заблокированные пользователи" + PrivacyListDialogMode.Exceptions -> "Исключения" + } + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.userSearchQuery, + onValueChange = onUserSearchQueryChanged, + singleLine = true, + label = { Text("Поиск пользователя") } + ) + if (state.isSearchingUsers) { + CircularProgressIndicator(color = TelegramBlue) + } + state.userSearchResults.take(8).forEach { user -> + when (mode) { + PrivacyListDialogMode.Blocked -> PrivacyUserResultRow( + user = user, + primaryActionLabel = if (settings.blockedUserIds.contains(user.id)) "Уже заблокирован" else "Заблокировать", + primaryEnabled = !settings.blockedUserIds.contains(user.id) && !state.isSaving, + onPrimaryClick = { onBlockUser(user) } + ) + + PrivacyListDialogMode.Exceptions -> PrivacyUserResultRow( + user = user, + primaryActionLabel = "Всегда", + primaryEnabled = !settings.alwaysAllowUserIds.contains(user.id) && !state.isSaving, + onPrimaryClick = { onAlwaysAllowUser(user) }, + secondaryActionLabel = "Никогда", + secondaryEnabled = !settings.neverAllowUserIds.contains(user.id) && !state.isSaving, + onSecondaryClick = { onNeverAllowUser(user) } + ) + } + } + when (mode) { + PrivacyListDialogMode.Blocked -> PrivacyIdList( + title = "Сейчас заблокированы", + userIds = settings.blockedUserIds, + users = settings.blockedUsers, + removeLabel = "Разблокировать", + isSaving = state.isSaving, + onRemove = onUnblockUser + ) + + PrivacyListDialogMode.Exceptions -> { + PrivacyIdList( + title = "Всегда разрешать", + userIds = settings.alwaysAllowUserIds, + users = settings.alwaysAllowUsers, + removeLabel = "Убрать", + isSaving = state.isSaving, + onRemove = onRemoveAlwaysAllow + ) + PrivacyIdList( + title = "Никогда не разрешать", + userIds = settings.neverAllowUserIds, + users = settings.neverAllowUsers, + removeLabel = "Убрать", + isSaving = state.isSaving, + onRemove = onRemoveNeverAllow + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Закрыть") + } + } + ) +} + +@Composable +private fun PrivacyUserResultRow( + user: UserSummaryDto, + primaryActionLabel: String, + primaryEnabled: Boolean, + onPrimaryClick: () -> Unit, + secondaryActionLabel: String? = null, + secondaryEnabled: Boolean = false, + onSecondaryClick: (() -> Unit)? = null +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text(user.displayName, style = MaterialTheme.typography.bodyMedium) + Text("@${user.username}", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } + TextButton(enabled = primaryEnabled, onClick = onPrimaryClick) { + Text(primaryActionLabel) + } + if (secondaryActionLabel != null && onSecondaryClick != null) { + TextButton(enabled = secondaryEnabled, onClick = onSecondaryClick) { + Text(secondaryActionLabel) + } + } + } +} + +@Composable +private fun PrivacyIdList( + title: String, + userIds: List, + users: List, + removeLabel: String, + isSaving: Boolean, + onRemove: (String) -> Unit +) { + val usersById = remember(users) { users.associateBy { it.id } } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.labelLarge, color = TelegramBlue) + if (userIds.isEmpty()) { + Text("Список пуст.", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + } else { + userIds.take(20).forEach { userId -> + PrivacySavedUserRow( + userId = userId, + user = usersById[userId], + removeLabel = removeLabel, + isSaving = isSaving, + onRemove = onRemove + ) + } + } + } +} + +@Composable +private fun PrivacySavedUserRow( + userId: String, + user: UserSummaryDto?, + removeLabel: String, + isSaving: Boolean, + onRemove: (String) -> Unit +) { + val title = user?.displayName?.takeIf { it.isNotBlank() } + ?: user?.username?.takeIf { it.isNotBlank() }?.let { "@$it" } + ?: userId.take(8) + val subtitle = user?.username?.takeIf { it.isNotBlank() }?.let { "@$it" } + ?: user?.phoneNumber?.takeIf { !it.isNullOrBlank() } + ?: userId + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AvatarView( + imageUrl = user?.avatarPath, + title = title, + size = 36.dp + ) + Column(modifier = Modifier.weight(1f)) { + Text( + title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton(enabled = !isSaving, onClick = { onRemove(userId) }) { + Text(removeLabel) } } } @@ -101,14 +443,15 @@ fun PrivacySecurityScreen( private fun PrivacyRow( title: String, value: String, - onClick: () -> Unit + icon: ImageVector, + onClick: (() -> Unit)? = null ) { IkarSettingRow( title = title, subtitle = "Сейчас: $value", onClick = onClick, leading = { - Text(value.take(1).uppercase(), color = TelegramInkMuted) + Icon(icon, contentDescription = null, tint = TelegramInkMuted) } ) } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityViewModel.kt index 85b88bb9..d8c334cc 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/privacy/PrivacySecurityViewModel.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope import com.seven.ikar.kotlin.app.AppContainer import com.seven.ikar.kotlin.core.model.PrivacySettingsDto import com.seven.ikar.kotlin.core.model.UpdatePrivacySettingsRequest +import com.seven.ikar.kotlin.core.model.UserSummaryDto +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -12,6 +15,9 @@ import kotlinx.coroutines.launch data class PrivacySecurityUiState( val settings: PrivacySettingsDto? = null, + val userSearchQuery: String = "", + val userSearchResults: List = emptyList(), + val isSearchingUsers: Boolean = false, val isLoading: Boolean = false, val isSaving: Boolean = false, val errorMessage: String? = null @@ -19,6 +25,7 @@ data class PrivacySecurityUiState( class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel() { private val _uiState = MutableStateFlow(PrivacySecurityUiState()) + private var userSearchJob: Job? = null val uiState: StateFlow = _uiState.asStateFlow() init { @@ -44,6 +51,99 @@ class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel( fun update(transform: (PrivacySettingsDto) -> PrivacySettingsDto) { val current = _uiState.value.settings ?: return val next = transform(current) + saveSettings(current, next) + } + + fun updateUserSearchQuery(value: String) { + _uiState.value = _uiState.value.copy(userSearchQuery = value) + userSearchJob?.cancel() + userSearchJob = viewModelScope.launch { + delay(250) + searchUsers(value) + } + } + + fun blockUser(user: UserSummaryDto) { + update { settings -> + settings.copy( + blockedUserIds = (settings.blockedUserIds + user.id).distinct(), + alwaysAllowUserIds = settings.alwaysAllowUserIds - user.id + ) + } + } + + fun unblockUser(userId: String) { + update { settings -> settings.copy(blockedUserIds = settings.blockedUserIds - userId) } + } + + fun alwaysAllowUser(user: UserSummaryDto) { + update { settings -> + settings.copy( + alwaysAllowUserIds = (settings.alwaysAllowUserIds + user.id).distinct(), + neverAllowUserIds = settings.neverAllowUserIds - user.id, + blockedUserIds = settings.blockedUserIds - user.id + ) + } + } + + fun neverAllowUser(user: UserSummaryDto) { + update { settings -> + settings.copy( + neverAllowUserIds = (settings.neverAllowUserIds + user.id).distinct(), + alwaysAllowUserIds = settings.alwaysAllowUserIds - user.id + ) + } + } + + fun removeAlwaysAllow(userId: String) { + update { settings -> settings.copy(alwaysAllowUserIds = settings.alwaysAllowUserIds - userId) } + } + + fun removeNeverAllow(userId: String) { + update { settings -> settings.copy(neverAllowUserIds = settings.neverAllowUserIds - userId) } + } + + fun enablePasscode(passcode: String, confirmation: String) { + val current = _uiState.value.settings ?: return + val normalized = passcode.trim() + if (!isValidPasscode(normalized)) { + _uiState.value = _uiState.value.copy(errorMessage = "Код должен содержать 4-12 цифр.") + return + } + if (normalized != confirmation.trim()) { + _uiState.value = _uiState.value.copy(errorMessage = "Коды не совпадают.") + return + } + + saveSettings( + current = current, + next = current.copy(passcodeLockEnabled = true), + afterSuccess = { container.privacyRuntimeSettings.setPasscode(normalized) } + ) + } + + fun disablePasscode(passcode: String) { + val current = _uiState.value.settings ?: return + val normalized = passcode.trim() + if (container.privacyRuntimeSettings.hasPasscode() && + !container.privacyRuntimeSettings.verifyPasscode(normalized) + ) { + _uiState.value = _uiState.value.copy(errorMessage = "Неверный код.") + return + } + + saveSettings( + current = current, + next = current.copy(passcodeLockEnabled = false), + afterSuccess = { container.privacyRuntimeSettings.clearPasscode() } + ) + } + + private fun saveSettings( + current: PrivacySettingsDto, + next: PrivacySettingsDto, + afterSuccess: () -> Unit = {} + ) { viewModelScope.launch { _uiState.value = _uiState.value.copy(settings = next, isSaving = true, errorMessage = null) runCatching { @@ -63,6 +163,7 @@ class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel( ) } }.onSuccess { saved -> + afterSuccess() container.privacyRuntimeSettings.saveFrom(saved) _uiState.value = _uiState.value.copy(settings = saved, isSaving = false) }.onFailure { error -> @@ -70,6 +171,28 @@ class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel( } } } + + private fun isValidPasscode(value: String): Boolean = + value.length in 4..12 && value.all { it.isDigit() } + + private suspend fun searchUsers(query: String) { + val normalizedQuery = query.trim() + if (normalizedQuery.length < 2) { + _uiState.value = _uiState.value.copy(userSearchResults = emptyList(), isSearchingUsers = false) + return + } + + _uiState.value = _uiState.value.copy(isSearchingUsers = true) + runCatching { + container.executeAuthorized { session -> + container.apiClient.searchUsers(session.accessToken, normalizedQuery) + } + }.onSuccess { users -> + _uiState.value = _uiState.value.copy(userSearchResults = users, isSearchingUsers = false) + }.onFailure { error -> + _uiState.value = _uiState.value.copy(isSearchingUsers = false, errorMessage = error.message) + } + } } internal fun nextVisibility(value: String): String = when (value) { diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/AccountSettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/AccountSettingsScreen.kt index f001fc81..c23ec0d0 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/AccountSettingsScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/AccountSettingsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.seven.ikar.kotlin.ui.AvatarView import com.seven.ikar.kotlin.ui.IkarSectionCard @@ -50,7 +51,6 @@ fun AccountSettingsScreen( onBackClick: () -> Unit, onDisplayNameChanged: (String) -> Unit, onAboutChanged: (String) -> Unit, - onSaveClick: () -> Unit, onDeleteAvatarClick: () -> Unit, onAvatarSelected: (Uri) -> Unit ) { @@ -103,7 +103,9 @@ fun AccountSettingsScreen( Text( text = "Нажмите на надпись, чтобы удалить текущее фото", color = TelegramInkMuted, - style = MaterialTheme.typography.bodySmall + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } @@ -175,27 +177,29 @@ fun AccountSettingsScreen( } } Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text("Добавить личный канал", style = MaterialTheme.typography.titleLarge) Text( - "Этот сценарий будет доведён отдельным экраном. Сейчас основной профиль уже перенесён.", + "Добавить личный канал", + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + "Отдельный экран будет добавлен позже.", style = MaterialTheme.typography.bodySmall, - color = TelegramInkMuted + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } } - Button( - onClick = onSaveClick, - enabled = !state.isLoading && !state.isSaving, - modifier = Modifier.fillMaxWidth() - ) { - Text(if (state.isSaving) "Сохранение..." else "Сохранить") - } - state.errorMessage?.let { Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium) } + if (state.isSaving) { + Text("Сохраняется...", color = TelegramInkMuted, style = MaterialTheme.typography.bodyMedium) + } state.infoMessage?.let { Text(it, color = TelegramBlue, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) } @@ -220,8 +224,19 @@ private fun IkarInfoLine( } } Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(title, style = MaterialTheme.typography.titleLarge) - Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + Text( + title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = TelegramInkMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/ProfileViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/ProfileViewModel.kt index 730f5fda..ef3d8593 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/ProfileViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/profile/ProfileViewModel.kt @@ -11,6 +11,8 @@ import com.seven.ikar.kotlin.core.network.UploadFileSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch data class ProfileUiState( @@ -28,17 +30,20 @@ data class ProfileUiState( class ProfileViewModel(private val container: AppContainer) : ViewModel() { private val _uiState = MutableStateFlow(ProfileUiState(session = container.sessionStore.read())) val uiState: StateFlow = _uiState.asStateFlow() + private var autosaveJob: Job? = null init { refresh() } fun updateDisplayName(value: String) { - _uiState.value = _uiState.value.copy(displayName = value) + _uiState.value = _uiState.value.copy(displayName = value, errorMessage = null, infoMessage = null) + scheduleAutosave() } fun updateAbout(value: String) { - _uiState.value = _uiState.value.copy(about = value) + _uiState.value = _uiState.value.copy(about = value.take(200), errorMessage = null, infoMessage = null) + scheduleAutosave() } fun refresh() { @@ -57,25 +62,35 @@ class ProfileViewModel(private val container: AppContainer) : ViewModel() { } } - fun save() { + private fun scheduleAutosave() { + autosaveJob?.cancel() + autosaveJob = viewModelScope.launch { + delay(ProfileAutosaveDelayMillis) + saveProfileFields() + } + } + + private fun saveProfileFields() { val state = _uiState.value if (state.displayName.isBlank()) { _uiState.value = state.copy(errorMessage = "Укажите отображаемое имя.") return } + val submittedDisplayName = state.displayName.trim() + val submittedAbout = state.about.trim() viewModelScope.launch { _uiState.value = state.copy(isSaving = true, errorMessage = null, infoMessage = null) runCatching { container.executeAuthorized { session -> session to container.apiClient.updateMyProfile( accessToken = session.accessToken, - displayName = state.displayName.trim(), - about = state.about.trim().ifBlank { null } + displayName = submittedDisplayName, + about = submittedAbout.ifBlank { null } ) } }.onSuccess { (session, user) -> - applyUser(session, user, infoMessage = "Профиль сохранён.") + applySavedProfile(session, user, submittedDisplayName, submittedAbout) }.onFailure { error -> _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) } @@ -143,4 +158,34 @@ class ProfileViewModel(private val container: AppContainer) : ViewModel() { infoMessage = infoMessage ) } + + private fun applySavedProfile( + session: AuthSessionDto, + user: UserSummaryDto, + submittedDisplayName: String, + submittedAbout: String + ) { + val updatedSession = session.copy(user = user) + container.sessionStore.save(updatedSession) + val current = _uiState.value + val changedAgain = current.displayName.trim() != submittedDisplayName || + current.about.trim() != submittedAbout + + _uiState.value = current.copy( + session = updatedSession, + avatarUrl = ServerConfig.normalizeMediaUrl(user.avatarPath), + isLoading = false, + isSaving = false, + errorMessage = null, + infoMessage = if (changedAgain) null else "Профиль обновлён." + ) + + if (changedAgain) { + scheduleAutosave() + } + } + + companion object { + private const val ProfileAutosaveDelayMillis = 600L + } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchScreen.kt index be2ecfb6..e124d261 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchScreen.kt @@ -3,7 +3,8 @@ package com.seven.ikar.kotlin.feature.search import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize @@ -180,6 +181,7 @@ fun GlobalSearchScreen( } @Composable +@OptIn(ExperimentalLayoutApi::class) private fun GlobalSearchAdvancedFilters( selectedDate: GlobalSearchDateFilter, selectedType: GlobalSearchResultType, @@ -189,7 +191,10 @@ private fun GlobalSearchAdvancedFilters( onAuthorFilterChanged: (String) -> Unit ) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { GlobalSearchDateFilter.entries.forEach { filter -> TextButton(onClick = { onDateFilterChanged(filter) }) { Text( @@ -200,7 +205,10 @@ private fun GlobalSearchAdvancedFilters( } } } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { GlobalSearchResultType.entries.forEach { type -> TextButton(onClick = { onResultTypeChanged(type) }) { Text( @@ -220,11 +228,15 @@ private fun GlobalSearchAdvancedFilters( } @Composable +@OptIn(ExperimentalLayoutApi::class) private fun GlobalSearchFilterRow( selected: GlobalSearchFilter, onFilterChanged: (GlobalSearchFilter) -> Unit ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { GlobalSearchFilter.entries.forEach { filter -> TextButton(onClick = { onFilterChanged(filter) }) { Text( @@ -261,6 +273,19 @@ private fun GlobalSearchResultRow( maxLines = 1, overflow = TextOverflow.Ellipsis ) + if (result.chat.type == ChatType.Channel) { + Text( + text = listOfNotNull( + result.chat.publicUsername?.let { "@$it" }, + "Публичный канал", + formatChannelSubscribers(result.chat.subscriberCount) + ).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = TelegramBlue, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } Text( text = highlightedText(result.matchText.ifBlank { chatPreview(result.chat) }, query), style = MaterialTheme.typography.bodyMedium, @@ -276,18 +301,6 @@ private fun GlobalSearchResultRow( overflow = TextOverflow.Ellipsis ) } - result.botMiniAppDeepLink()?.let { deepLink -> - TextButton(onClick = {}) { - Text("Мини-приложение: $deepLink", color = TelegramBlue) - } - } - if (result.chat.type == ChatType.Group) { - Text( - text = "Групповой звонок готов для голосовых и видеодействий.", - style = MaterialTheme.typography.bodySmall, - color = TelegramInkMuted - ) - } } } @@ -332,13 +345,16 @@ private fun GlobalSearchResultType.label(): String = when (this) { GlobalSearchResultType.All -> "Все типы" GlobalSearchResultType.Chats -> "Чаты" + GlobalSearchResultType.Channels -> "Каналы" + GlobalSearchResultType.Groups -> "Группы" GlobalSearchResultType.Messages -> "Сообщения" GlobalSearchResultType.Attachments -> "Вложения" GlobalSearchResultType.Bots -> "Боты" - GlobalSearchResultType.GroupCalls -> "Звонки" } -private fun ChatSearchResultDto.botMiniAppDeepLink(): String? { - val bot = chat.participants.firstOrNull { it.isBot } ?: return null - return "ikar://bot/${bot.username}?start=miniapp" -} +private fun formatChannelSubscribers(count: Int): String = + when { + count == 1 -> "1 подписчик" + count in 2..4 -> "$count подписчика" + else -> "$count подписчиков" + } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchViewModel.kt index ad355081..13b07429 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/search/GlobalSearchViewModel.kt @@ -32,19 +32,13 @@ enum class GlobalSearchDateFilter { enum class GlobalSearchResultType { All, Chats, + Channels, + Groups, Messages, Attachments, - Bots, - GroupCalls + Bots } -data class SearchCapabilityHooks( - val supportsMediaDateGrouping: Boolean = true, - val supportsBotMiniAppPlaceholder: Boolean = true, - val supportsScheduledMessageOptions: Boolean = true, - val supportsGroupCallPlaceholder: Boolean = true -) - data class GlobalSearchUiState( val query: String = "", val filter: GlobalSearchFilter = GlobalSearchFilter.All, @@ -56,8 +50,7 @@ data class GlobalSearchUiState( val hasMore: Boolean = false, val isSearching: Boolean = false, val isLoadingMore: Boolean = false, - val errorMessage: String? = null, - val hooks: SearchCapabilityHooks = SearchCapabilityHooks() + val errorMessage: String? = null ) { val visibleResults: List get() = results.filter { result -> @@ -214,10 +207,11 @@ private fun ChatSearchResultDto.matchesType(type: GlobalSearchResultType): Boole when (type) { GlobalSearchResultType.All -> true GlobalSearchResultType.Chats -> message == null + GlobalSearchResultType.Channels -> chat.type == ChatType.Channel + GlobalSearchResultType.Groups -> chat.type == ChatType.Group GlobalSearchResultType.Messages -> message != null GlobalSearchResultType.Attachments -> message?.attachments?.isNotEmpty() == true GlobalSearchResultType.Bots -> chat.participants.any { it.isBot } - GlobalSearchResultType.GroupCalls -> chat.type == ChatType.Group } internal fun ChatSearchResultDto.searchDateGroupLabel(): String = diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsPlaceholdersScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/AppSettingsScreens.kt similarity index 67% rename from src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsPlaceholdersScreen.kt rename to src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/AppSettingsScreens.kt index fcb030db..3087ed77 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsPlaceholdersScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/AppSettingsScreens.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -40,60 +39,7 @@ import com.seven.ikar.kotlin.ui.theme.TelegramBlue import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted @Composable -fun PrivacySettingsV2Screen( - onBackClick: () -> Unit, - onBlockedUsersClick: () -> Unit, - onExceptionsClick: () -> Unit -) { - IkarScreenSurface(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 18.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - IkarSimpleHeader(title = "Конфиденциальность", onBackClick = onBackClick) - IkarSectionCard { - IkarSectionTitle("Исключения", modifier = Modifier.padding(bottom = 12.dp)) - IkarValueRow("Заблокированные пользователи", "Список блокировок хранится в настройках конфиденциальности.", "Открыть", onClick = onBlockedUsersClick) - IkarRowDivider(startIndent = 0.dp) - IkarValueRow("Исключения приватности", "Списки «всегда разрешать» и «никогда не разрешать» для телефона, фото и историй.", "Открыть", onClick = onExceptionsClick) - } - IkarSectionCard { - Text("Подключено к серверному контракту", style = MaterialTheme.typography.titleLarge, color = TelegramBlue) - Text( - "Основные переключатели конфиденциальности и безопасности доступны через прежний экран; этот экран оставляет навигацию по подразделам как в Telegram.", - modifier = Modifier.padding(top = 6.dp), - style = MaterialTheme.typography.bodySmall, - color = TelegramInkMuted - ) - } - } - } -} - -@Composable -fun BlockedUsersPlaceholderScreen(onBackClick: () -> Unit) { - SimpleSettingsInfoScreen( - title = "Заблокированные", - body = "Сервер уже хранит список заблокированных пользователей в настройках конфиденциальности. Следующий слой: выбор пользователя с поиском и быстрым снятием блокировки.", - onBackClick = onBackClick - ) -} - -@Composable -fun PrivacyExceptionsPlaceholderScreen(onBackClick: () -> Unit) { - SimpleSettingsInfoScreen( - title = "Исключения", - body = "Контракт поддерживает списки разрешённых и запрещённых пользователей. Для полного интерфейса как в Telegram нужен выбор пользователей из контактов и отдельные правила для каждого поля.", - onBackClick = onBackClick - ) -} - -@Composable -fun ThemePlaceholderScreen( +fun ThemeSettingsScreen( state: AppSettingsUiState, onBackClick: () -> Unit, onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit, @@ -138,14 +84,21 @@ fun ThemePlaceholderScreen( onCheckedChange = { value -> onSnapshotChanged { it.copy(largeEmoji = value) } } ) } - Button(onClick = onResetClick, modifier = Modifier.fillMaxWidth()) { - Text("Сбросить") - } + Text( + text = "Сбросить", + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onResetClick) + .padding(vertical = 4.dp), + style = MaterialTheme.typography.titleLarge, + color = TelegramBlue, + fontWeight = FontWeight.SemiBold + ) } } @Composable -fun LanguagePlaceholderScreen( +fun LanguageSettingsScreen( state: AppSettingsUiState, onBackClick: () -> Unit, onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit @@ -167,9 +120,9 @@ fun LanguagePlaceholderScreen( } } IkarSectionCard { - Text("Локализация сохранена", style = MaterialTheme.typography.titleMedium, color = TelegramBlue) + Text("Локализация применяется сразу", style = MaterialTheme.typography.titleMedium, color = TelegramBlue) Text( - "Значение уже хранится локально. Для полного эффекта нужно применить его в базовом контексте приложения или слое локали Compose.", + "Выбранный язык сохраняется локально и сразу передается в контекст приложения. Системный режим возвращает язык устройства.", modifier = Modifier.padding(top = 6.dp), style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted @@ -179,7 +132,7 @@ fun LanguagePlaceholderScreen( } @Composable -fun ProxyPlaceholderScreen( +fun ProxySettingsScreen( state: AppSettingsUiState, onBackClick: () -> Unit, onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit @@ -189,14 +142,14 @@ fun ProxyPlaceholderScreen( IkarSectionCard { IkarToggleRow( "Использовать прокси", - "Профиль сохраняется локально и готов для подключения к OkHttp.", + "Применяется к новым сетевым запросам сразу после изменения.", snapshot.proxyEnabled, onCheckedChange = { value -> onSnapshotChanged { it.copy(proxyEnabled = value) } } ) IkarRowDivider(startIndent = 0.dp) IkarValueRow( "Тип", - "SOCKS5, MTProto или HTTP.", + "SOCKS5 или HTTP.", snapshot.proxyType.proxyTypeTitle(), onClick = { onSnapshotChanged { it.copy(proxyType = nextProxyType(it.proxyType)) } } ) @@ -224,15 +177,6 @@ fun ProxyPlaceholderScreen( singleLine = true, label = { Text("Порт") } ) - OutlinedTextField( - value = snapshot.proxySecret, - onValueChange = { value -> onSnapshotChanged { it.copy(proxySecret = value.take(256)) } }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp), - singleLine = true, - label = { Text("Секрет / пароль") } - ) } } } @@ -287,25 +231,6 @@ private fun SelectableSettingsRow( } } -@Composable -private fun SimpleSettingsInfoScreen( - title: String, - body: String, - onBackClick: () -> Unit -) { - IkarSettingsScaffold(title = title, onBackClick = onBackClick) { - IkarSectionCard { - Text("Готова модель данных", style = MaterialTheme.typography.titleLarge, color = TelegramBlue) - Text( - body, - modifier = Modifier.padding(top = 6.dp), - style = MaterialTheme.typography.bodyMedium, - color = TelegramInkMuted - ) - } - } -} - private fun AppThemeMode.themeTitle(): String = when (this) { AppThemeMode.System -> "Как в системе" AppThemeMode.Light -> "Дневная" @@ -349,12 +274,12 @@ private fun nextAccent(current: String): String = when (current) { private fun AppProxyType.proxyTypeTitle(): String = when (this) { AppProxyType.Socks5 -> "SOCKS5" - AppProxyType.MtProto -> "MTProto" + AppProxyType.MtProto -> "SOCKS5" AppProxyType.Http -> "HTTP" } private fun nextProxyType(current: AppProxyType): AppProxyType = when (current) { - AppProxyType.Socks5 -> AppProxyType.MtProto - AppProxyType.MtProto -> AppProxyType.Http + AppProxyType.Socks5 -> AppProxyType.Http + AppProxyType.MtProto -> AppProxyType.Socks5 AppProxyType.Http -> AppProxyType.Socks5 } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatSettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatSettingsScreen.kt index b0b4d01d..604024ad 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatSettingsScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/ChatSettingsScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -27,12 +26,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.seven.ikar.kotlin.ui.ChatWallpaperBackground -import com.seven.ikar.kotlin.core.settings.ChatDistanceUnitOption +import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot +import com.seven.ikar.kotlin.core.settings.ChatSwipeLeftActionOption import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Image import androidx.compose.material3.Icon @@ -108,10 +107,31 @@ fun ChatSettingsScreen( ) } ) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow( + title = "Список чатов", + subtitle = "Количество строк предпросмотра в списке чатов.", + value = snapshot.chatListLayout.title, + onClick = { onSnapshotChanged(snapshot.copy(chatListLayout = snapshot.chatListLayout.next())) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarValueRow( + title = "Свайп влево", + subtitle = "Действие при свайпе строки чата справа налево.", + value = snapshot.swipeLeftAction.title, + onClick = { onSnapshotChanged(snapshot.copy(swipeLeftAction = snapshot.swipeLeftAction.next())) } + ) + IkarRowDivider(startIndent = 0.dp) + IkarToggleRow( + title = "Анимации в чате", + subtitle = "Плавное движение выбранных обоев в окне переписки.", + checked = snapshot.animationsEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(animationsEnabled = it)) } + ) } ChatSettingsSection { - IkarSectionTitle("Медиафайлы и звук", modifier = Modifier.padding(bottom = 12.dp)) + IkarSectionTitle("Медиафайлы", modifier = Modifier.padding(bottom = 12.dp)) IkarToggleRow( title = "Листать медиафайлы по нажатию", subtitle = "Листать нажатием у края экрана в режиме просмотра медиафайлов.", @@ -120,93 +140,38 @@ fun ChatSettingsScreen( ) IkarRowDivider(startIndent = 0.dp) IkarToggleRow( - title = "Поднести и слушать", - subtitle = "Переключаться на верхний динамик, когда вы подносите телефон к уху.", - checked = snapshot.raiseToListenEnabled, - onCheckedChange = { onSnapshotChanged(snapshot.copy(raiseToListenEnabled = it)) } - ) - IkarRowDivider(startIndent = 0.dp) - IkarToggleRow( - title = "Поднести и говорить", - subtitle = "Записывать голосовое сообщение, когда вы подносите телефон к уху.", - checked = snapshot.raiseToTalkEnabled, - onCheckedChange = { onSnapshotChanged(snapshot.copy(raiseToTalkEnabled = it)) } - ) - IkarRowDivider(startIndent = 0.dp) - IkarToggleRow( - title = "Пауза музыки при записи", - subtitle = "Останавливать музыку на время записи голосового сообщения.", - checked = snapshot.pauseMusicOnRecordEnabled, - onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnRecordEnabled = it)) } - ) - IkarRowDivider(startIndent = 0.dp) - IkarToggleRow( - title = "Пауза музыки при запуске медиа", - subtitle = "Останавливать фоновую музыку при воспроизведении медиафайлов в чате.", + title = "Пауза музыки при запуске аудио", + subtitle = "Ставить фоновые плееры на паузу при прослушивании голосовых и аудио.", checked = snapshot.pauseMusicOnPlaybackEnabled, onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnPlaybackEnabled = it)) } ) IkarRowDivider(startIndent = 0.dp) - IkarValueRow( - title = "Микрофон", - subtitle = "Источник записи голосовых сообщений.", - value = "По умолчанию", - enabled = false + IkarToggleRow( + title = "Пауза музыки при записи", + subtitle = "Ставить фоновые плееры на паузу при записи голосового сообщения.", + checked = snapshot.pauseMusicOnRecordEnabled, + onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnRecordEnabled = it)) } ) } ChatSettingsSection { - IkarSectionTitle("Прочие настройки", modifier = Modifier.padding(bottom = 12.dp)) - IkarToggleRow( - title = "Смена темы ночью", - subtitle = "Автоматически включать ночную тему по локальному сценарию.", - checked = snapshot.autoNightThemeEnabled, - onCheckedChange = { onSnapshotChanged(snapshot.copy(autoNightThemeEnabled = it)) } - ) - IkarRowDivider(startIndent = 0.dp) + IkarSectionTitle("Ссылки", modifier = Modifier.padding(bottom = 12.dp)) IkarToggleRow( title = "Встроенный браузер", - subtitle = "Открывать ссылки внутри приложения, если доступен встроенный просмотр.", + subtitle = "Открывать ссылки из сообщений внутри Икара.", checked = snapshot.inAppBrowserEnabled, onCheckedChange = { onSnapshotChanged(snapshot.copy(inAppBrowserEnabled = it)) } ) - IkarRowDivider(startIndent = 0.dp) - IkarToggleRow( - title = "Анимации", - subtitle = "Использовать анимации интерфейса и медиа-переходов.", - checked = snapshot.animationsEnabled, - onCheckedChange = { onSnapshotChanged(snapshot.copy(animationsEnabled = it)) } - ) - IkarRowDivider(startIndent = 0.dp) - IkarToggleRow( - title = "Прямая отправка", - subtitle = "Показывать недавние чаты в системном меню «Поделиться».", - checked = snapshot.directShareEnabled, - onCheckedChange = { onSnapshotChanged(snapshot.copy(directShareEnabled = it)) } - ) - IkarRowDivider(startIndent = 0.dp) - IkarToggleRow( - title = "Показывать материалы 18+", - subtitle = "Не скрывать чувствительные медиафайлы.", - checked = snapshot.showSensitiveContentEnabled, - onCheckedChange = { onSnapshotChanged(snapshot.copy(showSensitiveContentEnabled = it)) } - ) - IkarRowDivider(startIndent = 0.dp) + } + + ChatSettingsSection { + IkarSectionTitle("Отправка", modifier = Modifier.padding(bottom = 12.dp)) IkarToggleRow( title = "Отправка по Вводу", subtitle = "Использовать действие отправки вместо перевода строки, если клавиатура поддерживает «Отправить».", checked = snapshot.sendByEnterEnabled, onCheckedChange = { onSnapshotChanged(snapshot.copy(sendByEnterEnabled = it)) } ) - IkarRowDivider(startIndent = 0.dp) - IkarValueRow( - title = "Мера расстояния", - subtitle = "Единицы для геоданных и расстояний в интерфейсе.", - value = snapshot.distanceUnit.title, - onClick = { - onSnapshotChanged(snapshot.copy(distanceUnit = snapshot.distanceUnit.next())) - } - ) } Text( @@ -293,6 +258,7 @@ private fun ChatStylePreview( ) { ChatWallpaperBackground( snapshot = wallpaperSnapshot, + animated = wallpaperSnapshot.animationsEnabled, modifier = Modifier.fillMaxSize() ) } @@ -333,26 +299,28 @@ private fun ChatStylePreview( } } -@Composable -private fun BoxLine(width: Dp) { - Row( - modifier = Modifier - .width(width) - .background(Color(0xFFDCE4EA), RoundedCornerShape(999.dp)) - .padding(vertical = 2.dp) - ) {} -} - -private val ChatDistanceUnitOption.title: String +private val ChatListLayoutOption.title: String get() = when (this) { - ChatDistanceUnitOption.Automatic -> "Автоматически" - ChatDistanceUnitOption.Kilometers -> "Километры" - ChatDistanceUnitOption.Miles -> "Мили" + ChatListLayoutOption.TwoLine -> "2 строки" + ChatListLayoutOption.ThreeLine -> "3 строки" } -private fun ChatDistanceUnitOption.next(): ChatDistanceUnitOption = when (this) { - ChatDistanceUnitOption.Automatic -> ChatDistanceUnitOption.Kilometers - ChatDistanceUnitOption.Kilometers -> ChatDistanceUnitOption.Miles - ChatDistanceUnitOption.Miles -> ChatDistanceUnitOption.Automatic +private fun ChatListLayoutOption.next(): ChatListLayoutOption = when (this) { + ChatListLayoutOption.TwoLine -> ChatListLayoutOption.ThreeLine + ChatListLayoutOption.ThreeLine -> ChatListLayoutOption.TwoLine } +private val ChatSwipeLeftActionOption.title: String + get() = when (this) { + ChatSwipeLeftActionOption.Delete -> "Удалить" + ChatSwipeLeftActionOption.Archive -> "Архив" + ChatSwipeLeftActionOption.Mute -> "Без звука" + ChatSwipeLeftActionOption.Read -> "Прочитать" + } + +private fun ChatSwipeLeftActionOption.next(): ChatSwipeLeftActionOption = when (this) { + ChatSwipeLeftActionOption.Delete -> ChatSwipeLeftActionOption.Archive + ChatSwipeLeftActionOption.Archive -> ChatSwipeLeftActionOption.Mute + ChatSwipeLeftActionOption.Mute -> ChatSwipeLeftActionOption.Read + ChatSwipeLeftActionOption.Read -> ChatSwipeLeftActionOption.Delete +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/DataStorageSettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/DataStorageSettingsScreen.kt index 4971c677..2534699e 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/DataStorageSettingsScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/DataStorageSettingsScreen.kt @@ -1,5 +1,6 @@ package com.seven.ikar.kotlin.feature.settings +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -12,7 +13,6 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,6 +26,7 @@ import com.seven.ikar.kotlin.ui.IkarSectionTitle import com.seven.ikar.kotlin.ui.IkarSimpleHeader import com.seven.ikar.kotlin.ui.IkarToggleRow import com.seven.ikar.kotlin.ui.IkarValueRow +import com.seven.ikar.kotlin.ui.theme.TelegramBlue import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted @Composable @@ -52,19 +53,19 @@ fun DataStorageSettingsScreen( IkarSectionTitle("Использование памяти", modifier = Modifier.padding(bottom = 12.dp)) IkarValueRow( "Кэш Ikar", - "Лимит локальных медиа и превью на устройстве.", + "Лимит локальных медиа.", "${snapshot.maxCacheMb} МБ", onClick = { onSnapshotChanged { it.copy(maxCacheMb = nextCacheLimit(it.maxCacheMb)) } } ) IkarRowDivider(startIndent = 0.dp) IkarValueRow( "Хранить медиа", - "Срок хранения локальных файлов перед LRU-очисткой.", + "Срок хранения файлов.", "${snapshot.keepMediaDays} дн.", onClick = { onSnapshotChanged { it.copy(keepMediaDays = nextKeepDays(it.keepMediaDays)) } } ) Text( - "Параметры сохраняются локально и готовы для подключения к TransferQueue/LRU-очистке.", + "Лимит и срок хранения применяются сразу к локальному кэшу медиафайлов.", modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted @@ -75,28 +76,28 @@ fun DataStorageSettingsScreen( IkarSectionTitle("Автозагрузка медиа", modifier = Modifier.padding(bottom = 12.dp)) IkarToggleRow( "Фото", - "Автоматически загружать фото в чатах.", + "Автозагрузка фото.", snapshot.autoDownloadPhotos, onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadPhotos = value) } } ) IkarRowDivider(startIndent = 0.dp) IkarToggleRow( "Видео", - "Автоматически загружать видео только по Wi-Fi.", + "Видео только по Wi-Fi.", snapshot.autoDownloadVideosOnWifi, onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadVideosOnWifi = value) } } ) IkarRowDivider(startIndent = 0.dp) IkarToggleRow( "Файлы", - "Автозагрузка документов только по Wi-Fi.", + "Документы только по Wi-Fi.", snapshot.autoDownloadFilesOnWifi, onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadFilesOnWifi = value) } } ) IkarRowDivider(startIndent = 0.dp) IkarToggleRow( - "Сохранять в галерею", - "Добавлять новые медиа в системную галерею.", + "Сохранять фото в галерею", + "Загруженные изображения сразу появляются в альбоме Ikar.", snapshot.saveMediaToGallery, onCheckedChange = { value -> onSnapshotChanged { it.copy(saveMediaToGallery = value) } } ) @@ -104,27 +105,23 @@ fun DataStorageSettingsScreen( IkarSectionCard { IkarSectionTitle("Сеть", modifier = Modifier.padding(bottom = 12.dp)) - IkarToggleRow( - "Меньше данных для звонков", - "Снижать битрейт звонков в мобильной сети.", - snapshot.lowDataCalls, - onCheckedChange = { value -> onSnapshotChanged { it.copy(lowDataCalls = value) } } - ) - IkarRowDivider(startIndent = 0.dp) IkarValueRow( "Прокси", - "SOCKS/MTProto/HTTP профиль для сетевого слоя.", + "SOCKS5/HTTP профиль.", if (snapshot.proxyEnabled) "Вкл." else "Выкл.", onClick = onProxyClick ) } - Button(onClick = onResetClick, modifier = Modifier.fillMaxWidth()) { - Text("Сбросить настройки") - } - Button(onClick = onBackClick, modifier = Modifier.fillMaxWidth()) { - Text("Готово") - } + Text( + text = "Сбросить настройки", + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onResetClick) + .padding(vertical = 4.dp), + style = MaterialTheme.typography.titleLarge, + color = TelegramBlue + ) } } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsScreen.kt index 9c6a373a..130b5ac6 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsScreen.kt @@ -16,21 +16,15 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Campaign -import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.PersonOutline -import androidx.compose.material.icons.outlined.Phone -import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.VolumeUp import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.seven.ikar.kotlin.core.push.NotificationPreferenceCatalog -import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalChoice -import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalOption import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot import com.seven.ikar.kotlin.core.push.NotificationSoundChoice import com.seven.ikar.kotlin.core.push.NotificationSoundOption @@ -49,14 +43,9 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted fun NotificationSettingsScreen( state: NotificationSettingsUiState, messageSoundOptions: List, - callRingtoneOptions: List, - repeatIntervalOptions: List, onBackClick: () -> Unit, onSnapshotChanged: (NotificationSettingsSnapshot) -> Unit, onMessageSoundChanged: (NotificationSoundOption) -> Unit, - onCallRingtoneChanged: (NotificationSoundOption) -> Unit, - onRepeatIntervalChanged: (NotificationRepeatIntervalOption) -> Unit, - onSaveClick: () -> Unit, onResetClick: () -> Unit ) { val snapshot = state.snapshot @@ -73,86 +62,59 @@ fun NotificationSettingsScreen( IkarSimpleHeader(title = "Уведомления и звуки", onBackClick = onBackClick) NotificationSection { - IkarSectionTitle("Уведомления из чатов", modifier = Modifier.padding(bottom = 12.dp)) - IkarToggleRow("Личные чаты", "Нажмите для изменения", snapshot.privateChatsEnabled, { onSnapshotChanged(snapshot.copy(privateChatsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.PersonOutline, contentDescription = null, tint = TelegramInkMuted) }) - IkarRowDivider(startIndent = 32.dp) - IkarToggleRow("Группы", "Нажмите для изменения", snapshot.groupsEnabled, { onSnapshotChanged(snapshot.copy(groupsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Groups, contentDescription = null, tint = TelegramInkMuted) }) - IkarRowDivider(startIndent = 32.dp) - IkarToggleRow("Каналы", "Нажмите для изменения", snapshot.channelsEnabled, { onSnapshotChanged(snapshot.copy(channelsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Campaign, contentDescription = null, tint = TelegramInkMuted) }) - IkarRowDivider(startIndent = 32.dp) - IkarToggleRow("Истории", "Отключены, 5 автоматических...", snapshot.storiesEnabled, { onSnapshotChanged(snapshot.copy(storiesEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Schedule, contentDescription = null, tint = TelegramInkMuted) }) - IkarRowDivider(startIndent = 32.dp) - IkarToggleRow("Реакции", "Сообщения, Истории", snapshot.reactionsEnabled, { onSnapshotChanged(snapshot.copy(reactionsEnabled = it)) }, leading = { Text("♥", color = TelegramBlue, style = MaterialTheme.typography.titleMedium) }) - } - - NotificationSection { - IkarSectionTitle("Звонки", modifier = Modifier.padding(bottom = 12.dp)) - IkarValueRow( - title = "Вибросигнал", - subtitle = "Переключить режим вибрации при звонке", - value = if (snapshot.callVibrationEnabled) "По умолчанию" else "Выключен", - onClick = { onSnapshotChanged(snapshot.copy(callVibrationEnabled = !snapshot.callVibrationEnabled)) }, - leading = { androidx.compose.material3.Icon(Icons.Outlined.Phone, contentDescription = null, tint = TelegramInkMuted) } + IkarSectionTitle("Основное", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow( + "Уведомления", + "Регистрировать устройство и показывать push-уведомления.", + snapshot.notificationsEnabled, + { onSnapshotChanged(snapshot.copy(notificationsEnabled = it)) }, + leading = { androidx.compose.material3.Icon(Icons.Outlined.Notifications, contentDescription = null, tint = TelegramInkMuted) } ) - IkarRowDivider(startIndent = 0.dp) + IkarRowDivider(startIndent = 32.dp) IkarValueRow( - title = "Рингтон", - subtitle = "Системный звук входящего звонка в Икар", - value = callRingtoneOptions.firstOrNull { it.value == snapshot.callRingtone }?.title ?: "По умолчанию", - onClick = { - val currentIndex = callRingtoneOptions.indexOfFirst { it.value == snapshot.callRingtone }.coerceAtLeast(0) - val next = callRingtoneOptions[(currentIndex + 1) % callRingtoneOptions.size] - onCallRingtoneChanged(next.value) - }, + title = "Звук сообщений", + subtitle = "Канал уведомлений для новых сообщений.", + value = messageSoundOptions.firstOrNull { it.value == snapshot.messageSound }?.title ?: "По умолчанию", + onClick = if (messageSoundOptions.isEmpty()) null else ({ + val currentIndex = messageSoundOptions.indexOfFirst { it.value == snapshot.messageSound }.coerceAtLeast(0) + val next = messageSoundOptions[(currentIndex + 1) % messageSoundOptions.size] + onMessageSoundChanged(next.value) + }), leading = { androidx.compose.material3.Icon(Icons.Outlined.VolumeUp, contentDescription = null, tint = TelegramInkMuted) } ) } + NotificationSection { + IkarSectionTitle("Уведомления из чатов", modifier = Modifier.padding(bottom = 12.dp)) + IkarToggleRow("Личные чаты", "Уведомления личных чатов", snapshot.privateChatsEnabled, { onSnapshotChanged(snapshot.copy(privateChatsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.PersonOutline, contentDescription = null, tint = TelegramInkMuted) }) + IkarRowDivider(startIndent = 32.dp) + IkarToggleRow("Группы", "Уведомления групп", snapshot.groupsEnabled, { onSnapshotChanged(snapshot.copy(groupsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Groups, contentDescription = null, tint = TelegramInkMuted) }) + IkarRowDivider(startIndent = 32.dp) + IkarToggleRow("Каналы", "Уведомления каналов", snapshot.channelsEnabled, { onSnapshotChanged(snapshot.copy(channelsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Campaign, contentDescription = null, tint = TelegramInkMuted) }) + IkarRowDivider(startIndent = 32.dp) + IkarToggleRow("Реакции", "Реакции на сообщения", snapshot.reactionsEnabled, { onSnapshotChanged(snapshot.copy(reactionsEnabled = it)) }, leading = { Text("♥", color = TelegramBlue, style = MaterialTheme.typography.titleMedium) }) + } + NotificationSection { IkarSectionTitle("Счётчик сообщений", modifier = Modifier.padding(bottom = 12.dp)) - IkarToggleRow("Показывать счётчик", "Разрешать значок-счётчик на иконке приложения", snapshot.showBadge, { onSnapshotChanged(snapshot.copy(showBadge = it)) }) + IkarToggleRow("Показывать счётчик", "Индикатор на значке «Чаты» в нижнем меню.", snapshot.showBadge, { onSnapshotChanged(snapshot.copy(showBadge = it)) }) IkarRowDivider(startIndent = 0.dp) - IkarToggleRow("Чаты без уведомлений", "Показывать muted-чаты внутри общего счётчика", snapshot.includeMutedChatsInBadge, { onSnapshotChanged(snapshot.copy(includeMutedChatsInBadge = it)) }) + IkarToggleRow("Чаты без уведомлений", "Включать в общий счётчик", snapshot.includeMutedChatsInBadge, { onSnapshotChanged(snapshot.copy(includeMutedChatsInBadge = it)) }) IkarRowDivider(startIndent = 0.dp) - IkarToggleRow("Число сообщений", "Показывать количество непрочитанных сообщений", snapshot.countMessagesInBadge, { onSnapshotChanged(snapshot.copy(countMessagesInBadge = it)) }) + IkarToggleRow("Число сообщений", "Показывать сумму сообщений вместо числа чатов.", snapshot.countMessagesInBadge, { onSnapshotChanged(snapshot.copy(countMessagesInBadge = it)) }) } NotificationSection { IkarSectionTitle("В приложении", modifier = Modifier.padding(bottom = 12.dp)) - IkarToggleRow("Звук", "Проигрывать звук для всплывающих уведомлений", snapshot.inAppSoundEnabled, { onSnapshotChanged(snapshot.copy(inAppSoundEnabled = it)) }) + IkarToggleRow("Звук", "Звук всплывающих уведомлений", snapshot.inAppSoundEnabled, { onSnapshotChanged(snapshot.copy(inAppSoundEnabled = it)) }) IkarRowDivider(startIndent = 0.dp) - IkarToggleRow("Вибросигнал", "Вибрировать для in-app уведомлений", snapshot.inAppVibrationEnabled, { onSnapshotChanged(snapshot.copy(inAppVibrationEnabled = it)) }) + IkarToggleRow("Вибросигнал", "Вибрация внутри приложения", snapshot.inAppVibrationEnabled, { onSnapshotChanged(snapshot.copy(inAppVibrationEnabled = it)) }) IkarRowDivider(startIndent = 0.dp) - IkarToggleRow("Показывать текст", "Показывать содержимое сообщения во всплывающем окне", snapshot.inAppShowTextEnabled, { onSnapshotChanged(snapshot.copy(inAppShowTextEnabled = it)) }) + IkarToggleRow("Показывать текст", "Текст во всплывающем окне", snapshot.inAppShowTextEnabled, { onSnapshotChanged(snapshot.copy(inAppShowTextEnabled = it)) }) IkarRowDivider(startIndent = 0.dp) - IkarToggleRow("Звук в чате", "Короткий сигнал для уже открытого чата", snapshot.soundInActiveChatEnabled, { onSnapshotChanged(snapshot.copy(soundInActiveChatEnabled = it)) }) + IkarToggleRow("Звук в чате", "Сигнал в открытом чате", snapshot.soundInActiveChatEnabled, { onSnapshotChanged(snapshot.copy(soundInActiveChatEnabled = it)) }) IkarRowDivider(startIndent = 0.dp) - IkarToggleRow("Всплывающие окна", "Показывать всплывающие окна, когда приложение открыто", snapshot.popupWindowsEnabled, { onSnapshotChanged(snapshot.copy(popupWindowsEnabled = it)) }) - } - - NotificationSection { - IkarSectionTitle("События", modifier = Modifier.padding(bottom = 12.dp)) - IkarToggleRow("Контакт присоединился к Ikar", "Сообщать, когда контакт появился в Ikar", snapshot.contactJoinedEnabled, { onSnapshotChanged(snapshot.copy(contactJoinedEnabled = it)) }) - IkarRowDivider(startIndent = 0.dp) - IkarToggleRow("Закреплённые сообщения", "Показывать уведомления о закреплённых сообщениях", snapshot.pinnedMessagesEnabled, { onSnapshotChanged(snapshot.copy(pinnedMessagesEnabled = it)) }) - } - - NotificationSection { - IkarSectionTitle("Другое", modifier = Modifier.padding(bottom = 12.dp)) - IkarToggleRow("Перезапуск при закрытии", "Поднимать фоновый сервис доставки уведомлений", snapshot.restartOnCloseEnabled, { onSnapshotChanged(snapshot.copy(restartOnCloseEnabled = it)) }) - IkarRowDivider(startIndent = 0.dp) - IkarToggleRow("Фоновое соединение", "Держать минимальное фоновое соединение", snapshot.backgroundConnectionEnabled, { onSnapshotChanged(snapshot.copy(backgroundConnectionEnabled = it)) }) - IkarRowDivider(startIndent = 0.dp) - IkarValueRow( - title = "Повтор уведомлений", - subtitle = "Повторять уведомление, пока в чате есть непрочитанные сообщения", - value = repeatIntervalOptions.firstOrNull { it.value == snapshot.repeatInterval }?.title ?: "1 час", - onClick = { - val currentIndex = repeatIntervalOptions.indexOfFirst { it.value == snapshot.repeatInterval }.coerceAtLeast(0) - val next = repeatIntervalOptions[(currentIndex + 1) % repeatIntervalOptions.size] - onRepeatIntervalChanged(next.value) - } - ) + IkarToggleRow("Всплывающие окна", "Показывать поверх приложения", snapshot.popupWindowsEnabled, { onSnapshotChanged(snapshot.copy(popupWindowsEnabled = it)) }) } NotificationSection { @@ -166,16 +128,12 @@ fun NotificationSettingsScreen( style = MaterialTheme.typography.titleLarge ) Text( - "Сбросить все особые настройки уведомлений для отдельных контактов, групп и каналов.", + "Сбросить особые настройки контактов, групп и каналов.", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted ) } - androidx.compose.material3.Button(onClick = onSaveClick, modifier = Modifier.fillMaxWidth()) { - Text(if (state.isSaving) "Сохранение..." else "Сохранить") - } - state.errorMessage?.let { Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium) } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsViewModel.kt index a4bb9632..a2c4b798 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/NotificationSettingsViewModel.kt @@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.seven.ikar.kotlin.app.AppContainer import com.seven.ikar.kotlin.core.push.NotificationPreferenceCatalog -import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalChoice -import com.seven.ikar.kotlin.core.push.NotificationRepeatIntervalOption import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot import com.seven.ikar.kotlin.core.push.NotificationSoundChoice import com.seven.ikar.kotlin.core.push.NotificationSoundOption @@ -28,36 +26,39 @@ class NotificationSettingsViewModel(private val container: AppContainer) : ViewM val uiState: StateFlow = _uiState.asStateFlow() val messageSoundOptions: List = NotificationPreferenceCatalog.messageSoundChoices - val callRingtoneOptions: List = NotificationPreferenceCatalog.callRingtoneChoices - val repeatIntervalOptions: List = NotificationPreferenceCatalog.repeatChoices + private var saveRevision = 0 fun updateSnapshot(transform: (NotificationSettingsSnapshot) -> NotificationSettingsSnapshot) { - _uiState.value = _uiState.value.copy(snapshot = transform(_uiState.value.snapshot)) + val snapshot = transform(_uiState.value.snapshot) + _uiState.value = _uiState.value.copy(snapshot = snapshot, errorMessage = null, infoMessage = null) + persist(snapshot) } fun setMessageSound(value: NotificationSoundOption) = updateSnapshot { it.copy(messageSound = value) } - fun setCallRingtone(value: NotificationSoundOption) = updateSnapshot { it.copy(callRingtone = value) } - - fun setRepeatInterval(value: NotificationRepeatIntervalOption) = updateSnapshot { it.copy(repeatInterval = value) } - - fun save() { + private fun persist(snapshot: NotificationSettingsSnapshot) { + val revision = ++saveRevision viewModelScope.launch { - val snapshot = _uiState.value.snapshot _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, infoMessage = null) runCatching { container.pushSettings.saveSnapshot(snapshot) container.pushRegistrationManager.registerCurrentDeviceAsync() }.onSuccess { - _uiState.value = _uiState.value.copy(isSaving = false, infoMessage = "Настройки уведомлений сохранены.") + if (revision == saveRevision) { + _uiState.value = _uiState.value.copy(isSaving = false, infoMessage = null) + } }.onFailure { error -> - _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + if (revision == saveRevision) { + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) + } } } } fun reset() { container.pushSettings.resetNotificationPreferences() - _uiState.value = NotificationSettingsUiState(snapshot = container.pushSettings.createDefaultSnapshot()) + val snapshot = container.pushSettings.createDefaultSnapshot() + _uiState.value = NotificationSettingsUiState(snapshot = snapshot) + persist(snapshot) } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsScreen.kt index fa3654cf..ffd9e937 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/settings/SettingsScreen.kt @@ -11,13 +11,26 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.AutoStories import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Devices +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material.icons.outlined.SystemUpdate +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.VpnKey import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -31,6 +44,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,7 +72,6 @@ fun SettingsScreen( onChatSettingsClick: () -> Unit, onPrivacyClick: () -> Unit, onNotificationsClick: () -> Unit, - onBotsClick: () -> Unit, onFoldersClick: () -> Unit, onDevicesClick: () -> Unit, onUpdatesClick: () -> Unit, @@ -76,7 +89,6 @@ fun SettingsScreen( onChatSettingsClick, onPrivacyClick, onNotificationsClick, - onBotsClick, onFoldersClick, onDevicesClick, onUpdatesClick, @@ -92,31 +104,24 @@ fun SettingsScreen( subtitle = "Телефон, имя и описание профиля", backgroundColor = Color(0xFFE6F1FD), accentColor = TelegramBlue, - letter = "А", + icon = Icons.Outlined.AccountCircle, onClick = onAccountClick, keywords = listOf("телефон", "номер", "имя", "username", "описание", "профиль") ), SettingsEntryModel( title = "Настройки чатов", - subtitle = "Обои, список чатов, жесты и медиа", - backgroundColor = Color(0xFFFFF8ECD8), + subtitle = "Обои, список чатов, текст, медиа и Enter", + backgroundColor = Color(0xFFFFF8EC), accentColor = Color(0xFFD7812B), - letter = "Ч", + icon = Icons.Outlined.Tune, onClick = onChatSettingsClick, keywords = listOf( "обои", "список чатов", - "жесты", "медиа", "текст сообщений", "углы", - "свайп", - "встроенный браузер", - "анимации", - "direct share", - "отправка по enter", - "микрофон", - "18+" + "отправка по enter" ) ), SettingsEntryModel( @@ -124,7 +129,7 @@ fun SettingsScreen( subtitle = "Контакты и отображение имён в чатах", backgroundColor = Color(0xFFE9F7E5), accentColor = Color(0xFF44A948), - letter = "К", + icon = Icons.Outlined.Security, onClick = onPrivacyClick, keywords = listOf("контакты", "имена", "приватность", "конфиденциальность") ), @@ -133,7 +138,7 @@ fun SettingsScreen( subtitle = "Звуки, звонки и счётчик сообщений", backgroundColor = Color(0xFFFDE8EA), accentColor = Color(0xFFDC4E5C), - letter = "У", + icon = Icons.Outlined.Notifications, onClick = onNotificationsClick, keywords = listOf("уведомления", "звук", "звонки", "push", "счётчик") ), @@ -142,25 +147,16 @@ fun SettingsScreen( subtitle = "Проверка новой версии Икара и установка из Argus", backgroundColor = Color(0xFFE8F3FF), accentColor = TelegramBlue, - letter = "О", + icon = Icons.Outlined.SystemUpdate, onClick = onUpdatesClick, keywords = listOf("обновление", "version", "apk", "argus", "релиз", "установка") ), - SettingsEntryModel( - title = "Боты", - subtitle = "Создание, токены и команды API ботов", - backgroundColor = Color(0xFFEAF4FF), - accentColor = TelegramBlue, - letter = "Б", - onClick = onBotsClick, - keywords = listOf("bot api", "токен", "команды", "бот", "боты") - ), SettingsEntryModel( title = "Данные и память", subtitle = "Кэш и локальные медиафайлы", backgroundColor = Color(0xFFEAF0FD), accentColor = Color(0xFF4B76E4), - letter = "Д", + icon = Icons.Outlined.Storage, onClick = onDataStorageClick, keywords = listOf("кэш", "память", "данные", "медиафайлы", "хранилище") ), @@ -169,34 +165,25 @@ fun SettingsScreen( subtitle = "Стартовый фильтр для списка чатов", backgroundColor = Color(0xFFEEF5FD), accentColor = Color(0xFF5293F1), - letter = "П", + icon = Icons.Outlined.Folder, onClick = onFoldersClick, keywords = listOf("папки", "фильтр", "список чатов", "архив") ), SettingsEntryModel( title = "Устройства", - subtitle = "Устройства уведомлений и их состояние", + subtitle = "Где используется Икар и состояние уведомлений", backgroundColor = Color(0xFFEAF7FB), accentColor = Color(0xFF49A8C2), - letter = "У", + icon = Icons.Outlined.Devices, onClick = onDevicesClick, - keywords = listOf("устройства", "push", "сессии", "авторизация") - ), - SettingsEntryModel( - title = "Энергосбережение", - subtitle = "Фоновое обновление и прогрев превью", - backgroundColor = Color(0xFFFFF1E6), - accentColor = Color(0xFFD7812B), - letter = "Э", - onClick = null, - keywords = listOf("батарея", "энергосбережение", "фон", "превью") + keywords = listOf("устройства", "push", "телефон", "планшет", "компьютер") ), SettingsEntryModel( title = "Истории", subtitle = "Лента, редактор, срок жизни и защищённые истории", backgroundColor = Color(0xFFEAF4FF), accentColor = TelegramBlue, - letter = "И", + icon = Icons.Outlined.AutoStories, onClick = onStoriesClick, keywords = listOf("stories", "истории", "сторис", "композер", "story") ), @@ -205,27 +192,27 @@ fun SettingsScreen( subtitle = "Тема, акцент, эмодзи и анимированные фоны", backgroundColor = Color(0xFFE9F7E5), accentColor = Color(0xFF44A948), - letter = "Т", + icon = Icons.Outlined.Palette, onClick = onThemeClick, keywords = listOf("theme", "тема", "ночная", "оформление") ), SettingsEntryModel( title = "Язык", - subtitle = "Русский", - backgroundColor = Color(0xFFF3EAFF), - accentColor = Color(0xFFA25FE0), - letter = "Я", + subtitle = "Русский, английский или язык устройства", + backgroundColor = Color(0xFFFFF8EC), + accentColor = Color(0xFFD7812B), + icon = Icons.Outlined.Language, onClick = onLanguageClick, - keywords = listOf("язык", "русский", "локаль") + keywords = listOf("language", "язык", "русский", "английский", "локализация") ), SettingsEntryModel( title = "Прокси", - subtitle = "SOCKS/MTProto/HTTP профиль подключения", + subtitle = "SOCKS5/HTTP профиль подключения", backgroundColor = Color(0xFFEAF7FB), accentColor = Color(0xFF49A8C2), - letter = "П", + icon = Icons.Outlined.VpnKey, onClick = onProxyClick, - keywords = listOf("proxy", "прокси", "socks", "mtproto", "сеть") + keywords = listOf("proxy", "прокси", "socks", "http", "сеть") ) ) } @@ -289,7 +276,7 @@ fun SettingsScreen( subtitle = entry.subtitle, backgroundColor = entry.backgroundColor, accentColor = entry.accentColor, - letter = entry.letter, + icon = entry.icon, onClick = entry.onClick ) if (index != visibleEntries.lastIndex) { @@ -387,7 +374,7 @@ private data class SettingsEntryModel( val subtitle: String, val backgroundColor: Color, val accentColor: Color, - val letter: String, + val icon: ImageVector, val onClick: (() -> Unit)?, val keywords: List ) @@ -437,7 +424,7 @@ private fun SettingsEntry( subtitle: String, backgroundColor: Color, accentColor: Color, - letter: String, + icon: ImageVector, onClick: (() -> Unit)? ) { IkarSettingRow( @@ -451,11 +438,11 @@ private fun SettingsEntry( shape = CircleShape ) { Box(contentAlignment = Alignment.Center) { - Text( - text = letter, - style = MaterialTheme.typography.titleMedium, - color = accentColor, - fontWeight = FontWeight.SemiBold + Icon( + imageVector = icon, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(22.dp) ) } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetScreen.kt index 52150d22..0bde37df 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetScreen.kt @@ -1,10 +1,15 @@ package com.seven.ikar.kotlin.feature.share +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding @@ -12,14 +17,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem import com.seven.ikar.kotlin.ui.AvatarView import com.seven.ikar.kotlin.ui.IkarRowDivider @@ -37,9 +45,18 @@ fun ShareTargetScreen( state: ShareTargetUiState, onBackClick: () -> Unit, onQueryChanged: (String) -> Unit, - onChatClick: (ShareChatTargetItemUiState) -> Unit, + onRefresh: () -> Unit, onUserClick: (DiscoverUserItem) -> Unit ) { + val context = LocalContext.current + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + onRefresh() + } + } + val hasContactsPermission = + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED + IkarScreenSurface(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier @@ -56,11 +73,26 @@ fun ShareTargetScreen( IkarSearchCard( value = state.query, onValueChange = onQueryChanged, - placeholder = "Поиск чата или контакта" + placeholder = "Поиск контакта" ) SharePreviewCard(state.payload) + if (!hasContactsPermission) { + IkarSectionCard { + Text("Нужен доступ к контактам", style = MaterialTheme.typography.headlineMedium) + Text( + "Разрешите доступ к телефонной книге, чтобы выбрать получателя из контактов.", + style = MaterialTheme.typography.bodyMedium, + color = TelegramInkMuted + ) + Spacer(modifier = Modifier.padding(top = 8.dp)) + Button(onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) }) { + Text("Разрешить доступ") + } + } + } + state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) } @@ -74,7 +106,7 @@ fun ShareTargetScreen( padding = PaddingValues(0.dp) ) { LazyColumn { - if (state.isLoading && state.chats.isEmpty() && state.contacts.isEmpty()) { + if (state.isLoading && state.contacts.isEmpty()) { item { Row( modifier = Modifier @@ -88,15 +120,6 @@ fun ShareTargetScreen( } } - if (state.chats.isNotEmpty()) { - item { - SectionLabel("Чаты") - } - items(state.chats, key = { it.chat.id }) { item -> - ShareChatRow(item = item, onClick = { onChatClick(item) }) - } - } - if (state.contacts.isNotEmpty()) { item { SectionLabel("Контакты") @@ -106,7 +129,7 @@ fun ShareTargetScreen( } } - if (!state.isLoading && state.chats.isEmpty() && state.contacts.isEmpty()) { + if (!state.isLoading && state.contacts.isEmpty()) { item { Column( modifier = Modifier @@ -115,7 +138,7 @@ fun ShareTargetScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Подходящих получателей нет", style = MaterialTheme.typography.headlineMedium) + Text("Подходящих контактов нет", style = MaterialTheme.typography.headlineMedium) Text( "Уточните поиск или откройте доступ к контактам.", color = TelegramInkMuted, @@ -176,59 +199,6 @@ private fun SectionLabel(text: String) { ) } -@Composable -private fun ShareChatRow( - item: ShareChatTargetItemUiState, - onClick: () -> Unit -) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 18.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AvatarView(imageUrl = null, title = item.resolvedTitle, size = 54.dp) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = item.resolvedTitle, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (item.lastActivityLabel.isNotBlank()) { - Text( - text = item.lastActivityLabel, - style = MaterialTheme.typography.bodySmall, - color = TelegramInkMuted, - maxLines = 1 - ) - } - } - Text( - text = item.preview, - style = MaterialTheme.typography.bodySmall, - color = TelegramInkMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - IkarRowDivider(startIndent = 86.dp) - } -} - @Composable private fun ShareContactRow( item: DiscoverUserItem, diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetViewModel.kt index e5120488..d10b4f14 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/share/ShareTargetViewModel.kt @@ -10,8 +10,6 @@ import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer import com.seven.ikar.kotlin.core.model.ChatSummaryDto import com.seven.ikar.kotlin.core.model.ChatType -import com.seven.ikar.kotlin.ui.chatPreview -import com.seven.ikar.kotlin.ui.formatLastActivity import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -19,17 +17,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -data class ShareChatTargetItemUiState( - val chat: ChatSummaryDto, - val resolvedTitle: String, - val lastActivityLabel: String, - val preview: String -) - data class ShareTargetUiState( val payload: PendingExternalShare, val query: String = "", - val chats: List = emptyList(), val contacts: List = emptyList(), val isLoading: Boolean = false, val isSending: Boolean = false, @@ -43,8 +33,6 @@ class ShareTargetViewModel( ) : ViewModel() { private val _uiState = MutableStateFlow(ShareTargetUiState(payload = pendingShare)) private val _allChats = mutableListOf() - private var contactNamesByNormalizedPhone: Map = emptyMap() - private var currentUserId: String? = container.sessionStore.read()?.user?.id private var searchJob: Job? = null val uiState: StateFlow = _uiState.asStateFlow() @@ -67,18 +55,14 @@ class ShareTargetViewModel( val query = _uiState.value.query.trim() _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) runCatching { - contactNamesByNormalizedPhone = loadDeviceContactNames() val chats = container.executeAuthorized { session -> - currentUserId = session.user.id container.apiClient.getChats(session.accessToken) } _allChats.clear() _allChats.addAll(chats) - val contacts = loadContactCandidates(query) - chats to contacts - }.onSuccess { (chats, contacts) -> + loadContactCandidates(query) + }.onSuccess { contacts -> _uiState.value = _uiState.value.copy( - chats = filterChats(chats, query), contacts = contacts, isLoading = false, errorMessage = null @@ -89,10 +73,6 @@ class ShareTargetViewModel( } } - fun sendToChat(chat: ShareChatTargetItemUiState) { - sendToChatId(chat.chat.id) - } - fun sendToUser(user: DiscoverUserItem) { if (_uiState.value.isSending) { return @@ -119,26 +99,6 @@ class ShareTargetViewModel( _uiState.value = _uiState.value.copy(pendingChatId = null) } - private fun sendToChatId(chatId: String) { - if (_uiState.value.isSending) { - return - } - - viewModelScope.launch { - _uiState.value = _uiState.value.copy(isSending = true, errorMessage = null) - runCatching { - container.executeAuthorized { session -> - sendPendingShare(session.accessToken, chatId) - chatId - } - }.onSuccess { targetChatId -> - _uiState.value = _uiState.value.copy(isSending = false, pendingChatId = targetChatId) - }.onFailure { error -> - _uiState.value = _uiState.value.copy(isSending = false, errorMessage = error.message) - } - } - } - private suspend fun sendPendingShare(accessToken: String, chatId: String) { val sharedText = pendingShare.text?.trim().orEmpty() if (pendingShare.attachments.isEmpty()) { @@ -155,32 +115,8 @@ class ShareTargetViewModel( ) } - private fun filterChats(chats: List, query: String): List { - val items = chats - .filter { it.canSendMessages } - .map { chat -> - ShareChatTargetItemUiState( - chat = chat, - resolvedTitle = resolveChatTitle(chat), - lastActivityLabel = formatLastActivity(chat.lastActivityAt), - preview = chatPreview(chat) - ) - } - - if (query.isBlank()) { - return items - } - - return items.filter { item -> - item.resolvedTitle.contains(query, ignoreCase = true) || - item.chat.title.contains(query, ignoreCase = true) || - item.chat.secondaryText.orEmpty().contains(query, ignoreCase = true) || - item.chat.lastMessagePreview.orEmpty().contains(query, ignoreCase = true) - } - } - private suspend fun loadContactCandidates(query: String): List { - val candidates = when { + return when { hasContactsPermission() -> container.resolvedContactsRepository.loadMergedCandidates(query) query.isBlank() -> emptyList() else -> container.executeAuthorized { session -> @@ -193,27 +129,6 @@ class ShareTargetViewModel( } } } - - val directParticipantIds = _allChats - .asSequence() - .filter { it.type == ChatType.Direct } - .flatMap { chat -> chat.participants.asSequence().map { participant -> participant.id } } - .toSet() - - return candidates.filterNot { directParticipantIds.contains(it.id) } - } - - private fun resolveChatTitle(chat: ChatSummaryDto): String { - if (chat.type != ChatType.Direct) { - return chat.title - } - - val peer = chat.participants.firstOrNull { it.id != currentUserId } ?: chat.participants.firstOrNull() - val normalizedPhone = PhoneNumberNormalizer.normalize(peer?.phoneNumber) - val contactName = normalizedPhone?.let(contactNamesByNormalizedPhone::get) - return contactName?.takeIf { it.isNotBlank() } - ?: peer?.displayName?.takeIf { it.isNotBlank() } - ?: chat.title } private fun findExistingDirectChatId(userId: String): String? = @@ -221,16 +136,6 @@ class ShareTargetViewModel( chat.type == ChatType.Direct && chat.participants.any { participant -> participant.id == userId } }?.id - private fun loadDeviceContactNames(): Map { - if (!hasContactsPermission()) { - return emptyMap() - } - - return container.contactDiscoveryService.getContacts().associate { contact -> - contact.normalizedPhoneNumber to contact.displayName - } - } - private fun hasContactsPermission(): Boolean = ContextCompat.checkSelfPermission( container.appContext, diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesScreen.kt index 7178a339..dddf14a2 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing @@ -28,6 +29,9 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add @@ -44,20 +48,38 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView import coil.compose.AsyncImage import com.seven.ikar.kotlin.core.model.AttachmentDto import com.seven.ikar.kotlin.core.model.StoryDto +import com.seven.ikar.kotlin.core.model.StoryViewDto import com.seven.ikar.kotlin.core.model.StoryVisibility import com.seven.ikar.kotlin.core.network.ServerConfig import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage @@ -72,8 +94,12 @@ import com.seven.ikar.kotlin.ui.IkarToggleRow import com.seven.ikar.kotlin.ui.SecureWindowEffect import com.seven.ikar.kotlin.ui.theme.TelegramBlue import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted +import kotlinx.coroutines.delay import java.io.File +private const val StoryAutoAdvanceDurationMillis = 5_000L +private const val StoryAutoAdvanceTickMillis = 50L + @Composable fun StoriesScreen( state: StoriesUiState, @@ -85,6 +111,11 @@ fun StoriesScreen( onCloseStoryPreview: () -> Unit, onNextStory: () -> Unit, onPreviousStory: () -> Unit, + onStoryReplyTextChanged: (String) -> Unit, + onSendStoryReply: (StoryDto) -> Unit, + onSendStoryReaction: (StoryDto, String) -> Unit, + onShowStoryViews: (StoryDto) -> Unit, + onCloseStoryViews: () -> Unit, onRetryTransfer: (String) -> Unit, onCancelTransfer: (String) -> Unit ) { @@ -96,9 +127,22 @@ fun StoriesScreen( stories = state.stories, accessToken = state.accessToken, isOwnStory = story.author.id == state.currentUserId, + replyText = state.storyReplyText, + isSendingReply = state.isSendingStoryReply, + replyErrorMessage = state.storyReplyErrorMessage, + replySent = state.storyReplySentStoryId == story.id, + storyViews = state.storyViews.takeIf { state.storyViewsStoryId == story.id }.orEmpty(), + isLoadingStoryViews = state.isLoadingStoryViews && state.storyViewsStoryId == story.id, + storyViewsErrorMessage = state.storyViewsErrorMessage.takeIf { state.storyViewsStoryId == story.id }, + showStoryViews = state.storyViewsStoryId == story.id, onClose = onCloseStoryPreview, onNextStory = onNextStory, onPreviousStory = onPreviousStory, + onReplyTextChanged = onStoryReplyTextChanged, + onSendReply = { onSendStoryReply(story) }, + onSendReaction = { emoji -> onSendStoryReaction(story, emoji) }, + onShowStoryViews = { onShowStoryViews(story) }, + onCloseStoryViews = onCloseStoryViews, onDeleteStory = { onDeleteStory(story) } ) } @@ -346,9 +390,22 @@ private fun StoryViewerDialog( stories: List, accessToken: String?, isOwnStory: Boolean, + replyText: String, + isSendingReply: Boolean, + replyErrorMessage: String?, + replySent: Boolean, + storyViews: List, + isLoadingStoryViews: Boolean, + storyViewsErrorMessage: String?, + showStoryViews: Boolean, onClose: () -> Unit, onNextStory: () -> Unit, onPreviousStory: () -> Unit, + onReplyTextChanged: (String) -> Unit, + onSendReply: () -> Unit, + onSendReaction: (String) -> Unit, + onShowStoryViews: () -> Unit, + onCloseStoryViews: () -> Unit, onDeleteStory: () -> Unit ) { Dialog( @@ -356,26 +413,64 @@ private fun StoryViewerDialog( properties = DialogProperties(usePlatformDefaultWidth = false) ) { BackHandler(onBack = onClose) + var selectedMediaIndex by remember(story.id, story.attachments.size) { mutableIntStateOf(0) } + val lastMediaIndex = (story.attachments.size - 1).coerceAtLeast(0) + val safeMediaIndex = selectedMediaIndex.coerceIn(0, lastMediaIndex) + var autoProgress by remember(story.id, safeMediaIndex) { mutableFloatStateOf(0f) } + val autoAdvancePaused = showStoryViews || replyText.isNotBlank() || isSendingReply + + LaunchedEffect(story.id, safeMediaIndex, autoAdvancePaused) { + autoProgress = 0f + if (autoAdvancePaused) { + return@LaunchedEffect + } + + var elapsedMillis = 0L + while (elapsedMillis < StoryAutoAdvanceDurationMillis) { + delay(StoryAutoAdvanceTickMillis) + elapsedMillis += StoryAutoAdvanceTickMillis + autoProgress = (elapsedMillis.toFloat() / StoryAutoAdvanceDurationMillis) + .coerceIn(0f, 1f) + } + + if (story.attachments.isNotEmpty() && safeMediaIndex < story.attachments.lastIndex) { + selectedMediaIndex = safeMediaIndex + 1 + } else { + onNextStory() + } + } Box( modifier = Modifier .fillMaxSize() .background(Color.Black) ) { - StoryViewerContent(story = story, accessToken = accessToken) + StoryViewerContent(story = story, accessToken = accessToken, mediaIndex = safeMediaIndex) Row(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .weight(1f) .fillMaxSize() - .clickable(onClick = onPreviousStory) + .clickable { + if (safeMediaIndex > 0) { + selectedMediaIndex = safeMediaIndex - 1 + } else { + onPreviousStory() + } + } ) Box( modifier = Modifier .weight(1f) .fillMaxSize() - .clickable(onClick = onNextStory) + .clickable { + if (story.attachments.isNotEmpty() && safeMediaIndex < story.attachments.lastIndex) { + selectedMediaIndex = safeMediaIndex + 1 + } else { + onNextStory() + } + } ) } @@ -384,12 +479,30 @@ private fun StoryViewerDialog( stories = stories, accessToken = accessToken, isOwnStory = isOwnStory, + mediaIndex = safeMediaIndex, + mediaCount = story.attachments.size, + progress = autoProgress, onClose = onClose, onDeleteStory = onDeleteStory ) - StoryViewerCaption( + StoryViewerBottomBar( story = story, + isOwnStory = isOwnStory, + replyText = replyText, + isSendingReply = isSendingReply, + replyErrorMessage = replyErrorMessage, + replySent = replySent, + storyViews = storyViews, + isLoadingStoryViews = isLoadingStoryViews, + storyViewsErrorMessage = storyViewsErrorMessage, + showStoryViews = showStoryViews, + accessToken = accessToken, + onReplyTextChanged = onReplyTextChanged, + onSendReply = onSendReply, + onSendReaction = onSendReaction, + onShowStoryViews = onShowStoryViews, + onCloseStoryViews = onCloseStoryViews, modifier = Modifier.align(Alignment.BottomCenter) ) } @@ -397,34 +510,38 @@ private fun StoryViewerDialog( } @Composable -private fun StoryViewerContent(story: StoryDto, accessToken: String?) { - val imageAttachment = story.attachments.firstOrNull { it.contentType.startsWith("image/") } - if (imageAttachment != null) { +private fun StoryViewerContent(story: StoryDto, accessToken: String?, mediaIndex: Int) { + val attachment = story.attachments.getOrNull(mediaIndex) ?: story.attachments.firstOrNull() + if (attachment != null && attachment.contentType.startsWith("image/", ignoreCase = true)) { AuthenticatedAsyncImage( - imageUrl = ServerConfig.normalizeMediaUrl(imageAttachment.downloadPath), + imageUrl = ServerConfig.normalizeMediaUrl(attachment.downloadPath), accessToken = accessToken, - contentDescription = imageAttachment.fileName, + contentDescription = attachment.fileName, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) return } + if (attachment != null && attachment.contentType.startsWith("video/", ignoreCase = true)) { + StoryVideoPlayer(attachment = attachment, accessToken = accessToken) + return + } + Box( modifier = Modifier .fillMaxSize() .padding(28.dp), contentAlignment = Alignment.Center ) { - if (story.attachments.isNotEmpty()) { - val first = story.attachments.first() + if (attachment != null) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text("Видео", style = MaterialTheme.typography.headlineMedium, color = Color.White) Text( - first.fileName, + attachment.fileName, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.72f), textAlign = TextAlign.Center, @@ -443,12 +560,58 @@ private fun StoryViewerContent(story: StoryDto, accessToken: String?) { } } +@Composable +private fun StoryVideoPlayer(attachment: AttachmentDto, accessToken: String?) { + val context = LocalContext.current + val videoUrl = remember(attachment.downloadPath) { + ServerConfig.normalizeMediaUrl(attachment.downloadPath) + } + if (videoUrl.isNullOrBlank()) { + return + } + + val player = remember(videoUrl, accessToken) { + val dataSourceFactory = DefaultHttpDataSource.Factory().apply { + accessToken?.takeIf { it.isNotBlank() }?.let { token -> + setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token")) + } + } + ExoPlayer.Builder(context).build().apply { + repeatMode = Player.REPEAT_MODE_ONE + playWhenReady = true + setMediaSource(ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(videoUrl))) + prepare() + } + } + + DisposableEffect(player) { + onDispose { player.release() } + } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { viewContext -> + PlayerView(viewContext).apply { + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + this.player = player + } + }, + update = { view -> + view.player = player + } + ) +} + @Composable private fun StoryViewerTopBar( story: StoryDto, stories: List, accessToken: String?, isOwnStory: Boolean, + mediaIndex: Int, + mediaCount: Int, + progress: Float, onClose: () -> Unit, onDeleteStory: () -> Unit ) { @@ -459,7 +622,13 @@ private fun StoryViewerTopBar( .padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - StoryProgressSegments(story = story, stories = stories) + StoryProgressSegments( + story = story, + stories = stories, + mediaIndex = mediaIndex, + mediaCount = mediaCount, + progress = progress + ) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -498,41 +667,111 @@ private fun StoryViewerTopBar( } @Composable -private fun StoryProgressSegments(story: StoryDto, stories: List) { +private fun StoryProgressSegments( + story: StoryDto, + stories: List, + mediaIndex: Int, + mediaCount: Int, + progress: Float +) { + if (mediaCount > 1) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) { + repeat(mediaCount.coerceAtMost(10)) { index -> + StoryProgressSegment( + progress = when { + index < mediaIndex -> 1f + index == mediaIndex -> progress + else -> 0f + }, + modifier = Modifier.weight(1f) + ) + } + } + return + } + val selectedIndex = stories.indexOfFirst { it.id == story.id } val firstIndex = if (selectedIndex < 0) 0 else (selectedIndex - 6).coerceAtLeast(0) val segmentStories = stories.drop(firstIndex).take(12).ifEmpty { listOf(story) } + val activeSegmentIndex = segmentStories.indexOfFirst { it.id == story.id }.coerceAtLeast(0) Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) { - segmentStories.forEach { item -> - Box( - modifier = Modifier - .weight(1f) - .height(3.dp) - .clip(RoundedCornerShape(2.dp)) - .background(if (item.id == story.id) Color.White else Color.White.copy(alpha = 0.34f)) + segmentStories.forEachIndexed { index, _ -> + StoryProgressSegment( + progress = when { + index < activeSegmentIndex -> 1f + index == activeSegmentIndex -> progress + else -> 0f + }, + modifier = Modifier.weight(1f) ) } } } @Composable -private fun StoryViewerCaption(story: StoryDto, modifier: Modifier = Modifier) { +private fun StoryProgressSegment(progress: Float, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .height(3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.34f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .height(3.dp) + .background(Color.White) + ) + } +} + +@Composable +private fun StoryViewerBottomBar( + story: StoryDto, + isOwnStory: Boolean, + replyText: String, + isSendingReply: Boolean, + replyErrorMessage: String?, + replySent: Boolean, + storyViews: List, + isLoadingStoryViews: Boolean, + storyViewsErrorMessage: String?, + showStoryViews: Boolean, + accessToken: String?, + onReplyTextChanged: (String) -> Unit, + onSendReply: () -> Unit, + onSendReaction: (String) -> Unit, + onShowStoryViews: () -> Unit, + onCloseStoryViews: () -> Unit, + modifier: Modifier = Modifier +) { Column( modifier = modifier .fillMaxWidth() + .imePadding() .background(Color.Black.copy(alpha = 0.54f)) .padding(horizontal = 18.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + verticalArrangement = Arrangement.spacedBy(10.dp) ) { if (story.attachments.isNotEmpty() && story.text.isNotBlank()) { Text(story.text, style = MaterialTheme.typography.bodyLarge, color = Color.White) } Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "${story.viewCount} просмотров", - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.72f) - ) + if (isOwnStory) { + TextButton(onClick = if (showStoryViews) onCloseStoryViews else onShowStoryViews) { + Text( + if (showStoryViews) "Скрыть просмотры" else "${story.viewCount} просмотров", + style = MaterialTheme.typography.bodySmall, + color = Color.White + ) + } + } else { + Text( + "${story.viewCount} просмотров", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.72f) + ) + } if (story.hasProtectedContent) { Spacer(modifier = Modifier.width(10.dp)) Text( @@ -542,6 +781,204 @@ private fun StoryViewerCaption(story: StoryDto, modifier: Modifier = Modifier) { ) } } + if (isOwnStory && showStoryViews) { + StoryViewsPanel( + views = storyViews, + isLoading = isLoadingStoryViews, + errorMessage = storyViewsErrorMessage, + accessToken = accessToken + ) + } + if (!isOwnStory) { + StoryReplyComposer( + replyText = replyText, + isSendingReply = isSendingReply, + replySent = replySent, + replyErrorMessage = replyErrorMessage, + onReplyTextChanged = onReplyTextChanged, + onSendReply = onSendReply, + onSendReaction = onSendReaction + ) + } + } +} + +@Composable +private fun StoryReplyComposer( + replyText: String, + isSendingReply: Boolean, + replySent: Boolean, + replyErrorMessage: String?, + onReplyTextChanged: (String) -> Unit, + onSendReply: () -> Unit, + onSendReaction: (String) -> Unit +) { + val canSend = replyText.isNotBlank() && !isSendingReply + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + StoryQuickReactionRow( + enabled = !isSendingReply, + onSendReaction = onSendReaction + ) + Surface( + color = Color.White.copy(alpha = 0.14f), + shape = RoundedCornerShape(24.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = replyText, + onValueChange = onReplyTextChanged, + enabled = !isSendingReply, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { if (canSend) onSendReply() }), + modifier = Modifier.weight(1f), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = Color.White), + decorationBox = { inner -> + if (replyText.isBlank()) { + Text( + "Сообщение", + color = Color.White.copy(alpha = 0.62f), + style = MaterialTheme.typography.bodyLarge + ) + } + inner() + } + ) + IconButton( + onClick = onSendReply, + enabled = canSend + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Send, + contentDescription = "Отправить", + tint = if (canSend) Color.White else Color.White.copy(alpha = 0.34f) + ) + } + } + } + replyErrorMessage?.let { message -> + Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + if (replySent) { + Text("Отправлено", style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.72f)) + } + } +} + +@Composable +private fun StoryQuickReactionRow( + enabled: Boolean, + onSendReaction: (String) -> Unit +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StoryQuickReactions.forEach { emoji -> + Surface( + color = Color.White.copy(alpha = if (enabled) 0.16f else 0.08f), + shape = CircleShape, + modifier = Modifier + .size(40.dp) + .clickable(enabled = enabled) { onSendReaction(emoji) } + ) { + Box(contentAlignment = Alignment.Center) { + Text(emoji, style = MaterialTheme.typography.titleMedium) + } + } + } + } +} + +private val StoryQuickReactions = listOf("❤️", "👍", "🔥", "👏", "😂", "😮") + +@Composable +private fun StoryViewsPanel( + views: List, + isLoading: Boolean, + errorMessage: String?, + accessToken: String? +) { + Surface( + color = Color.White.copy(alpha = 0.12f), + shape = RoundedCornerShape(18.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Просмотрели", + style = MaterialTheme.typography.labelLarge, + color = Color.White.copy(alpha = 0.84f) + ) + when { + isLoading -> Text( + text = "Загрузка...", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.72f) + ) + + !errorMessage.isNullOrBlank() -> Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + + views.isEmpty() -> Text( + text = "Пока никто не посмотрел", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.72f) + ) + + else -> { + views.take(8).forEach { view -> + StoryViewRow(view = view, accessToken = accessToken) + } + if (views.size > 8) { + Text( + text = "И ещё ${views.size - 8}", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.72f) + ) + } + } + } + } + } +} + +@Composable +private fun StoryViewRow(view: StoryViewDto, accessToken: String?) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarView( + imageUrl = ServerConfig.normalizeMediaUrl(view.viewer.avatarPath), + title = view.viewer.displayName, + accessToken = accessToken, + size = 34.dp + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = view.viewer.displayName.ifBlank { "@${view.viewer.username}" }, + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = view.viewedAt.shortDateTime(), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.68f) + ) + } } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesViewModel.kt index fe20806c..61d378ed 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/stories/StoriesViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.seven.ikar.kotlin.app.AppContainer import com.seven.ikar.kotlin.core.local.TransferQueueEntity import com.seven.ikar.kotlin.core.model.StoryDto +import com.seven.ikar.kotlin.core.model.StoryViewDto import com.seven.ikar.kotlin.core.model.StoryVisibility import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile import kotlinx.coroutines.flow.MutableStateFlow @@ -43,7 +44,16 @@ data class StoriesUiState( val isImportingMedia: Boolean = false, val errorMessage: String? = null, val createdStoryId: String? = null, - val selectedStory: StoryDto? = null + val selectedStory: StoryDto? = null, + val storyReplyText: String = "", + val isSendingStoryReply: Boolean = false, + val storyReplyErrorMessage: String? = null, + val storyReplySentStoryId: String? = null, + val storyViewsStoryId: String? = null, + val storyViews: List = emptyList(), + val isLoadingStoryViews: Boolean = false, + val storyViewsErrorMessage: String? = null, + val selectedStoryAuthorId: String? = null ) class StoriesViewModel(private val container: AppContainer) : ViewModel() { @@ -54,6 +64,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() { init { attachStoryTransferState() + attachRealtime() refresh() } @@ -81,6 +92,14 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() { _uiState.value = _uiState.value.copy(draftText = value.take(500), errorMessage = null, createdStoryId = null) } + fun updateStoryReplyText(value: String) { + _uiState.value = _uiState.value.copy( + storyReplyText = value.take(1_000), + storyReplyErrorMessage = null, + storyReplySentStoryId = null + ) + } + fun setVisibility(visibility: StoryVisibility) { _uiState.value = _uiState.value.copy(visibility = visibility) } @@ -182,6 +201,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() { isSaving = false, createdStoryId = story.id ) + container.realtimeService.notifyStoriesChangedLocally() onCreated() }.onFailure { error -> _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message) @@ -191,7 +211,18 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() { fun openStory(story: StoryDto) { viewModelScope.launch { - _uiState.value = _uiState.value.copy(selectedStory = story, errorMessage = null) + _uiState.value = _uiState.value.copy( + selectedStory = story, + selectedStoryAuthorId = story.author.id, + errorMessage = null, + storyReplyText = "", + storyReplyErrorMessage = null, + storyReplySentStoryId = null, + storyViewsStoryId = null, + storyViews = emptyList(), + isLoadingStoryViews = false, + storyViewsErrorMessage = null + ) runCatching { container.executeAuthorized { session -> container.apiClient.getStory(session.accessToken, story.id) @@ -199,6 +230,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() { }.onSuccess { viewedStory -> _uiState.value = _uiState.value.copy( selectedStory = viewedStory, + selectedStoryAuthorId = viewedStory.author.id, stories = _uiState.value.stories.map { if (it.id == viewedStory.id) viewedStory else it } ) }.onFailure { error -> @@ -223,8 +255,16 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() { }.onSuccess { storyFromServer -> _uiState.value = _uiState.value.copy( selectedStory = storyFromServer, + selectedStoryAuthorId = storyFromServer.author.id, stories = listOf(storyFromServer) + _uiState.value.stories.filterNot { it.id == storyFromServer.id }, - isLoading = false + isLoading = false, + storyReplyText = "", + storyReplyErrorMessage = null, + storyReplySentStoryId = null, + storyViewsStoryId = null, + storyViews = emptyList(), + isLoadingStoryViews = false, + storyViewsErrorMessage = null ) }.onFailure { error -> _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message) @@ -241,7 +281,112 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() { } fun closeStoryPreview() { - _uiState.value = _uiState.value.copy(selectedStory = null) + _uiState.value = _uiState.value.copy( + selectedStory = null, + selectedStoryAuthorId = null, + storyReplyText = "", + storyReplyErrorMessage = null, + storyReplySentStoryId = null, + storyViewsStoryId = null, + storyViews = emptyList(), + isLoadingStoryViews = false, + storyViewsErrorMessage = null + ) + } + + fun loadStoryViews(story: StoryDto) { + if (story.author.id != _uiState.value.currentUserId || _uiState.value.isLoadingStoryViews) { + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + storyViewsStoryId = story.id, + isLoadingStoryViews = true, + storyViewsErrorMessage = null + ) + runCatching { + container.executeAuthorized { session -> + container.apiClient.getStoryViews(session.accessToken, story.id) + } + }.onSuccess { views -> + _uiState.value = _uiState.value.copy( + storyViews = views, + isLoadingStoryViews = false + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isLoadingStoryViews = false, + storyViewsErrorMessage = error.message ?: "Не удалось загрузить просмотры." + ) + } + } + } + + fun closeStoryViews() { + _uiState.value = _uiState.value.copy( + storyViewsStoryId = null, + storyViews = emptyList(), + isLoadingStoryViews = false, + storyViewsErrorMessage = null + ) + } + + fun sendStoryReply(story: StoryDto) { + val state = _uiState.value + val text = state.storyReplyText.trim() + if (text.isBlank() || state.isSendingStoryReply) { + return + } + + sendStoryMessage(story, text, clearDraft = true) + } + + fun sendStoryReaction(story: StoryDto, emoji: String) { + val text = emoji.trim().take(16) + if (text.isBlank() || _uiState.value.isSendingStoryReply) { + return + } + + sendStoryMessage(story, text, clearDraft = false) + } + + private fun sendStoryMessage(story: StoryDto, text: String, clearDraft: Boolean) { + val state = _uiState.value + if (story.author.id == state.currentUserId) { + _uiState.value = state.copy(storyReplyErrorMessage = "Нельзя отправить ответ себе.") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isSendingStoryReply = true, + storyReplyErrorMessage = null, + storyReplySentStoryId = null + ) + runCatching { + container.executeAuthorized { session -> + val chat = container.apiClient.createDirectChat(session.accessToken, story.author.id) + container.apiClient.sendMessage( + chatId = chat.id, + text = text, + accessToken = session.accessToken, + storyReplyStoryId = story.id + ) + } + }.onSuccess { + _uiState.value = _uiState.value.copy( + storyReplyText = if (clearDraft) "" else _uiState.value.storyReplyText, + isSendingStoryReply = false, + storyReplySentStoryId = story.id + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isSendingStoryReply = false, + storyReplyErrorMessage = error.message ?: "Не удалось отправить сообщение." + ) + } + } } fun deleteStory(story: StoryDto) { @@ -253,6 +398,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() { stories = _uiState.value.stories.filterNot { it.id == story.id }, selectedStory = _uiState.value.selectedStory?.takeUnless { it.id == story.id } ) + container.realtimeService.notifyStoriesChangedLocally() }.onFailure { error -> _uiState.value = _uiState.value.copy(errorMessage = error.message) } @@ -286,10 +432,21 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() { } } + private fun attachRealtime() { + viewModelScope.launch { + container.realtimeService.storiesChanged.collect { + refresh() + } + } + } + private fun openRelativeStory(offset: Int) { val state = _uiState.value val selected = state.selectedStory ?: return - val stories = state.stories + val stories = state.selectedStoryAuthorId + ?.let { authorId -> state.stories.filter { it.author.id == authorId } } + ?.takeIf { it.isNotEmpty() } + ?: state.stories if (stories.isEmpty()) { return } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/AttachmentUploadWorker.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/AttachmentUploadWorker.kt index 687a440d..15f02cf9 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/AttachmentUploadWorker.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/AttachmentUploadWorker.kt @@ -113,14 +113,16 @@ class AttachmentUploadWorker( ttlSeconds = first.ttlSeconds ?: 86_400, files = files ) - } else if (files.size == 1) { - container.apiClient.uploadAttachment( - accessToken = session.accessToken, - chatId = chatId!!, - text = first.text, - replyToMessageId = first.replyToMessageId, - file = files.first() - ) + } else if (files.size == 1 || !files.all(::isMediaAlbumFile)) { + files.forEachIndexed { index, file -> + container.apiClient.uploadAttachment( + accessToken = session.accessToken, + chatId = chatId!!, + text = first.text.takeIf { index == 0 }, + replyToMessageId = first.replyToMessageId.takeIf { index == 0 }, + file = file + ) + } } else { container.apiClient.uploadAttachmentAlbum( accessToken = session.accessToken, @@ -134,6 +136,9 @@ class AttachmentUploadWorker( }.fold( onSuccess = { dao.updateGroupStatus(groupId, TransferQueueStatus.Completed, 100, null, System.currentTimeMillis()) + if (isStoryUpload) { + container.realtimeService.notifyStoriesChangedLocally() + } Result.success() }, onFailure = { error -> @@ -155,6 +160,23 @@ class AttachmentUploadWorker( } } +private fun isMediaAlbumFile(file: UploadFileSource): Boolean { + val contentType = file.contentType + ?.substringBefore(';') + ?.trim() + ?.lowercase() + if (contentType != null && (contentType.startsWith("image/") || contentType.startsWith("video/"))) { + return true + } + + val extension = file.fileName + .substringBefore('?') + .substringBefore('#') + .substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + return extension in setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "mp4", "m4v", "mov", "webm", "mkv") +} + private fun String?.toStoryVisibility(): StoryVisibility = runCatching { StoryVisibility.valueOf(this ?: StoryVisibility.Everyone.name) } .getOrDefault(StoryVisibility.Everyone) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateScreen.kt index 246c206e..c75991dd 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateScreen.kt @@ -111,7 +111,7 @@ fun AppUpdateScreen( state.latestManifest?.let { manifest -> UpdateFieldRow( title = "Последний релиз Argus", - value = "${manifest.release.version} (${manifest.release.androidVersionCode ?: "?"})" + value = "${manifest.release.resolvedVersionName} (${manifest.release.resolvedVersionCode ?: "?"})" ) } } @@ -140,7 +140,7 @@ fun AppUpdateScreen( Spacer(modifier = Modifier.height(8.dp)) UpdateFieldRow( title = "Версия", - value = "${update.manifest.release.version} (${update.manifest.release.androidVersionCode ?: "?"})" + value = "${update.manifest.release.resolvedVersionName} (${update.manifest.release.resolvedVersionCode ?: "?"})" ) UpdateFieldRow( title = "Размер APK", diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateViewModel.kt index 0b4eaad2..63ad5c61 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/update/AppUpdateViewModel.kt @@ -126,7 +126,7 @@ class AppUpdateViewModel(private val container: AppContainer) : ViewModel() { availableUpdate = result.update, latestManifest = manifest, statusTitle = "Проверка проведена", - statusDetail = "Обновления есть: версия ${manifest.release.version} (${manifest.release.androidVersionCode ?: "?"})." + statusDetail = "Обновления есть: версия ${manifest.release.resolvedVersionName} (${manifest.release.resolvedVersionCode ?: "?"})." ) } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt index 2a85bbcf..41d8a133 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.platform.LocalContext import coil.compose.AsyncImage import coil.request.CachePolicy import coil.request.ImageRequest +import java.security.MessageDigest @Composable fun AuthenticatedAsyncImage( @@ -24,9 +25,11 @@ fun AuthenticatedAsyncImage( val resolvedAccessToken = accessToken ?: LocalIkarAccessToken.current val context = LocalContext.current val request = remember(imageUrl, resolvedAccessToken) { + val diskCacheKey = buildAuthenticatedDiskCacheKey(imageUrl, resolvedAccessToken) ImageRequest.Builder(context) .data(imageUrl) - .diskCacheKey(imageUrl) + .diskCacheKey(diskCacheKey) + .memoryCacheKey(diskCacheKey) .diskCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED) .apply { @@ -45,3 +48,14 @@ fun AuthenticatedAsyncImage( contentScale = contentScale ) } + +private fun buildAuthenticatedDiskCacheKey(imageUrl: String, accessToken: String?): String { + if (accessToken.isNullOrBlank()) { + return imageUrl + } + + val digest = MessageDigest.getInstance("SHA-256") + .digest(accessToken.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { byte -> "%02x".format(byte) } + return "$imageUrl#auth:$digest" +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/ChatWallpapers.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/ChatWallpapers.kt index c0bdd3fd..1186c2a5 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/ChatWallpapers.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/ChatWallpapers.kt @@ -1,5 +1,10 @@ package com.seven.ikar.kotlin.ui +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight @@ -15,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import com.seven.ikar.kotlin.core.settings.DEFAULT_CHAT_WALLPAPER_PRESET_ID @@ -166,6 +172,7 @@ object ChatWallpaperCatalog { @Composable fun ChatWallpaperBackground( snapshot: ChatSettingsSnapshot, + animated: Boolean = false, modifier: Modifier = Modifier ) { Box(modifier = modifier) { @@ -188,6 +195,7 @@ fun ChatWallpaperBackground( } else { PresetWallpaperBackground( preset = ChatWallpaperCatalog.resolvePreset(snapshot.selectedWallpaperPresetId), + animated = animated, modifier = Modifier.fillMaxSize() ) } @@ -197,8 +205,10 @@ fun ChatWallpaperBackground( @Composable fun PresetWallpaperBackground( preset: ChatWallpaperPreset, + animated: Boolean = false, modifier: Modifier = Modifier ) { + val motion = wallpaperMotion(animated) Box( modifier = modifier.background( Brush.linearGradient(colors = preset.gradientColors) @@ -210,6 +220,7 @@ fun PresetWallpaperBackground( modifier = Modifier .align(Alignment.TopEnd) .offset(x = 36.dp, y = (-18).dp) + .wallpaperMotion(motion, x = 10f, y = -6f) .size(132.dp), color = preset.accentPrimary ) @@ -217,6 +228,7 @@ fun PresetWallpaperBackground( modifier = Modifier .align(Alignment.BottomStart) .offset(x = (-28).dp, y = 24.dp) + .wallpaperMotion(motion, x = -8f, y = 7f) .size(156.dp), color = preset.accentSecondary ) @@ -227,6 +239,7 @@ fun PresetWallpaperBackground( modifier = Modifier .align(Alignment.TopStart) .offset(x = (-40).dp, y = (-24).dp) + .wallpaperMotion(motion, x = -7f, y = 8f) .size(150.dp), color = preset.accentPrimary ) @@ -234,6 +247,7 @@ fun PresetWallpaperBackground( modifier = Modifier .align(Alignment.CenterEnd) .offset(x = 28.dp) + .wallpaperMotion(motion, x = 9f, y = 4f) .size(124.dp), color = preset.accentSecondary ) @@ -241,6 +255,7 @@ fun PresetWallpaperBackground( modifier = Modifier .align(Alignment.BottomCenter) .offset(y = 34.dp) + .wallpaperMotion(motion, x = 5f, y = -7f) .size(108.dp), color = preset.accentPrimary.copy(alpha = 0.45f) ) @@ -251,6 +266,7 @@ fun PresetWallpaperBackground( modifier = Modifier .align(Alignment.TopStart) .offset(x = (-18).dp, y = 22.dp) + .wallpaperMotion(motion, x = 8f, y = 5f) .fillMaxWidth(0.9f) .height(64.dp), color = preset.accentPrimary @@ -259,6 +275,7 @@ fun PresetWallpaperBackground( modifier = Modifier .align(Alignment.BottomEnd) .offset(x = 22.dp, y = (-14).dp) + .wallpaperMotion(motion, x = -8f, y = -4f) .fillMaxWidth(0.76f) .height(56.dp), color = preset.accentSecondary @@ -268,6 +285,35 @@ fun PresetWallpaperBackground( } } +@Composable +private fun wallpaperMotion(animated: Boolean): Float { + if (!animated) { + return 0f + } + + val transition = rememberInfiniteTransition(label = "chat-wallpaper-motion") + val motion = transition.animateFloat( + initialValue = -1f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 7000), + repeatMode = RepeatMode.Reverse + ), + label = "chat-wallpaper-motion-progress" + ) + return motion.value +} + +private fun Modifier.wallpaperMotion(progress: Float, x: Float, y: Float): Modifier = + if (progress == 0f) { + this + } else { + graphicsLayer( + translationX = progress * x, + translationY = progress * y + ) + } + @Composable private fun AccentCircle( modifier: Modifier, diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt index d310f5e4..2ca6ad93 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt @@ -338,8 +338,19 @@ fun IkarSettingRow( content = leading ) Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(title, style = MaterialTheme.typography.titleLarge) - Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } trailing?.invoke() } @@ -371,8 +382,19 @@ fun IkarToggleRow( ) } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(title, style = MaterialTheme.typography.titleLarge) - Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } Switch( checked = checked, @@ -417,8 +439,19 @@ fun IkarValueRow( Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center, content = leading) } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(title, style = MaterialTheme.typography.titleLarge) - Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } Text( value, diff --git a/src/Ikar.AndroidKotlin/scripts/prepare-argus-publication.ps1 b/src/Ikar.AndroidKotlin/scripts/prepare-argus-publication.ps1 index b820a608..bb754604 100644 --- a/src/Ikar.AndroidKotlin/scripts/prepare-argus-publication.ps1 +++ b/src/Ikar.AndroidKotlin/scripts/prepare-argus-publication.ps1 @@ -7,7 +7,7 @@ param( [string]$Channel = 'stable', [string]$Platform = 'android', [string]$Notes, - [string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/Massenger.git', + [string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/Ikar.git', [string]$HomepageUrl = 'https://ikar.kusoft.xyz', [switch]$Unlisted, [switch]$SkipSigningInitialization diff --git a/src/Ikar.AndroidKotlin/scripts/publish-release.ps1 b/src/Ikar.AndroidKotlin/scripts/publish-release.ps1 index 42e3d6b2..8d0e2887 100644 --- a/src/Ikar.AndroidKotlin/scripts/publish-release.ps1 +++ b/src/Ikar.AndroidKotlin/scripts/publish-release.ps1 @@ -1,10 +1,16 @@ param( - [string]$BaseUrl = 'https://argus.kusoft.xyz', + [Alias('ServerHost')] + [string]$SshHost = '192.168.0.185', + [Alias('Username')] + [string]$SshUsername = 'sevenhill', + [Alias('Password')] [Parameter(Mandatory = $true)] - [string]$Username, - [Parameter(Mandatory = $true)] - [string]$Password, + [string]$SshPassword, + [Alias('BaseUrl')] + [string]$PublicBaseUrl = 'https://argus.kusoft.xyz', + [string]$ArgusDataPath = '/srv/argus-data', [Parameter(Mandatory = $true)] + [ValidatePattern('^[a-z0-9][a-z0-9-]{0,99}$')] [string]$Slug, [Parameter(Mandatory = $true)] [string]$Name, @@ -12,13 +18,11 @@ param( [string]$Summary, [Parameter(Mandatory = $true)] [string]$Description, - [Parameter(Mandatory = $true)] [string]$AndroidPackageName, [Parameter(Mandatory = $true)] [string]$Version, - [Parameter(Mandatory = $true)] - [ValidateRange(1, [int]::MaxValue)] - [int]$AndroidVersionCode, + [ValidateRange(0, [int]::MaxValue)] + [int]$AndroidVersionCode = 0, [Parameter(Mandatory = $true)] [string]$PackagePath, [string]$Channel = 'stable', @@ -31,111 +35,380 @@ param( ) $ErrorActionPreference = 'Stop' -Add-Type -AssemblyName System.Net.Http - -function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) { - if ($null -eq $Response.Content) { - return '' - } - - return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult() -} - -function Ensure-Success( - [System.Net.Http.HttpResponseMessage]$Response, - [string]$Operation -) { - if ($Response.IsSuccessStatusCode) { - return - } - - $body = Read-HttpBody $Response - throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body" -} - -function New-JsonContent([string]$Json) { - return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json') -} $resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path -if (-not (Test-Path $resolvedPackagePath)) { - throw "Package file was not found: $resolvedPackagePath" +$packageItem = Get-Item -LiteralPath $resolvedPackagePath +if ($packageItem.Length -le 0) { + throw "Package file is empty: $resolvedPackagePath" } -$baseUri = $BaseUrl.TrimEnd('/') -$cookieContainer = [System.Net.CookieContainer]::new() -$handler = [System.Net.Http.HttpClientHandler]::new() -$handler.CookieContainer = $cookieContainer -$handler.UseCookies = $true -$client = [System.Net.Http.HttpClient]::new($handler) -$client.Timeout = [TimeSpan]::FromMinutes(15) +$baseUrl = $PublicBaseUrl.TrimEnd('/') +$manifestUrl = "{0}/api/apps/{1}/manifest?platform={2}&channel={3}" -f ` + $baseUrl, + [uri]::EscapeDataString($Slug), + [uri]::EscapeDataString($Platform), + [uri]::EscapeDataString($Channel) + +$payload = [ordered]@{ + sshHost = $SshHost + sshUsername = $SshUsername + argusDataPath = $ArgusDataPath + publicBaseUrl = $baseUrl + packagePath = $resolvedPackagePath + slug = $Slug + name = $Name + summary = $Summary + description = $Description + repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl.Trim() } + homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl.Trim() } + isListed = (-not $Unlisted) + version = $Version + channel = $Channel + platform = $Platform + packageKind = $PackageKind + notes = if ([string]::IsNullOrWhiteSpace($Notes)) { $null } else { $Notes } +} | ConvertTo-Json -Depth 6 -Compress + +$env:IKAR_ARGUS_PUBLISH_PAYLOAD = $payload +$env:IKAR_ARGUS_PUBLISH_SSH_PASSWORD = $SshPassword + +$pythonScript = @' +import hashlib +import json +import mimetypes +import os +import posixpath +import re +import shutil +import sqlite3 +import sys +import uuid +from datetime import datetime, timezone +from pathlib import Path + +try: + import paramiko +except ImportError as exc: + raise SystemExit("Python package 'paramiko' is required for SSH publication.") from exc + +payload = json.loads(os.environ["IKAR_ARGUS_PUBLISH_PAYLOAD"]) +ssh_password = os.environ["IKAR_ARGUS_PUBLISH_SSH_PASSWORD"] + +host = payload["sshHost"] +username = payload["sshUsername"] +local_package = Path(payload["packagePath"]).resolve() +remote_upload_dir = f"/home/{username}/.argus-upload/{uuid.uuid4().hex}" +remote_package = posixpath.join(remote_upload_dir, local_package.name) + + +def ensure_remote_dir(sftp, path): + current = "/" + for part in [p for p in path.strip("/").split("/") if p]: + current = posixpath.join(current, part) + try: + sftp.stat(current) + except FileNotFoundError: + sftp.mkdir(current) + + +def cleanup_upload(client): + try: + sftp = client.open_sftp() + try: + sftp.remove(remote_package) + except FileNotFoundError: + pass + try: + sftp.rmdir(remote_upload_dir) + except OSError: + pass + sftp.close() + except Exception: + pass + + +remote_payload = dict(payload) +remote_payload["sourcePath"] = remote_package + +remote_body = r""" +source = Path(PAYLOAD["sourcePath"]).resolve() +data_root = Path(PAYLOAD.get("argusDataPath", "/srv/argus-data")).resolve() +db_path = data_root / "argus.db" +packages_root = data_root / "Packages" + +slug = PAYLOAD["slug"].strip() +name = PAYLOAD["name"].strip() +summary = PAYLOAD["summary"].strip() +description = PAYLOAD["description"].strip() +repository_url = (PAYLOAD.get("repositoryUrl") or "").strip() or None +homepage_url = (PAYLOAD.get("homepageUrl") or "").strip() or None +is_listed = 1 if PAYLOAD.get("isListed", True) else 0 + +version = PAYLOAD["version"].strip() +channel = (PAYLOAD.get("channel") or "stable").strip().lower() +platform = (PAYLOAD.get("platform") or "generic").strip().lower() +package_kind = (PAYLOAD.get("packageKind") or "binary").strip().lower() +notes = (PAYLOAD.get("notes") or "").strip() or None + +if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug): + raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.") +if not source.is_file(): + raise SystemExit(f"SOURCE_FILE does not exist: {source}") +if not db_path.is_file(): + raise SystemExit(f"Argus database does not exist: {db_path}") +for key, value in { + "ARGUS_NAME": name, + "ARGUS_SUMMARY": summary, + "ARGUS_DESCRIPTION": description, + "ARGUS_VERSION": version, +}.items(): + if not value: + raise SystemExit(f"{key} is required.") + +release_id = str(uuid.uuid4()).upper() +now = datetime.now(timezone.utc).isoformat(timespec="microseconds") +safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release" +extension = source.suffix or ".bin" +stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}" +stored_relative_path = f"{slug}/{stored_name}" +target_dir = packages_root / slug +target_path = target_dir / stored_name + +target_dir.mkdir(parents=True, exist_ok=True) +shutil.copy2(source, target_path) + +size_bytes = target_path.stat().st_size +sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest() +content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream" +if source.suffix.lower() == ".apk": + content_type = "application/vnd.android.package-archive" + +conn = sqlite3.connect(db_path) +try: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("BEGIN") + + row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone() + if row is None: + app_id = str(uuid.uuid4()).upper() + conn.execute( + ''' + INSERT INTO "Apps" + ("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', + (app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now), + ) + else: + app_id = row[0] + conn.execute( + ''' + UPDATE "Apps" + SET "Name" = ?, + "Summary" = ?, + "Description" = ?, + "RepositoryUrl" = ?, + "HomepageUrl" = ?, + "IsListed" = ?, + "UpdatedAt" = ? + WHERE "Id" = ? + ''', + (name, summary, description, repository_url, homepage_url, is_listed, now, app_id), + ) + + duplicate = conn.execute( + ''' + SELECT "Id" + FROM "Releases" + WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ? + ''', + (app_id, version, channel, platform), + ).fetchone() + if duplicate is not None: + raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.") + + conn.execute( + ''' + INSERT INTO "Releases" + ("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind", + "OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes", + "Sha256", "Notes", "PublishedAt") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', + ( + release_id, + app_id, + version, + channel, + platform, + package_kind, + source.name, + stored_relative_path, + content_type, + size_bytes, + sha256, + notes, + now, + ), + ) + + conn.commit() +except Exception: + conn.rollback() + try: + target_path.unlink() + except FileNotFoundError: + pass + raise +finally: + conn.close() + +print(json.dumps({ + "releaseId": release_id, + "slug": slug, + "version": version, + "channel": channel, + "platform": platform, + "storedRelativePath": stored_relative_path, + "sizeBytes": size_bytes, + "sha256": sha256, + "manifestUrl": f"{PAYLOAD['publicBaseUrl']}/api/apps/{slug}/manifest?platform={platform}&channel={channel}", +}, ensure_ascii=False)) +""" + +remote_script = ( + "import json\n" + "PAYLOAD = json.loads(" + json.dumps(json.dumps(remote_payload, ensure_ascii=False), ensure_ascii=False) + ")\n" + "import hashlib\n" + "import mimetypes\n" + "import re\n" + "import shutil\n" + "import sqlite3\n" + "import uuid\n" + "from datetime import datetime, timezone\n" + "from pathlib import Path\n" + + remote_body +) + +client = paramiko.SSHClient() +client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +client.connect(host, username=username, password=ssh_password, timeout=15) + +try: + sftp = client.open_sftp() + ensure_remote_dir(sftp, remote_upload_dir) + sftp.put(str(local_package), remote_package) + sftp.close() + + stdin, stdout, stderr = client.exec_command("sudo -n true", timeout=30) + stdout.read() + stderr.read() + can_sudo_without_password = stdout.channel.recv_exit_status() == 0 + + if can_sudo_without_password: + command = "sudo -n python3 -" + input_text = remote_script + else: + command = "sudo -S -p '' python3 -" + input_text = ssh_password + "\n" + remote_script + + stdin, stdout, stderr = client.exec_command(command, timeout=900) + stdin.write(input_text) + stdin.channel.shutdown_write() + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0: + raise RuntimeError( + f"Remote Argus publication failed with exit status {exit_status}\n" + f"STDOUT:\n{out}\nSTDERR:\n{err}" + ) + print(out.strip()) +finally: + cleanup_upload(client) + client.close() +'@ try { - $loginPayload = @{ - username = $Username - password = $Password - } | ConvertTo-Json -Compress - - $loginResponse = $client.PostAsync( - "$baseUri/api/admin/session/login", - (New-JsonContent $loginPayload) - ).GetAwaiter().GetResult() - Ensure-Success $loginResponse 'Argus login' - - $appPayload = @{ - name = $Name - summary = $Summary - description = $Description - androidPackageName = $AndroidPackageName - repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl } - homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl } - isListed = (-not $Unlisted) - } | ConvertTo-Json -Compress - - $appResponse = $client.PutAsync( - "$baseUri/api/admin/apps/$Slug", - (New-JsonContent $appPayload) - ).GetAwaiter().GetResult() - Ensure-Success $appResponse 'Argus app metadata update' - - $form = [System.Net.Http.MultipartFormDataContent]::new() - $fields = [ordered]@{ - Version = $Version - Channel = $Channel - Platform = $Platform - PackageKind = $PackageKind - AndroidVersionCode = [string]$AndroidVersionCode - } - - foreach ($entry in $fields.GetEnumerator()) { - $form.Add([System.Net.Http.StringContent]::new($entry.Value, [System.Text.Encoding]::UTF8), $entry.Key) - } - - if (-not [string]::IsNullOrWhiteSpace($Notes)) { - $form.Add([System.Net.Http.StringContent]::new($Notes, [System.Text.Encoding]::UTF8), 'Notes') - } - - $fileStream = [System.IO.File]::OpenRead($resolvedPackagePath) - try { - $fileContent = [System.Net.Http.StreamContent]::new($fileStream) - $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive') - $form.Add($fileContent, 'Package', [System.IO.Path]::GetFileName($resolvedPackagePath)) - - $releaseResponse = $client.PostAsync( - "$baseUri/api/admin/apps/$Slug/releases", - $form - ).GetAwaiter().GetResult() - Ensure-Success $releaseResponse 'Argus release upload' - - $releaseBody = Read-HttpBody $releaseResponse - if (-not [string]::IsNullOrWhiteSpace($releaseBody)) { - Write-Output $releaseBody - } - } finally { - $fileStream.Dispose() - $form.Dispose() - } + $publishOutput = $pythonScript | python - + $pythonExitCode = $LASTEXITCODE } finally { - $client.Dispose() - $handler.Dispose() + Remove-Item Env:\IKAR_ARGUS_PUBLISH_PAYLOAD -ErrorAction SilentlyContinue + Remove-Item Env:\IKAR_ARGUS_PUBLISH_SSH_PASSWORD -ErrorAction SilentlyContinue } + +if ($pythonExitCode -ne 0) { + throw "Argus SSH publication failed with exit code $pythonExitCode." +} + +if ($publishOutput) { + $publishOutput | Write-Output +} + +$publishJson = $publishOutput | + Where-Object { $_ -and $_.TrimStart().StartsWith('{') } | + Select-Object -Last 1 +if (-not $publishJson) { + throw 'Argus publication did not return a JSON result.' +} +$publishResult = $publishJson | ConvertFrom-Json + +$manifest = $null +$lastManifestError = $null +for ($attempt = 1; $attempt -le 6; $attempt++) { + try { + $manifest = Invoke-RestMethod -Method Get -Uri $manifestUrl -TimeoutSec 60 + if ($manifest.release.version -eq $Version) { + break + } + } catch { + $lastManifestError = $_ + } + Start-Sleep -Seconds 2 +} + +if ($null -eq $manifest) { + throw "Could not verify Argus public manifest at $manifestUrl. Last error: $lastManifestError" +} +if ($manifest.release.version -ne $Version) { + throw "Argus public manifest is reachable, but latest version is $($manifest.release.version), expected $Version." +} +if ($manifest.release.sha256 -and $publishResult.sha256 -and + -not $manifest.release.sha256.Equals($publishResult.sha256, [System.StringComparison]::OrdinalIgnoreCase) +) { + throw "Argus public manifest SHA-256 does not match the published file." +} + +$downloadPath = [string]$manifest.release.downloadPath +if ([string]::IsNullOrWhiteSpace($downloadPath)) { + throw 'Argus public manifest does not contain release.downloadPath.' +} +$downloadUrl = if ($downloadPath.StartsWith('http://', [System.StringComparison]::OrdinalIgnoreCase) -or + $downloadPath.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase) +) { + $downloadPath +} else { + $baseUrl + '/' + $downloadPath.TrimStart('/') +} + +$downloadResponse = $null +try { + $downloadRequest = [System.Net.HttpWebRequest]::Create($downloadUrl) + $downloadRequest.Method = 'GET' + $downloadRequest.Timeout = 60000 + $downloadRequest.AddRange(0, 0) + $downloadResponse = [System.Net.HttpWebResponse]$downloadRequest.GetResponse() + $downloadStatus = [int]$downloadResponse.StatusCode + if ($downloadStatus -ne 200 -and $downloadStatus -ne 206) { + throw "Argus download endpoint returned HTTP $downloadStatus." + } +} catch { + throw "Could not verify Argus download endpoint at $downloadUrl. $($_.Exception.Message)" +} finally { + if ($null -ne $downloadResponse) { + $downloadResponse.Dispose() + } +} + +Write-Output "Verified manifest: $manifestUrl" +Write-Output "Verified download: $downloadUrl" diff --git a/src/Ikar.Server.Tests/AdminUserDeletionTests.cs b/src/Ikar.Server.Tests/AdminUserDeletionTests.cs new file mode 100644 index 00000000..1b47a6d8 --- /dev/null +++ b/src/Ikar.Server.Tests/AdminUserDeletionTests.cs @@ -0,0 +1,180 @@ +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Admin; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed class AdminUserDeletionTests +{ + [Fact] + public async Task DeleteUserAsync_UserOwnsBot_RemovesOwnerAndBotUser() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + using var tempRoot = new TempDirectory(); + var service = CreateService(dbContext, tempRoot.Path); + + var owner = CreateUser("owner"); + var botUser = CreateUser("helperbot", isBot: true); + var bot = new Bot + { + User = botUser, + OwnerUser = owner, + TokenHash = "bot-token" + }; + var chat = CreateDirectChat(owner, botUser); + chat.Messages.Add(new Message + { + Chat = chat, + Sender = botUser, + Text = "bot history", + SentAt = DateTimeOffset.UtcNow + }); + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + var deleted = await service.DeleteUserAsync(owner.Id, CancellationToken.None); + + Assert.True(deleted); + Assert.False(await dbContext.Users.AnyAsync(x => x.Id == owner.Id)); + Assert.False(await dbContext.Users.AnyAsync(x => x.Id == botUser.Id)); + Assert.False(await dbContext.Bots.AnyAsync(x => x.Id == bot.Id)); + Assert.False(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id)); + } + + [Fact] + public async Task DeleteUserAsync_UserHasRestrictingRows_RemovesRowsWithoutThrowing() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + using var tempRoot = new TempDirectory(); + var service = CreateService(dbContext, tempRoot.Path); + + var user = CreateUser("deleted"); + var peer = CreateUser("peer"); + var chat = new Chat + { + Type = ChatType.Group, + Title = "Group", + CreatedBy = peer, + LastActivityAt = DateTimeOffset.UtcNow + }; + chat.Members.Add(new ChatMember { Chat = chat, User = user, Role = ChatMemberRole.Member }); + chat.Members.Add(new ChatMember { Chat = chat, User = peer, IsOwner = true, Role = ChatMemberRole.Owner }); + var message = new Message + { + Chat = chat, + Sender = user, + Text = "remove me", + SentAt = DateTimeOffset.UtcNow + }; + chat.Messages.Add(message); + var inviteLink = new ChatInviteLink + { + Chat = chat, + CreatedBy = user, + Token = "invite-token" + }; + var topic = new ChatTopic + { + Chat = chat, + CreatedBy = user, + Title = "Topic", + Icon = "chat" + }; + var moderationEntry = new ModerationLogEntry + { + Chat = chat, + ActorUser = user, + TargetType = ModerationLogTargetType.Message, + TargetId = message.Id, + Action = "message.deleted" + }; + dbContext.AddRange(user, peer, chat, inviteLink, topic, moderationEntry); + await dbContext.SaveChangesAsync(); + + var deleted = await service.DeleteUserAsync(user.Id, CancellationToken.None); + + Assert.True(deleted); + Assert.False(await dbContext.Users.AnyAsync(x => x.Id == user.Id)); + Assert.False(await dbContext.Messages.AnyAsync(x => x.Id == message.Id)); + Assert.False(await dbContext.ChatInviteLinks.AnyAsync(x => x.Id == inviteLink.Id)); + Assert.False(await dbContext.ChatTopics.AnyAsync(x => x.Id == topic.Id)); + Assert.False(await dbContext.ModerationLogEntries.AnyAsync(x => x.Id == moderationEntry.Id)); + Assert.True(await dbContext.Users.AnyAsync(x => x.Id == peer.Id)); + Assert.True(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id)); + } + + private static AdminService CreateService(IkarDbContext dbContext, string contentRootPath) + { + var attachmentStorageService = new AttachmentStorageService(new TestWebHostEnvironment(contentRootPath)); + return new AdminService( + dbContext, + new TestWebHostEnvironment(contentRootPath), + new ServerRuntimeState(DateTimeOffset.UtcNow), + attachmentStorageService, + new BotDeletionService(dbContext, attachmentStorageService), + new NoopHubContext()); + } + + private static User CreateUser(string username, bool isBot = false) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test", + IsBot = isBot + }; + + private static Chat CreateDirectChat(User first, User second) + { + var chat = new Chat + { + Type = ChatType.Direct, + CreatedBy = first, + LastActivityAt = DateTimeOffset.UtcNow + }; + chat.Members.Add(new ChatMember { Chat = chat, User = first, IsOwner = true, Role = ChatMemberRole.Owner }); + chat.Members.Add(new ChatMember { Chat = chat, User = second }); + return chat; + } + + private sealed class TempDirectory : IDisposable + { + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ikar-admin-delete-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } + + private sealed class TestWebHostEnvironment(string contentRootPath) : IWebHostEnvironment + { + public string ApplicationName { get; set; } = "Ikar.Server.Tests"; + public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath); + public string ContentRootPath { get; set; } = contentRootPath; + public string EnvironmentName { get; set; } = "Test"; + public string WebRootPath { get; set; } = contentRootPath; + public IFileProvider WebRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath); + } +} diff --git a/src/Ikar.Server.Tests/AuthEmailControllerTests.cs b/src/Ikar.Server.Tests/AuthEmailControllerTests.cs new file mode 100644 index 00000000..5602e23a --- /dev/null +++ b/src/Ikar.Server.Tests/AuthEmailControllerTests.cs @@ -0,0 +1,201 @@ +using Ikar.Server.Controllers; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Tests; + +public sealed class AuthEmailControllerTests +{ + [Fact] + public async Task VerifyEmailCode_NewEmail_CreatesUserAndSession() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var sender = new RecordingEmailCodeSender(); + var challengeStore = new EmailAuthChallengeStore(); + var controller = CreateController(dbContext, challengeStore, sender); + + var challengeResult = await controller.RequestEmailCode( + new RequestEmailCodeRequest("user@yandex.ru", "+10000000001"), + CancellationToken.None); + var challenge = ControllerTestFixture.ValueOf(challengeResult); + + var sessionResult = await controller.VerifyEmailCode( + new VerifyEmailCodeRequest(challenge.ChallengeId, "user@yandex.ru", "+10000000001", sender.LastCode!, "Иван"), + CancellationToken.None); + var session = ControllerTestFixture.ValueOf(sessionResult); + + Assert.Equal("Иван", session.User.DisplayName); + Assert.NotEmpty(session.AccessToken); + Assert.NotEmpty(session.RefreshToken); + Assert.Contains(dbContext.Users, user => user.NormalizedEmail == "USER@YANDEX.RU"); + Assert.Contains(dbContext.Users, user => user.NormalizedPhoneNumber == PhoneNumberNormalizer.Normalize("+10000000001")); + } + + [Fact] + public async Task RequestEmailCode_NewAccountWithUnsupportedEmailDomain_ReturnsValidationProblem() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var sender = new RecordingEmailCodeSender(); + var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender); + + var result = await controller.RequestEmailCode( + new RequestEmailCodeRequest("user@gmail.com", "+10000000009"), + CancellationToken.None); + + var badRequest = Assert.IsType(result.Result); + Assert.Equal(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage, badRequest.Value); + Assert.Null(sender.LastCode); + } + + [Fact] + public async Task VerifyEmailCode_ExistingPhoneOnlyUser_LinksEmailAndCreatesSession() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + const string phoneNumber = "+10000000002"; + var existingUser = new User + { + Username = "legacy", + NormalizedUsername = "LEGACY", + DisplayName = "Legacy", + PasswordHash = "test", + PhoneNumber = phoneNumber, + NormalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber) + }; + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingEmailCodeSender(); + var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender); + + var challengeResult = await controller.RequestEmailCode( + new RequestEmailCodeRequest("legacy@mail.ru", phoneNumber), + CancellationToken.None); + var challenge = ControllerTestFixture.ValueOf(challengeResult); + + var sessionResult = await controller.VerifyEmailCode( + new VerifyEmailCodeRequest(challenge.ChallengeId, "legacy@mail.ru", phoneNumber, sender.LastCode!, null), + CancellationToken.None); + var session = ControllerTestFixture.ValueOf(sessionResult); + + var storedUser = Assert.Single(dbContext.Users, user => user.Id == existingUser.Id); + Assert.Equal(existingUser.Id, session.User.Id); + Assert.Equal("LEGACY@MAIL.RU", storedUser.NormalizedEmail); + Assert.Equal(PhoneNumberNormalizer.Normalize(phoneNumber), storedUser.NormalizedPhoneNumber); + } + + [Fact] + public async Task RequestEmailCode_EmailAndPhoneFromDifferentAccounts_ReturnsConflict() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + dbContext.Users.AddRange( + new User + { + Username = "email-user", + NormalizedUsername = "EMAIL-USER", + DisplayName = "Email", + PasswordHash = "test", + Email = "user@yandex.ru", + NormalizedEmail = "USER@YANDEX.RU", + PhoneNumber = "+10000000003", + NormalizedPhoneNumber = "10000000003" + }, + new User + { + Username = "phone-user", + NormalizedUsername = "PHONE-USER", + DisplayName = "Phone", + PasswordHash = "test", + PhoneNumber = "+10000000004", + NormalizedPhoneNumber = "10000000004" + }); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingEmailCodeSender(); + var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender); + + var result = await controller.RequestEmailCode( + new RequestEmailCodeRequest("user@yandex.ru", "+10000000004"), + CancellationToken.None); + + Assert.IsType(result.Result); + Assert.Null(sender.LastCode); + } + + [Fact] + public async Task VerifyEmailCode_WithDifferentPhoneThanChallenge_ReturnsUnauthorized() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var sender = new RecordingEmailCodeSender(); + var controller = CreateController(dbContext, new EmailAuthChallengeStore(), sender); + + var challengeResult = await controller.RequestEmailCode( + new RequestEmailCodeRequest("user@yandex.ru", "+10000000005"), + CancellationToken.None); + var challenge = ControllerTestFixture.ValueOf(challengeResult); + + var sessionResult = await controller.VerifyEmailCode( + new VerifyEmailCodeRequest(challenge.ChallengeId, "user@yandex.ru", "+10000000006", sender.LastCode!, "Иван"), + CancellationToken.None); + + Assert.IsType(sessionResult.Result); + } + + [Fact] + public async Task RequestPhoneCode_NewPhone_DoesNotRegister() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var controller = CreateController(dbContext, new EmailAuthChallengeStore(), new RecordingEmailCodeSender()); + + var result = await controller.RequestPhoneCode( + new RequestPhoneCodeRequest("+10000009999"), + CancellationToken.None); + + Assert.IsType(result.Result); + } + + private static AuthController CreateController( + IkarDbContext dbContext, + EmailAuthChallengeStore emailChallengeStore, + IEmailCodeSender emailCodeSender) + { + var controller = new AuthController( + dbContext, + new TokenService(dbContext, Options.Create(new JwtOptions())), + new PresenceTracker(), + new PhoneAuthChallengeStore(), + emailChallengeStore, + emailCodeSender); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + return controller; + } + + private sealed class RecordingEmailCodeSender : IEmailCodeSender + { + public string? LastCode { get; private set; } + + public Task SendLoginCodeAsync( + string email, + string code, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default) + { + LastCode = code; + return Task.CompletedTask; + } + } +} diff --git a/src/Ikar.Server.Tests/BotApiControllerTests.cs b/src/Ikar.Server.Tests/BotApiControllerTests.cs new file mode 100644 index 00000000..2ec71222 --- /dev/null +++ b/src/Ikar.Server.Tests/BotApiControllerTests.cs @@ -0,0 +1,790 @@ +using Ikar.Server.Controllers; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.FileProviders; + +namespace Ikar.Server.Tests; + +public sealed class BotApiControllerTests +{ + [Fact] + public async Task SendDocument_DecodesPercentEncodedFileName() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("documentbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream("%PDF-1.3"u8.ToArray()); + var encodedFileName = "Fluke_8508A_" + Uri.EscapeDataString("методика.pdf"); + var file = new FormFile(stream, 0, stream.Length, "file", encodedFileName) + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendDocument( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "", + File = file + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + var attachment = Assert.Single(message.Attachments); + Assert.Equal("Fluke_8508A_методика.pdf", attachment.FileName); + Assert.Equal("application/pdf", attachment.ContentType); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendDocument_SavesOggAsVoiceNoteAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("documentbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream([1, 2, 3, 4]); + var file = new FormFile(stream, 0, stream.Length, "file", "song.ogg") + { + Headers = new HeaderDictionary(), + ContentType = "audio/ogg" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendDocument( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "", + File = file + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + var attachment = Assert.Single(message.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + Assert.Equal("audio/ogg", attachment.ContentType); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendPhoto_SavesImageAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("photobot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream([0xFF, 0xD8, 0xFF, 0xD9]); + var file = new FormFile(stream, 0, stream.Length, "file", "photo.jpg") + { + Headers = new HeaderDictionary(), + ContentType = "image/jpeg" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendPhoto( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "caption", + File = file + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + Assert.Equal("caption", message.Text); + var attachment = Assert.Single(message.Attachments); + Assert.Equal(AttachmentKind.File, attachment.Kind); + Assert.Equal("image/jpeg", attachment.ContentType); + Assert.Equal("photo.jpg", attachment.FileName); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendPhoto_RejectsVideoAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("photobot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream([1, 2, 3, 4]); + var file = new FormFile(stream, 0, stream.Length, "file", "clip.mp4") + { + Headers = new HeaderDictionary(), + ContentType = "video/mp4" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendPhoto( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "", + File = file + }, + CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("sendPhoto accepts only image files.", badRequest.Value); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendVideo_SavesVideoAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("videobot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream([0, 0, 0, 24, 102, 116, 121, 112]); + var file = new FormFile(stream, 0, stream.Length, "file", "clip.mp4") + { + Headers = new HeaderDictionary(), + ContentType = "video/mp4" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendVideo( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "clip", + File = file + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + Assert.Equal("clip", message.Text); + var attachment = Assert.Single(message.Attachments); + Assert.Equal(AttachmentKind.File, attachment.Kind); + Assert.Equal("video/mp4", attachment.ContentType); + Assert.Equal("clip.mp4", attachment.FileName); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendMediaGroup_SavesImageAndVideoAlbum() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("albumbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + var photo = CreateFormFile([0xFF, 0xD8, 0xFF, 0xD9], "photo.jpg", "image/jpeg"); + var video = CreateFormFile([0, 0, 0, 24, 102, 116, 121, 112], "clip.mp4", "video/mp4"); + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendMediaGroup( + token, + new BotSendMediaGroupRequest + { + ChatId = chat.Id, + Text = "album", + Files = [photo, video] + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + Assert.Equal("album", message.Text); + Assert.NotNull(message.MediaAlbumId); + Assert.Equal(2, message.Attachments.Count); + Assert.Equal("photo.jpg", message.Attachments[0].FileName); + Assert.Equal(0, message.Attachments[0].SortOrder); + Assert.Equal("clip.mp4", message.Attachments[1].FileName); + Assert.Equal(1, message.Attachments[1].SortOrder); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendMediaGroup_RejectsNonMediaAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("albumbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + var photo = CreateFormFile([0xFF, 0xD8, 0xFF, 0xD9], "photo.jpg", "image/jpeg"); + var document = CreateFormFile([0x25, 0x50, 0x44, 0x46], "manual.pdf", "application/pdf"); + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendMediaGroup( + token, + new BotSendMediaGroupRequest + { + ChatId = chat.Id, + Files = [photo, document] + }, + CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("sendMediaGroup accepts only image and video files.", badRequest.Value); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendVoice_SavesOctetStreamOggNameAsVoiceNoteAttachment() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("voicebot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var chat = CreateDirectChat(owner, botUser); + + dbContext.AddRange(owner, botUser, bot, chat); + await dbContext.SaveChangesAsync(); + + await using var stream = new MemoryStream([1, 2, 3, 4, 5]); + var file = new FormFile(stream, 0, stream.Length, "file", "Правила ogg") + { + Headers = new HeaderDictionary(), + ContentType = "application/octet-stream" + }; + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendVoice( + token, + new BotSendAttachmentRequest + { + ChatId = chat.Id, + Text = "", + File = file + }, + CancellationToken.None); + + var message = ControllerTestFixture.ValueOf(response); + var attachment = Assert.Single(message.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + Assert.Equal("audio/ogg", attachment.ContentType); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendMessage_ChannelWithLinkedDiscussion_CreatesDiscussionRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("newsbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var group = CreateGroup(owner, botUser, canBotPost: true); + var channel = CreateChannel(owner, botUser, group); + + dbContext.AddRange(owner, botUser, bot, group, channel); + await dbContext.SaveChangesAsync(); + + var controller = CreateController(dbContext, tokenService, tempRoot); + var response = await controller.SendMessage( + token, + new BotSendMessageRequest(channel.Id, "Bot channel post"), + CancellationToken.None); + + var postDto = ControllerTestFixture.ValueOf(response); + Assert.NotNull(postDto.DiscussionMessageId); + + var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); + Assert.Equal(group.Id, discussionRoot.ChatId); + Assert.Equal("Bot channel post", discussionRoot.Text); + Assert.Equal(channel.Title, discussionRoot.ForwardedFromDisplayName); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendVoice_ChannelWithLinkedDiscussion_MirrorsVoiceAttachmentToDiscussionRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("voicebot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var group = CreateGroup(owner, botUser, canBotPost: true); + var channel = CreateChannel(owner, botUser, group); + + dbContext.AddRange(owner, botUser, bot, group, channel); + await dbContext.SaveChangesAsync(); + + var file = CreateFormFile([1, 2, 3, 4], "rules.ogg", "application/octet-stream"); + var controller = CreateController(dbContext, tokenService, tempRoot); + var response = await controller.SendVoice( + token, + new BotSendAttachmentRequest + { + ChatId = channel.Id, + Text = "Rules", + File = file + }, + CancellationToken.None); + + var postDto = ControllerTestFixture.ValueOf(response); + Assert.NotNull(postDto.DiscussionMessageId); + + var discussionRoot = dbContext.Messages + .Include(x => x.Attachments) + .Single(x => x.Id == postDto.DiscussionMessageId); + var mirroredAttachment = Assert.Single(discussionRoot.Attachments); + Assert.Equal(group.Id, discussionRoot.ChatId); + Assert.Equal("Rules", discussionRoot.Text); + Assert.Equal(AttachmentKind.VoiceNote, mirroredAttachment.Kind); + Assert.Equal("audio/ogg", mirroredAttachment.ContentType); + Assert.Equal("rules.ogg", mirroredAttachment.OriginalFileName); + Assert.True(File.Exists(mirroredAttachment.StoragePath)); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task SendMessage_DeniesRestrictedGroupBot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + var botUser = CreateUser("restrictedbot", isBot: true); + var tokenService = new BotTokenService(); + var token = "bot-token"; + var bot = new Bot + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = tokenService.HashToken(token) + }; + var group = CreateGroup(owner, botUser, canBotPost: false); + + dbContext.AddRange(owner, botUser, bot, group); + await dbContext.SaveChangesAsync(); + + var controller = CreateController(dbContext, tokenService, tempRoot); + + var response = await controller.SendMessage( + token, + new BotSendMessageRequest(group.Id, "test"), + CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + private static BotApiController CreateController( + IkarDbContext dbContext, + BotTokenService tokenService, + string contentRootPath) => + new( + dbContext, + new PresenceTracker(), + tokenService, + new BotUpdateService(dbContext), + new NoopHubContext(), + new PushDispatchQueue(), + new AttachmentStorageService(new TestWebHostEnvironment(contentRootPath))); + + private static FormFile CreateFormFile(byte[] content, string fileName, string contentType) + { + var stream = new MemoryStream(content); + return new FormFile(stream, 0, stream.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + private static User CreateUser(string username, bool isBot = false) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test", + IsBot = isBot + }; + + private static Chat CreateChannel(User owner, User botUser, Chat linkedDiscussionChat) + { + var channel = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Channel, + Title = "News", + CreatedById = owner.Id, + CreatedBy = owner, + LinkedDiscussionChatId = linkedDiscussionChat.Id, + CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero) + }; + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPostMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPinMessages = true + }); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = botUser.Id, + User = botUser, + Role = ChatMemberRole.Admin, + CanPostMessages = true + }); + return channel; + } + + private static Chat CreateDirectChat(User first, User second) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Direct, + CreatedById = first.Id, + CreatedBy = first, + CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero) + }; + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); + return chat; + } + + private static Chat CreateGroup(User owner, User botUser, bool canBotPost) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Group, + Title = "Group", + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero) + }; + chat.Members.Add(new ChatMember + { + ChatId = chat.Id, + Chat = chat, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPostMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPinMessages = true + }); + chat.Members.Add(new ChatMember + { + ChatId = chat.Id, + Chat = chat, + UserId = botUser.Id, + User = botUser, + Role = ChatMemberRole.Member, + CanPostMessages = canBotPost + }); + return chat; + } + + private sealed class TestWebHostEnvironment(string contentRootPath) : IWebHostEnvironment + { + public string ApplicationName { get; set; } = "Ikar.Server.Tests"; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + public string WebRootPath { get; set; } = contentRootPath; + public string EnvironmentName { get; set; } = "Development"; + public string ContentRootPath { get; set; } = contentRootPath; + public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath); + } +} diff --git a/src/Ikar.Server.Tests/BotCreatorServiceTests.cs b/src/Ikar.Server.Tests/BotCreatorServiceTests.cs new file mode 100644 index 00000000..6327cddc --- /dev/null +++ b/src/Ikar.Server.Tests/BotCreatorServiceTests.cs @@ -0,0 +1,106 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed class BotCreatorServiceTests +{ + [Fact] + public async Task TryHandleIncomingMessage_RussianCreateCommand_CreatesBot() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var service = CreateService(dbContext); + await service.EnsureCreatorBotAsync(); + + var owner = CreateUser("owner", "Owner"); + dbContext.Users.Add(owner); + await dbContext.SaveChangesAsync(); + + var creatorUser = await dbContext.Users.SingleAsync(x => x.NormalizedUsername == BotCreatorService.CreatorUsername.ToUpperInvariant()); + var chat = new Chat + { + Type = ChatType.Direct, + CreatedById = owner.Id, + LastActivityAt = DateTimeOffset.UtcNow, + Members = + [ + new ChatMember { UserId = owner.Id, IsOwner = true, Role = ChatMemberRole.Owner }, + new ChatMember { UserId = creatorUser.Id } + ] + }; + var message = new Message + { + Chat = chat, + Sender = owner, + Text = "/создать_бота Эхо | echobot | тестовый бот", + SentAt = DateTimeOffset.UtcNow + }; + chat.Messages.Add(message); + dbContext.Chats.Add(chat); + await dbContext.SaveChangesAsync(); + + var handled = await service.TryHandleIncomingMessageAsync(message.Id); + + Assert.True(handled); + var createdBot = await dbContext.Bots + .Include(x => x.User) + .SingleOrDefaultAsync(x => x.User.NormalizedUsername == "ECHOBOT"); + Assert.NotNull(createdBot); + Assert.Equal(owner.Id, createdBot.OwnerUserId); + Assert.Contains(dbContext.Messages, x => x.ChatId == chat.Id && x.SenderId == creatorUser.Id && x.Text.Contains("Токен")); + } + + [Fact] + public async Task UsersSearch_FindsCreatorBot() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var service = CreateService(dbContext); + await service.EnsureCreatorBotAsync(); + + var owner = CreateUser("owner", "Owner"); + dbContext.Users.Add(owner); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateUsersController(dbContext, owner.Id); + var response = await controller.Search("botfatherbot", CancellationToken.None); + + var users = ControllerTestFixture.ValueOf(response); + var creator = Assert.Single(users); + Assert.True(creator.IsBot); + Assert.Equal(BotCreatorService.CreatorUsername, creator.Username); + Assert.Equal("Создатель ботов", creator.DisplayName); + } + + private static BotCreatorService CreateService(Ikar.Server.Data.IkarDbContext dbContext) + { + var pushQueue = new PushDispatchQueue(); + return new BotCreatorService( + dbContext, + new BotTokenService(), + new BotDeletionService(dbContext, ControllerTestFixture.CreateAttachmentStorageService(Path.GetTempPath())), + new PresenceTracker(), + new NoopHubContext(), + pushQueue); + } + + private static User CreateUser(string username, string displayName) + { + var user = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName + }; + user.PasswordHash = new PasswordHasher().HashPassword(user, "password"); + return user; + } +} diff --git a/src/Ikar.Server.Tests/BotUpdateServiceTests.cs b/src/Ikar.Server.Tests/BotUpdateServiceTests.cs new file mode 100644 index 00000000..fcb62da7 --- /dev/null +++ b/src/Ikar.Server.Tests/BotUpdateServiceTests.cs @@ -0,0 +1,141 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Bots; +using Ikar.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Tests; + +public sealed class BotUpdateServiceTests +{ + [Fact] + public async Task EnqueueIncomingMessageAsync_CreatesUpdateForGroupBotMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var botUser = CreateUser("groupbot", isBot: true); + var bot = CreateBot(owner, botUser); + var group = CreateChat(ChatType.Group, owner, botUser, botCanPost: false); + var message = CreateMessage(group, owner, "hello group bot"); + dbContext.AddRange(owner, botUser, bot, group, message); + await dbContext.SaveChangesAsync(); + + var service = new BotUpdateService(dbContext); + await service.EnqueueIncomingMessageAsync(message.Id, CancellationToken.None); + + var update = await dbContext.BotUpdates.SingleAsync(); + Assert.Equal(bot.Id, update.BotId); + Assert.Equal(group.Id, update.ChatId); + Assert.Equal(message.Id, update.MessageId); + } + + [Fact] + public async Task EnqueueIncomingMessageAsync_CreatesUpdateForChannelAdminBotOnly() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var adminBotUser = CreateUser("adminbot", isBot: true); + var subscriberBotUser = CreateUser("subscriberbot", isBot: true); + var adminBot = CreateBot(owner, adminBotUser); + var subscriberBot = CreateBot(owner, subscriberBotUser); + var channel = CreateChat(ChatType.Channel, owner, adminBotUser, botCanPost: true); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = subscriberBotUser.Id, + User = subscriberBotUser, + Role = ChatMemberRole.Member, + CanPostMessages = false + }); + var message = CreateMessage(channel, owner, "channel post"); + dbContext.AddRange(owner, adminBotUser, subscriberBotUser, adminBot, subscriberBot, channel, message); + await dbContext.SaveChangesAsync(); + + var service = new BotUpdateService(dbContext); + await service.EnqueueIncomingMessageAsync(message.Id, CancellationToken.None); + + var update = await dbContext.BotUpdates.SingleAsync(); + Assert.Equal(adminBot.Id, update.BotId); + Assert.DoesNotContain(dbContext.BotUpdates, x => x.BotId == subscriberBot.Id); + } + + private static Bot CreateBot(User owner, User botUser) => + new() + { + Id = Guid.NewGuid(), + UserId = botUser.Id, + User = botUser, + OwnerUserId = owner.Id, + OwnerUser = owner, + TokenHash = $"token-{botUser.Username}", + IsEnabled = true + }; + + private static User CreateUser(string username, bool isBot = false) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test", + IsBot = isBot + }; + + private static Chat CreateChat(ChatType type, User owner, User botUser, bool botCanPost) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = type, + Title = type == ChatType.Channel ? "Channel" : "Group", + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero) + }; + chat.Members.Add(new ChatMember + { + ChatId = chat.Id, + Chat = chat, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPostMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPinMessages = true + }); + chat.Members.Add(new ChatMember + { + ChatId = chat.Id, + Chat = chat, + UserId = botUser.Id, + User = botUser, + Role = botCanPost ? ChatMemberRole.Admin : ChatMemberRole.Member, + CanPostMessages = botCanPost, + CanDeleteMessages = botCanPost, + CanInviteUsers = botCanPost, + CanPinMessages = botCanPost + }); + return chat; + } + + private static Message CreateMessage(Chat chat, User sender, string text) + { + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + SenderId = sender.Id, + Sender = sender, + Text = text, + SentAt = new DateTimeOffset(2026, 6, 1, 12, 5, 0, TimeSpan.Zero) + }; + chat.Messages.Add(message); + return message; + } +} diff --git a/src/Ikar.Server.Tests/BotsControllerTests.cs b/src/Ikar.Server.Tests/BotsControllerTests.cs index 6f295f27..05aacf1e 100644 --- a/src/Ikar.Server.Tests/BotsControllerTests.cs +++ b/src/Ikar.Server.Tests/BotsControllerTests.cs @@ -8,7 +8,29 @@ namespace Ikar.Server.Tests; public sealed class BotsControllerTests { [Fact] - public async Task DeleteBot_RemovesBotProfileAndSafeData_ButKeepsMessageHistory() + public async Task CreateBot_ReturnsGoneBecauseBotsAreCreatedThroughCreatorBot() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var owner = CreateUser("owner"); + dbContext.Users.Add(owner); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateBotsController(dbContext, owner.Id); + var response = controller.CreateBot(new CreateBotRequest("helperbot", "Helper", "test bot")); + + var result = Assert.IsType(response.Result); + Assert.Equal(410, result.StatusCode); + var message = Assert.IsType(result.Value); + Assert.Contains("@botfatherbot", message); + Assert.Contains("/создать_бота", message); + Assert.False(await dbContext.Bots.AnyAsync()); + Assert.False(await dbContext.Users.AnyAsync(x => x.IsBot)); + } + + [Fact] + public async Task DeleteBot_RemovesBotUserAndDirectChatHistory() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); @@ -84,14 +106,9 @@ public sealed class BotsControllerTests Assert.False(await dbContext.UserSessions.AnyAsync(x => x.UserId == botUser.Id)); Assert.False(await dbContext.PushDevices.AnyAsync(x => x.UserId == botUser.Id)); - var reloadedBotUser = await dbContext.Users.SingleAsync(x => x.Id == botUser.Id); - Assert.True(reloadedBotUser.IsBot); - Assert.True(reloadedBotUser.IsBlocked); - Assert.NotNull(reloadedBotUser.BlockedAt); - - var reloadedMessage = await dbContext.Messages.AsNoTracking().SingleAsync(x => x.Id == message.Id); - Assert.Equal(botUser.Id, reloadedMessage.SenderId); - Assert.Equal("history", reloadedMessage.Text); + Assert.False(await dbContext.Users.AnyAsync(x => x.Id == botUser.Id)); + Assert.False(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id)); + Assert.False(await dbContext.Messages.AnyAsync(x => x.Id == message.Id)); } [Fact] diff --git a/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs b/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs index ac521c8e..09d7c530 100644 --- a/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs +++ b/src/Ikar.Server.Tests/ChannelDtoMapperTests.cs @@ -49,6 +49,72 @@ public sealed class ChannelDtoMapperTests Assert.Equal(owner.Id, visibleMember.User.Id); } + [Fact] + public void PublicChannelPreview_HidesContactOnlyProfileFields_ForNonMember() + { + var owner = CreateUser("owner", "Owner"); + owner.PhoneNumber = "+10000000001"; + owner.AvatarStoragePath = "owner-avatar.png"; + owner.PrivacySettings = new UserPrivacySettings + { + UserId = owner.Id, + User = owner, + PhoneNumberVisibility = "contacts", + LastSeenVisibility = "contacts", + ProfilePhotoVisibility = "contacts" + }; + owner.Sessions.Add(new UserSession + { + Id = Guid.NewGuid(), + UserId = owner.Id, + User = owner, + LastSeenAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }); + var viewer = CreateUser("viewer", "Viewer"); + var channel = CreateChannel(owner); + channel.PublicUsername = "ikar_updates"; + + var details = channel.ToDetailsDto(viewer.Id, new PresenceTracker()); + + var visibleMember = Assert.Single(details.Members!); + Assert.Equal(owner.Id, visibleMember.User.Id); + Assert.Null(visibleMember.User.PhoneNumber); + Assert.Null(visibleMember.User.LastSeenAt); + Assert.Null(visibleMember.User.AvatarPath); + } + + [Fact] + public void ChannelDetails_ShowsContactOnlyProfileFields_ForSubscriber() + { + var owner = CreateUser("owner", "Owner"); + owner.PhoneNumber = "+10000000001"; + owner.AvatarStoragePath = "owner-avatar.png"; + owner.PrivacySettings = new UserPrivacySettings + { + UserId = owner.Id, + User = owner, + PhoneNumberVisibility = "contacts", + LastSeenVisibility = "contacts", + ProfilePhotoVisibility = "contacts" + }; + owner.Sessions.Add(new UserSession + { + Id = Guid.NewGuid(), + UserId = owner.Id, + User = owner, + LastSeenAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }); + var subscriber = CreateUser("subscriber", "Subscriber"); + var channel = CreateChannel(owner, subscriber); + + var details = channel.ToDetailsDto(subscriber.Id, new PresenceTracker()); + + var ownerMember = Assert.Single(details.Members!, member => member.User.Id == owner.Id); + Assert.Equal(owner.PhoneNumber, ownerMember.User.PhoneNumber); + Assert.NotNull(ownerMember.User.LastSeenAt); + Assert.NotNull(ownerMember.User.AvatarPath); + } + [Fact] public void ChannelPost_MasksSenderForRegularSubscriber_AndKeepsViewCount() { @@ -83,6 +149,69 @@ public sealed class ChannelDtoMapperTests Assert.Equal(owner.Id, ownerDto.Sender.Id); } + [Fact] + public void MessageAttachment_MapsExplicitKindToVoiceNote() + { + var owner = CreateUser("owner", "Owner"); + var channel = CreateChannel(owner); + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = channel.Id, + Chat = channel, + SenderId = owner.Id, + Sender = owner, + Text = string.Empty, + SentAt = DateTimeOffset.UtcNow + }; + message.Attachments.Add(new MessageAttachment + { + Id = Guid.NewGuid(), + MessageId = message.Id, + Message = message, + OriginalFileName = "VOICE.OGG", + ContentType = "application/ogg", + Kind = AttachmentKind.VoiceNote, + StoragePath = "VOICE.OGG", + FileSizeBytes = 4096 + }); + + var dto = message.ToDto(new PresenceTracker(), owner.Id); + var attachment = Assert.Single(dto.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + } + + [Fact] + public void MessageAttachment_InfersVoiceNoteFromOggLikeFileName() + { + var owner = CreateUser("owner", "Owner"); + var channel = CreateChannel(owner); + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = channel.Id, + Chat = channel, + SenderId = owner.Id, + Sender = owner, + Text = string.Empty, + SentAt = DateTimeOffset.UtcNow + }; + message.Attachments.Add(new MessageAttachment + { + Id = Guid.NewGuid(), + MessageId = message.Id, + Message = message, + OriginalFileName = "Правила ogg", + ContentType = "application/octet-stream", + StoragePath = "rules", + FileSizeBytes = 4096 + }); + + var dto = message.ToDto(new PresenceTracker(), owner.Id); + var attachment = Assert.Single(dto.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + } + private static Chat CreateChannel(User owner, params User[] subscribers) { var channel = new Chat diff --git a/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs b/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs index 0e20cf4d..0d8bc991 100644 --- a/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs +++ b/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs @@ -1,10 +1,73 @@ +using Ikar.Server.Controllers; using Ikar.Server.Data.Entities; using Ikar.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; namespace Ikar.Server.Tests; public sealed class ChannelMembersControllerTests { + [Fact] + public async Task GetPublicChannelByUsername_ReturnsPreviewForNonMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var viewer = CreateUser("viewer"); + var channel = CreateChannel(owner); + channel.PublicUsername = "newsroom"; + dbContext.AddRange(owner, viewer, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.GetPublicChannelByUsername("@newsroom", CancellationToken.None); + + var details = ControllerTestFixture.ValueOf(response); + Assert.Equal(channel.Id, details.Id); + Assert.Equal(ChatType.Channel, details.Type); + Assert.False(details.IsSubscribed); + Assert.True(details.CanSubscribe); + Assert.Equal("newsroom", details.PublicUsername); + } + + [Fact] + public async Task SearchPage_ReturnsPublicChannelForNonMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var viewer = CreateUser("viewer"); + var channel = CreateChannel(owner); + channel.Title = "City News"; + channel.PublicUsername = "city_news"; + channel.Description = "Daily city updates"; + dbContext.AddRange(owner, viewer, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.SearchPage( + q: "city", + filter: null, + type: null, + page: 1, + limit: 20, + date: null, + dateFrom: null, + dateTo: null, + authorId: null, + author: null, + CancellationToken.None); + + var page = ControllerTestFixture.ValueOf(response); + var result = Assert.Single(page.Results, x => x.Chat.Id == channel.Id); + Assert.Null(result.Message); + Assert.Equal(ChatType.Channel, result.Chat.Type); + Assert.Equal("city_news", result.Chat.PublicUsername); + Assert.True(result.Chat.CanSubscribe); + Assert.False(result.Chat.IsSubscribed); + } + [Fact] public async Task AddMembers_CanAddBotAsChannelAdmin() { @@ -36,6 +99,980 @@ public sealed class ChannelMembersControllerTests Assert.Equal(2, details.AdminCount); } + [Fact] + public async Task AddMembers_CanAddMemberToGroup() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var group = CreateGroup(owner); + dbContext.AddRange(owner, member, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.AddMembers( + group.Id, + new AddChatMembersRequest([member.Id]), + CancellationToken.None); + + var details = ControllerTestFixture.ValueOf(response); + var addedMember = Assert.Single(details.Members ?? [], x => x.User.Id == member.Id); + Assert.Equal(ChatMemberRole.Member, addedMember.Role); + Assert.False(addedMember.IsOwner); + Assert.True(addedMember.Permissions.CanPostMessages); + Assert.Equal(2, details.SubscriberCount); + } + + [Fact] + public async Task AddMembers_DeniesWhenCandidateBlockedAdmin() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var group = CreateGroup(owner); + dbContext.AddRange( + owner, + member, + group, + new UserPrivacySettings + { + UserId = member.Id, + User = member, + BlockedUserIds = owner.Id.ToString() + }); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.AddMembers( + group.Id, + new AddChatMembersRequest([member.Id]), + CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == group.Id && x.UserId == member.Id); + } + + [Fact] + public async Task SendMessage_DeniesRestrictedGroupMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var group = CreateGroup(owner); + group.Members.Add(new ChatMember + { + ChatId = group.Id, + Chat = group, + UserId = member.Id, + User = member, + Role = ChatMemberRole.Member, + CanPostMessages = false + }); + dbContext.AddRange(owner, member, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id); + var response = await controller.SendMessage( + group.Id, + new SendMessageRequest("test"), + CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + } + + [Fact] + public async Task RemoveMember_CanRemoveRegularChannelMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.RemoveMember(channel.Id, member.Id, CancellationToken.None); + + var details = ControllerTestFixture.ValueOf(response); + Assert.DoesNotContain(details.Members ?? [], x => x.User.Id == member.Id); + Assert.False(dbContext.ChatMembers.Any(x => x.ChatId == channel.Id && x.UserId == member.Id)); + Assert.Equal(1, details.SubscriberCount); + } + + [Fact] + public async Task JoinByInviteLink_ReturnsPendingApprovalResultForModeratedInvite() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var requester = CreateUser("requester"); + var channel = CreateChannel(owner); + var invite = new ChatInviteLink + { + ChatId = channel.Id, + Chat = channel, + CreatedById = owner.Id, + CreatedBy = owner, + Token = "moderated-token", + CreatesJoinRequest = true + }; + channel.InviteLinks.Add(invite); + dbContext.AddRange(owner, requester, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, requester.Id); + var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None); + + var accepted = Assert.IsType(response.Result); + var result = Assert.IsType(accepted.Value); + Assert.Equal(JoinChatResultStatus.PendingApproval, result.Status); + Assert.NotNull(result.JoinRequestId); + Assert.Equal(ChatJoinRequestStatus.Pending, result.JoinRequestStatus); + Assert.Null(result.Chat); + Assert.Single(dbContext.ChatJoinRequests, x => x.ChatId == channel.Id && x.UserId == requester.Id); + } + + [Fact] + public async Task CreateInviteLink_ReturnsExpirationAndUsageLimits() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + dbContext.AddRange(owner, channel); + await dbContext.SaveChangesAsync(); + var expiresAt = DateTimeOffset.UtcNow.AddHours(2); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.CreateInviteLink( + channel.Id, + new CreateChatInviteLinkRequest( + Title: "limited", + CreatesJoinRequest: false, + ExpiresAt: expiresAt, + MaxUses: 3, + MemberLimit: 10), + CancellationToken.None); + + var link = ControllerTestFixture.ValueOf(response); + Assert.Equal("limited", link.Title); + Assert.False(link.CreatesJoinRequest); + Assert.Equal(expiresAt, link.ExpiresAt); + Assert.Equal(3, link.MaxUses); + Assert.Equal(0, link.UsesCount); + Assert.Equal(10, link.MemberLimit); + + var stored = Assert.Single(dbContext.ChatInviteLinks, x => x.ChatId == channel.Id); + Assert.Equal(expiresAt, stored.ExpiresAt); + Assert.Equal(3, stored.MaxUses); + Assert.Equal(0, stored.UsesCount); + Assert.Equal(10, stored.MemberLimit); + } + + [Fact] + public async Task JoinByInviteLink_RejectsExpiredLink() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var viewer = CreateUser("viewer"); + var channel = CreateChannel(owner); + var invite = new ChatInviteLink + { + ChatId = channel.Id, + Chat = channel, + CreatedById = owner.Id, + CreatedBy = owner, + Token = "expired-token", + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1) + }; + channel.InviteLinks.Add(invite); + dbContext.AddRange(owner, viewer, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Invite link has expired.", badRequest.Value); + Assert.Equal(0, invite.UsesCount); + Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == viewer.Id); + } + + [Fact] + public async Task JoinByInviteLink_IncrementsUsesAndRejectsAfterMaxUses() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var firstViewer = CreateUser("first"); + var secondViewer = CreateUser("second"); + var channel = CreateChannel(owner); + var invite = new ChatInviteLink + { + ChatId = channel.Id, + Chat = channel, + CreatedById = owner.Id, + CreatedBy = owner, + Token = "single-use-token", + MaxUses = 1 + }; + channel.InviteLinks.Add(invite); + dbContext.AddRange(owner, firstViewer, secondViewer, channel); + await dbContext.SaveChangesAsync(); + + var firstController = ControllerTestFixture.CreateChatsController(dbContext, firstViewer.Id); + var firstResponse = await firstController.JoinByInviteLink(invite.Token, CancellationToken.None); + + var firstResult = ControllerTestFixture.ValueOf(firstResponse); + Assert.Equal(JoinChatResultStatus.Joined, firstResult.Status); + Assert.Equal(1, invite.UsesCount); + Assert.Contains(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == firstViewer.Id); + + var secondController = ControllerTestFixture.CreateChatsController(dbContext, secondViewer.Id); + var secondResponse = await secondController.JoinByInviteLink(invite.Token, CancellationToken.None); + + var badRequest = Assert.IsType(secondResponse.Result); + Assert.Equal("Invite link usage limit reached.", badRequest.Value); + Assert.Equal(1, invite.UsesCount); + Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == secondViewer.Id); + } + + [Fact] + public async Task JoinByInviteLink_RejectsWhenMemberLimitReached() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var viewer = CreateUser("viewer"); + var channel = CreateChannel(owner); + var invite = new ChatInviteLink + { + ChatId = channel.Id, + Chat = channel, + CreatedById = owner.Id, + CreatedBy = owner, + Token = "full-token", + MemberLimit = 1 + }; + channel.InviteLinks.Add(invite); + dbContext.AddRange(owner, viewer, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Chat member limit reached.", badRequest.Value); + Assert.Equal(0, invite.UsesCount); + Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == viewer.Id); + } + + [Fact] + public async Task UpdateMemberPermissions_CannotPromoteMemberToOwner() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + IsOwner = false, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UpdateMemberPermissions( + channel.Id, + member.Id, + new UpdateChatMemberPermissionsRequest( + ChatMemberRole.Owner, + CanPinMessages: true, + CanDeleteMessages: true, + CanInviteUsers: true, + CanPostMessages: true), + CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Ownership can only be transferred by a dedicated owner transfer action.", badRequest.Value); + + var members = dbContext.ChatMembers.Where(x => x.ChatId == channel.Id).ToList(); + Assert.Single(members, x => x.IsOwner || x.Role == ChatMemberRole.Owner); + Assert.Equal(ChatMemberRole.Member, Assert.Single(members, x => x.UserId == member.Id).Role); + } + + [Fact] + public async Task UpdateChatState_CanLinkAndClearChannelDiscussionGroup() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + + var linkResponse = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest(LinkedDiscussionChatId: group.Id), + CancellationToken.None); + + var linked = ControllerTestFixture.ValueOf(linkResponse); + Assert.Equal(group.Id, linked.LinkedDiscussionChatId); + Assert.Equal(group.Id, dbContext.Chats.Single(x => x.Id == channel.Id).LinkedDiscussionChatId); + + var clearResponse = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest(ClearLinkedDiscussionChat: true), + CancellationToken.None); + + var cleared = ControllerTestFixture.ValueOf(clearResponse); + Assert.Null(cleared.LinkedDiscussionChatId); + Assert.Null(dbContext.Chats.Single(x => x.Id == channel.Id).LinkedDiscussionChatId); + } + + [Fact] + public async Task SendMessage_ChannelWithLinkedDiscussion_CreatesDiscussionRootAndCountsNestedReplies() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + channel.LinkedDiscussionChatId = group.Id; + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var postResponse = await controller.SendMessage( + channel.Id, + new SendMessageRequest("Breaking news"), + CancellationToken.None); + + var postDto = ControllerTestFixture.ValueOf(postResponse); + Assert.NotNull(postDto.DiscussionMessageId); + Assert.Equal(0, postDto.CommentCount); + + var rootMessage = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); + Assert.Equal(group.Id, rootMessage.ChatId); + Assert.Equal("Breaking news", rootMessage.Text); + Assert.Equal(channel.Title, rootMessage.ForwardedFromDisplayName); + + var firstReplyResponse = await controller.SendMessage( + group.Id, + new SendMessageRequest("First comment", ReplyToMessageId: rootMessage.Id), + CancellationToken.None); + var firstReply = ControllerTestFixture.ValueOf(firstReplyResponse); + + await controller.SendMessage( + group.Id, + new SendMessageRequest("Nested comment", ReplyToMessageId: firstReply.Id), + CancellationToken.None); + + var storedPost = dbContext.Messages.Single(x => x.Id == postDto.Id); + Assert.Equal(2, storedPost.CommentCount); + } + + [Fact] + public async Task UploadAttachment_ChannelWithLinkedDiscussion_MirrorsAttachmentToDiscussionRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-channel-upload-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + channel.LinkedDiscussionChatId = group.Id; + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var storage = ControllerTestFixture.CreateAttachmentStorageService(tempRoot); + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id, storage); + var response = await controller.UploadAttachment( + channel.Id, + new UploadAttachmentRequest + { + Text = "Photo post", + File = CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg") + }, + CancellationToken.None); + + var postDto = ControllerTestFixture.ValueOf(response); + Assert.NotNull(postDto.DiscussionMessageId); + + var discussionRoot = dbContext.Messages + .Include(x => x.Attachments) + .Single(x => x.Id == postDto.DiscussionMessageId); + var mirroredAttachment = Assert.Single(discussionRoot.Attachments); + Assert.Equal(group.Id, discussionRoot.ChatId); + Assert.Equal("Photo post", discussionRoot.Text); + Assert.Equal("photo.jpg", mirroredAttachment.OriginalFileName); + Assert.Equal("image/jpeg", mirroredAttachment.ContentType); + Assert.True(File.Exists(mirroredAttachment.StoragePath)); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task UploadAttachmentAlbum_ChannelWithLinkedDiscussion_MirrorsAlbumToDiscussionRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-channel-album-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + channel.LinkedDiscussionChatId = group.Id; + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var storage = ControllerTestFixture.CreateAttachmentStorageService(tempRoot); + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id, storage); + var response = await controller.UploadAttachmentAlbum( + channel.Id, + new UploadAttachmentAlbumRequest + { + Text = "Gallery", + Files = + [ + CreateFormFile([1, 2, 3], "first.jpg", "image/jpeg"), + CreateFormFile([4, 5, 6], "second.jpg", "image/jpeg") + ] + }, + CancellationToken.None); + + var postDto = ControllerTestFixture.ValueOf(response); + Assert.NotNull(postDto.DiscussionMessageId); + + var channelPost = dbContext.Messages.Single(x => x.Id == postDto.Id); + var discussionRoot = dbContext.Messages + .Include(x => x.Attachments) + .Single(x => x.Id == postDto.DiscussionMessageId); + Assert.Equal(channelPost.MediaAlbumId, discussionRoot.MediaAlbumId); + Assert.Equal( + new[] { "first.jpg", "second.jpg" }, + discussionRoot.Attachments.OrderBy(x => x.SortOrder).Select(x => x.OriginalFileName)); + Assert.All(discussionRoot.Attachments, attachment => Assert.True(File.Exists(attachment.StoragePath))); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Fact] + public async Task UpdateMessage_ChannelPostWithLinkedDiscussion_UpdatesDiscussionRootText() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + channel.LinkedDiscussionChatId = group.Id; + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var postResponse = await controller.SendMessage( + channel.Id, + new SendMessageRequest("Old text"), + CancellationToken.None); + var postDto = ControllerTestFixture.ValueOf(postResponse); + + var updateResponse = await controller.UpdateMessage( + channel.Id, + postDto.Id, + new UpdateMessageRequest("New text"), + CancellationToken.None); + + var updatedDto = ControllerTestFixture.ValueOf(updateResponse); + var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); + Assert.Equal("New text", updatedDto.Text); + Assert.Equal("New text", discussionRoot.Text); + Assert.NotNull(discussionRoot.EditedAt); + } + + [Fact] + public async Task DeleteMessage_ChannelPostWithLinkedDiscussion_DeletesDiscussionRoot() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var group = CreateGroup(owner); + channel.LinkedDiscussionChatId = group.Id; + dbContext.AddRange(owner, channel, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var postResponse = await controller.SendMessage( + channel.Id, + new SendMessageRequest("Post to delete"), + CancellationToken.None); + var postDto = ControllerTestFixture.ValueOf(postResponse); + + var deleteResponse = await controller.DeleteMessage(channel.Id, postDto.Id, CancellationToken.None); + + var deletedDto = ControllerTestFixture.ValueOf(deleteResponse); + var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); + Assert.NotNull(deletedDto.DeletedAt); + Assert.NotNull(discussionRoot.DeletedAt); + Assert.Equal(string.Empty, discussionRoot.Text); + } + + [Fact] + public async Task UpdateChatState_CanUpdateChannelProfile() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + dbContext.AddRange(owner, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest( + Title: "Daily News", + Description: "Verified city updates", + PublicUsername: "daily_news"), + CancellationToken.None); + + var summary = ControllerTestFixture.ValueOf(response); + var stored = dbContext.Chats.Single(x => x.Id == channel.Id); + Assert.Equal("Daily News", summary.Title); + Assert.Equal("Daily News", stored.Title); + Assert.Equal("Verified city updates", stored.Description); + Assert.Equal("daily_news", stored.PublicUsername); + Assert.Contains(dbContext.ModerationLogEntries, x => + x.ChatId == channel.Id && + x.ActorUserId == owner.Id && + x.Action == "chat.profile.updated"); + } + + [Fact] + public async Task UpdateChatState_CanClearChannelDescriptionAndPublicUsername() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + channel.Description = "Old description"; + channel.PublicUsername = "old_news"; + dbContext.AddRange(owner, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest(ClearDescription: true, ClearPublicUsername: true), + CancellationToken.None); + + ControllerTestFixture.ValueOf(response); + var stored = dbContext.Chats.Single(x => x.Id == channel.Id); + Assert.Null(stored.Description); + Assert.Null(stored.PublicUsername); + } + + [Fact] + public async Task UpdateChatState_DeniesMemberProfileUpdate() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id); + var response = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest(Title: "Member title"), + CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + Assert.Equal("Only admins can update chat profile.", forbidden.Value); + Assert.Equal("News", dbContext.Chats.Single(x => x.Id == channel.Id).Title); + } + + [Fact] + public async Task UpdateChatState_RejectsTakenPublicUsername() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var otherOwner = CreateUser("other"); + var channel = CreateChannel(owner); + var otherChannel = CreateChannel(otherOwner); + otherChannel.PublicUsername = "taken_name"; + dbContext.AddRange(owner, otherOwner, channel, otherChannel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UpdateChatState( + channel.Id, + new ChatStateRequest(PublicUsername: "taken_name"), + CancellationToken.None); + + var conflict = Assert.IsType(response.Result); + Assert.Equal("Public username is already taken.", conflict.Value); + Assert.Equal("news", dbContext.Chats.Single(x => x.Id == channel.Id).PublicUsername); + } + + [Fact] + public async Task TransferOwnership_MovesOwnerRoleToChannelMember() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + IsOwner = false, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.TransferOwnership(channel.Id, member.Id, CancellationToken.None); + + var details = ControllerTestFixture.ValueOf(response); + Assert.Equal(member.Id, dbContext.Chats.Single(x => x.Id == channel.Id).CreatedById); + var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id); + var targetMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == member.Id); + Assert.False(ownerMember.IsOwner); + Assert.Equal(ChatMemberRole.Admin, ownerMember.Role); + Assert.True(ownerMember.CanPostMessages); + Assert.True(targetMember.IsOwner); + Assert.Equal(ChatMemberRole.Owner, targetMember.Role); + Assert.True(targetMember.CanInviteUsers); + Assert.Single(dbContext.ChatMembers.Where(x => x.ChatId == channel.Id && (x.IsOwner || x.Role == ChatMemberRole.Owner))); + var returnedTarget = Assert.Single(details.Members ?? [], x => x.User.Id == member.Id); + Assert.True(returnedTarget.IsOwner); + Assert.Equal(ChatMemberRole.Owner, returnedTarget.Role); + Assert.Contains(dbContext.ModerationLogEntries, x => + x.ChatId == channel.Id && + x.ActorUserId == owner.Id && + x.TargetId == member.Id && + x.Action == "member.owner.transferred"); + } + + [Fact] + public async Task TransferOwnership_RejectsAdminActor() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var admin = CreateUser("admin"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = admin.Id, + User = admin, + IsOwner = false, + Role = ChatMemberRole.Admin, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + IsOwner = false, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, admin, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, admin.Id); + var response = await controller.TransferOwnership(channel.Id, member.Id, CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(Microsoft.AspNetCore.Http.StatusCodes.Status403Forbidden, forbidden.StatusCode); + Assert.Equal("Only owner can transfer ownership.", forbidden.Value); + var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id); + Assert.True(ownerMember.IsOwner); + Assert.Equal(ChatMemberRole.Owner, ownerMember.Role); + } + + [Fact] + public async Task TransferOwnership_RejectsBotTarget() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var bot = CreateUser("bot", isBot: true); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = bot.Id, + User = bot, + IsOwner = false, + Role = ChatMemberRole.Admin, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + dbContext.AddRange(owner, bot, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.TransferOwnership(channel.Id, bot.Id, CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Bot cannot become chat owner.", badRequest.Value); + var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id); + var botMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == bot.Id); + Assert.True(ownerMember.IsOwner); + Assert.False(botMember.IsOwner); + Assert.Equal(ChatMemberRole.Admin, botMember.Role); + } + + [Fact] + public async Task DeleteChat_BlocksOwnerLeavingChannelWithoutDeletingForEveryone() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + IsOwner = false, + Role = ChatMemberRole.Member + }); + dbContext.AddRange(owner, member, channel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.DeleteChat(channel.Id, forEveryone: false, CancellationToken.None); + + var badRequest = Assert.IsType(response); + Assert.Equal("Owner must transfer ownership or delete the chat for everyone.", badRequest.Value); + Assert.Equal(2, dbContext.ChatMembers.Count(x => x.ChatId == channel.Id)); + Assert.True(dbContext.Chats.Any(x => x.Id == channel.Id)); + } + + [Fact] + public async Task UpdateMessage_AllowsChannelAdminWithPostPermissionToEditChannelPost() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var admin = CreateUser("admin"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = admin.Id, + User = admin, + Role = ChatMemberRole.Admin, + CanPostMessages = true + }); + var message = CreateTextMessage(channel, owner, "Original"); + dbContext.AddRange(owner, admin, channel, message); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, admin.Id); + var response = await controller.UpdateMessage( + channel.Id, + message.Id, + new UpdateMessageRequest("Updated by admin"), + CancellationToken.None); + + var updated = ControllerTestFixture.ValueOf(response); + Assert.Equal("Updated by admin", updated.Text); + Assert.NotNull(dbContext.Messages.Single(x => x.Id == message.Id).EditedAt); + } + + [Fact] + public async Task UpdateMessage_DeniesChannelMemberWithoutPostPermission() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var member = CreateUser("member"); + var channel = CreateChannel(owner); + channel.Members.Add(new ChatMember + { + ChatId = channel.Id, + Chat = channel, + UserId = member.Id, + User = member, + Role = ChatMemberRole.Member, + CanPostMessages = false + }); + var message = CreateTextMessage(channel, owner, "Original"); + dbContext.AddRange(owner, member, channel, message); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id); + var response = await controller.UpdateMessage( + channel.Id, + message.Id, + new UpdateMessageRequest("Updated by admin"), + CancellationToken.None); + + var forbidden = Assert.IsType(response.Result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + Assert.Equal("You cannot edit this message.", forbidden.Value); + Assert.Equal("Original", dbContext.Messages.Single(x => x.Id == message.Id).Text); + } + + [Fact] + public async Task DownloadAttachment_DoesNotServeDeletedMessageAttachment() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var message = CreateMessageWithAttachment(channel, owner, deletedAt: DateTimeOffset.UtcNow); + dbContext.AddRange(owner, channel, message); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.DownloadAttachment(channel.Id, message.Attachments.Single().Id, CancellationToken.None); + + Assert.IsType(response); + } + + [Fact] + public async Task UploadAttachmentAlbum_RejectsSingleFile() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var group = CreateGroup(owner); + dbContext.AddRange(owner, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UploadAttachmentAlbum( + group.Id, + new UploadAttachmentAlbumRequest + { + Files = [CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg")] + }, + CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Albums must contain at least 2 files.", badRequest.Value); + } + + [Fact] + public async Task UploadAttachmentAlbum_RejectsDocumentFile() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var group = CreateGroup(owner); + dbContext.AddRange(owner, group); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.UploadAttachmentAlbum( + group.Id, + new UploadAttachmentAlbumRequest + { + Files = + [ + CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg"), + CreateFormFile([0x25, 0x50, 0x44, 0x46], "manual.pdf", "application/pdf") + ] + }, + CancellationToken.None); + + var badRequest = Assert.IsType(response.Result); + Assert.Equal("Albums accept only image and video files.", badRequest.Value); + } + + [Fact] + public async Task DownloadAttachment_DoesNotServeExpiredMessageAttachment() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var owner = CreateUser("owner"); + var channel = CreateChannel(owner); + var message = CreateMessageWithAttachment(channel, owner, expiresAt: DateTimeOffset.UtcNow.AddSeconds(-1)); + dbContext.AddRange(owner, channel, message); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); + var response = await controller.DownloadAttachment(channel.Id, message.Attachments.Single().Id, CancellationToken.None); + + Assert.IsType(response); + } + private static User CreateUser(string username, bool isBot = false) => new() { @@ -77,4 +1114,90 @@ public sealed class ChannelMembersControllerTests return channel; } + + private static Chat CreateGroup(User owner) + { + var group = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Group, + Title = "Group", + CreatedById = owner.Id, + CreatedBy = owner, + CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero), + LastActivityAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero) + }; + group.Members.Add(new ChatMember + { + ChatId = group.Id, + Chat = group, + UserId = owner.Id, + User = owner, + IsOwner = true, + Role = ChatMemberRole.Owner, + CanPinMessages = true, + CanDeleteMessages = true, + CanInviteUsers = true, + CanPostMessages = true + }); + + return group; + } + + private static FormFile CreateFormFile(byte[] content, string fileName, string contentType) + { + var stream = new MemoryStream(content); + return new FormFile(stream, 0, stream.Length, "files", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + private static Message CreateTextMessage(Chat chat, User sender, string text) + { + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + SenderId = sender.Id, + Sender = sender, + Text = text, + SentAt = DateTimeOffset.UtcNow + }; + chat.Messages.Add(message); + return message; + } + + private static Message CreateMessageWithAttachment( + Chat chat, + User sender, + DateTimeOffset? deletedAt = null, + DateTimeOffset? expiresAt = null) + { + var message = new Message + { + Id = Guid.NewGuid(), + ChatId = chat.Id, + Chat = chat, + SenderId = sender.Id, + Sender = sender, + Text = string.Empty, + SentAt = DateTimeOffset.UtcNow, + DeletedAt = deletedAt, + ExpiresAt = expiresAt + }; + message.Attachments.Add(new MessageAttachment + { + Id = Guid.NewGuid(), + MessageId = message.Id, + Message = message, + OriginalFileName = "document.pdf", + ContentType = "application/pdf", + StoragePath = "missing.pdf" + }); + + return message; + } } diff --git a/src/Ikar.Server.Tests/ControllerTestFixture.cs b/src/Ikar.Server.Tests/ControllerTestFixture.cs index 447c3013..2ada3202 100644 --- a/src/Ikar.Server.Tests/ControllerTestFixture.cs +++ b/src/Ikar.Server.Tests/ControllerTestFixture.cs @@ -6,11 +6,14 @@ using Ikar.Server.Infrastructure.Bots; using Ikar.Server.Infrastructure.Auth; using Ikar.Server.Infrastructure.Hubs; using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.FileProviders; namespace Ikar.Server.Tests; @@ -37,15 +40,28 @@ internal sealed class SqliteTestDatabase : IAsyncDisposable internal static class ControllerTestFixture { - public static ChatsController CreateChatsController(IkarDbContext dbContext, Guid userId) + public static ChatsController CreateChatsController( + IkarDbContext dbContext, + Guid userId, + AttachmentStorageService? attachmentStorageService = null) { + var pushDispatchQueue = new PushDispatchQueue(); + var hubContext = new NoopHubContext(); + var botAttachmentStorageService = attachmentStorageService ?? CreateAttachmentStorageService(Path.GetTempPath()); var controller = new ChatsController( dbContext, new PresenceTracker(), - new NoopHubContext(), - attachmentStorageService: null!, - new PushDispatchQueue(), - new BotUpdateService(dbContext)); + hubContext, + attachmentStorageService: attachmentStorageService!, + pushDispatchQueue, + new BotUpdateService(dbContext), + new BotCreatorService( + dbContext, + new BotTokenService(), + new BotDeletionService(dbContext, botAttachmentStorageService), + new PresenceTracker(), + hubContext, + pushDispatchQueue)); AttachUser(controller, userId); return controller; } @@ -62,21 +78,38 @@ internal static class ControllerTestFixture public static BotsController CreateBotsController(IkarDbContext dbContext, Guid userId) { - var controller = new BotsController(dbContext, new BotTokenService()); + var attachmentStorageService = CreateAttachmentStorageService(Path.GetTempPath()); + var controller = new BotsController( + dbContext, + new BotTokenService(), + new BotDeletionService(dbContext, attachmentStorageService)); AttachUser(controller, userId); return controller; } public static StoriesController CreateStoriesController(IkarDbContext dbContext, Guid userId) + { + return CreateStoriesController(dbContext, userId, new NoopHubContext()); + } + + public static StoriesController CreateStoriesController( + IkarDbContext dbContext, + Guid userId, + IHubContext hubContext, + AttachmentStorageService? attachmentStorageService = null) { var controller = new StoriesController( dbContext, new PresenceTracker(), - attachmentStorageService: null!); + hubContext, + attachmentStorageService ?? new AttachmentStorageService(new TestWebHostEnvironment(Path.GetTempPath()))); AttachUser(controller, userId); return controller; } + public static AttachmentStorageService CreateAttachmentStorageService(string contentRootPath) => + new(new TestWebHostEnvironment(contentRootPath)); + public static T ValueOf(ActionResult actionResult) { var ok = Assert.IsType(actionResult.Result); @@ -96,6 +129,84 @@ internal static class ControllerTestFixture } }; } + + private sealed class TestWebHostEnvironment(string contentRootPath) : IWebHostEnvironment + { + public string ApplicationName { get; set; } = "Ikar.Server.Tests"; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + public string WebRootPath { get; set; } = contentRootPath; + public string EnvironmentName { get; set; } = "Development"; + public string ContentRootPath { get; set; } = contentRootPath; + public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath); + } +} + +internal sealed record RecordedHubSend( + string TargetKind, + IReadOnlyList Targets, + string Method, + int ArgumentCount); + +internal sealed class RecordingHubContext : IHubContext + where THub : Hub +{ + private readonly RecordingHubClients _clients = new(); + + public IHubClients Clients => _clients; + + public IGroupManager Groups { get; } = new RecordingGroupManager(); + + public IReadOnlyList Sends => _clients.Sends; + + private sealed class RecordingHubClients : IHubClients + { + private readonly List _sends = []; + + public IReadOnlyList Sends => _sends; + + public IClientProxy All => Proxy("All", []); + + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy("AllExcept", excludedConnectionIds); + + public IClientProxy Client(string connectionId) => Proxy("Client", [connectionId]); + + public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy("Clients", connectionIds); + + public IClientProxy Group(string groupName) => Proxy("Group", [groupName]); + + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => + Proxy("GroupExcept", [groupName, .. excludedConnectionIds]); + + public IClientProxy Groups(IReadOnlyList groupNames) => Proxy("Groups", groupNames); + + public IClientProxy User(string userId) => Proxy("User", [userId]); + + public IClientProxy Users(IReadOnlyList userIds) => Proxy("Users", userIds); + + private IClientProxy Proxy(string targetKind, IReadOnlyList targets) => + new RecordingClientProxy(_sends, targetKind, targets); + } + + private sealed class RecordingClientProxy( + List sends, + string targetKind, + IReadOnlyList targets) : IClientProxy + { + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + { + sends.Add(new RecordedHubSend(targetKind, targets.ToList(), method, args.Length)); + return Task.CompletedTask; + } + } + + private sealed class RecordingGroupManager : IGroupManager + { + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } } internal sealed class NoopHubContext : IHubContext diff --git a/src/Ikar.Server.Tests/PrivacyAndStoriesControllerTests.cs b/src/Ikar.Server.Tests/PrivacyAndStoriesControllerTests.cs index 60d45e49..7b1de855 100644 --- a/src/Ikar.Server.Tests/PrivacyAndStoriesControllerTests.cs +++ b/src/Ikar.Server.Tests/PrivacyAndStoriesControllerTests.cs @@ -1,5 +1,9 @@ +using Ikar.Server.Controllers; using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Hubs; using Ikar.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; namespace Ikar.Server.Tests; @@ -33,12 +37,103 @@ public sealed class PrivacyAndStoriesControllerTests Assert.Equal("contacts", settings.PhoneNumberVisibility); Assert.Equal("nobody", settings.LastSeenVisibility); Assert.Equal([bob.Id], settings.BlockedUserIds); + var blockedUser = Assert.Single(settings.BlockedUsers); + Assert.Equal(bob.Id, blockedUser.Id); + Assert.Equal("bob", blockedUser.DisplayName); Assert.Empty(settings.AlwaysAllowUserIds); + Assert.Empty(settings.AlwaysAllowUsers); Assert.Equal([bob.Id], settings.NeverAllowUserIds); + var neverAllowUser = Assert.Single(settings.NeverAllowUsers); + Assert.Equal(bob.Id, neverAllowUser.Id); + Assert.Equal("bob", neverAllowUser.DisplayName); Assert.True(settings.PasscodeLockEnabled); Assert.False(settings.ShowMessagePreview); } + [Fact] + public async Task GetUser_HidesPrivatePhoneLastSeenAndAvatar() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var phoneNumber = "+10000000001"; + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber)!; + alice.PhoneNumber = phoneNumber; + alice.NormalizedPhoneNumber = normalizedPhoneNumber; + alice.AvatarStoragePath = "avatar.png"; + alice.AvatarContentType = "image/png"; + alice.PrivacySettings = new UserPrivacySettings + { + UserId = alice.Id, + User = alice, + PhoneNumberVisibility = "nobody", + LastSeenVisibility = "nobody", + ProfilePhotoVisibility = "nobody" + }; + alice.Sessions.Add(new UserSession + { + UserId = alice.Id, + User = alice, + LastSeenAt = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero) + }); + var sharedChat = CreateDirectChat(alice, bob); + dbContext.AddRange(alice, bob, sharedChat); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateUsersController(dbContext, bob.Id); + var response = await controller.GetUser(alice.Id, CancellationToken.None); + + var user = ControllerTestFixture.ValueOf(response); + Assert.Null(user.PhoneNumber); + Assert.Null(user.LastSeenAt); + Assert.Null(user.AvatarPath); + Assert.False(user.IsOnline); + } + + [Fact] + public async Task ResolveContacts_UsesPrivacyFilteredUserDto() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var phoneNumber = "+10000000001"; + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber)!; + alice.PhoneNumber = phoneNumber; + alice.NormalizedPhoneNumber = normalizedPhoneNumber; + alice.AvatarStoragePath = "avatar.png"; + alice.AvatarContentType = "image/png"; + alice.PrivacySettings = new UserPrivacySettings + { + UserId = alice.Id, + User = alice, + PhoneNumberVisibility = "nobody", + LastSeenVisibility = "nobody", + ProfilePhotoVisibility = "nobody" + }; + alice.Sessions.Add(new UserSession + { + UserId = alice.Id, + User = alice, + LastSeenAt = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero) + }); + dbContext.AddRange(alice, bob); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateUsersController(dbContext, bob.Id); + var response = await controller.ResolveContacts( + new ResolveContactsRequest([phoneNumber]), + CancellationToken.None); + + var match = Assert.Single(ControllerTestFixture.ValueOf(response)); + Assert.Equal(normalizedPhoneNumber, match.PhoneNumber); + Assert.Equal(alice.Id, match.User.Id); + Assert.Null(match.User.PhoneNumber); + Assert.Null(match.User.LastSeenAt); + Assert.Null(match.User.AvatarPath); + } + [Fact] public async Task GetStories_ReturnsEveryoneStoriesWithoutSharedChat() { @@ -106,6 +201,118 @@ public sealed class PrivacyAndStoriesControllerTests Assert.Equal("own", story.Text); } + [Fact] + public async Task CreateStory_NotifiesAllClientsForEveryoneStory() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + dbContext.Users.Add(alice); + await dbContext.SaveChangesAsync(); + + var hubContext = new RecordingHubContext(); + var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id, hubContext); + await controller.CreateStory(new CreateStoryRequest("public", StoryVisibility.Everyone), CancellationToken.None); + + var send = Assert.Single(hubContext.Sends, x => x.Method == "StoriesChanged"); + Assert.Equal("All", send.TargetKind); + Assert.Empty(send.Targets); + Assert.Equal(0, send.ArgumentCount); + } + + [Fact] + public async Task CreateStory_NotifiesOnlyAuthorAndPeersForContactsStory() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var charlie = CreateUser("charlie"); + var sharedChat = CreateDirectChat(alice, bob); + dbContext.AddRange(alice, bob, charlie, sharedChat); + await dbContext.SaveChangesAsync(); + + var hubContext = new RecordingHubContext(); + var controller = ControllerTestFixture.CreateStoriesController(dbContext, alice.Id, hubContext); + await controller.CreateStory(new CreateStoryRequest("contacts", StoryVisibility.Contacts), CancellationToken.None); + + var send = Assert.Single(hubContext.Sends, x => x.Method == "StoriesChanged"); + Assert.Equal("Users", send.TargetKind); + Assert.Equal( + new[] { alice.Id.ToString(), bob.Id.ToString() }.OrderBy(x => x).ToArray(), + send.Targets.OrderBy(x => x).ToArray()); + Assert.DoesNotContain(charlie.Id.ToString(), send.Targets); + Assert.Equal(0, send.ArgumentCount); + } + + [Fact] + public async Task CreateMediaStory_PreservesAllSelectedFilesInOrder() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-story-upload-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + try + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + dbContext.Users.Add(alice); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateStoriesController( + dbContext, + alice.Id, + new RecordingHubContext(), + ControllerTestFixture.CreateAttachmentStorageService(tempRoot)); + + var response = await controller.CreateMediaStory( + new UploadStoryRequest + { + Text = "trip", + Visibility = StoryVisibility.Everyone, + Files = + [ + CreateFormFile([1, 2, 3], "first.jpg", "image/jpeg"), + CreateFormFile([4, 5, 6], "second.jpg", "image/jpeg"), + CreateFormFile([7, 8, 9], "third.jpg", "image/jpeg") + ] + }, + CancellationToken.None); + + var dto = ControllerTestFixture.ValueOf(response); + Assert.Equal(3, dto.Attachments.Count); + Assert.Collection( + dto.Attachments, + attachment => + { + Assert.Equal("first.jpg", attachment.FileName); + Assert.Equal(0, attachment.SortOrder); + }, + attachment => + { + Assert.Equal("second.jpg", attachment.FileName); + Assert.Equal(1, attachment.SortOrder); + }, + attachment => + { + Assert.Equal("third.jpg", attachment.FileName); + Assert.Equal(2, attachment.SortOrder); + }); + + var storedStory = await dbContext.Stories + .Include(x => x.Attachments) + .SingleAsync(x => x.Id == dto.Id); + var storedAttachments = storedStory.Attachments + .OrderBy(x => x.SortOrder) + .ToList(); + Assert.Equal(3, storedAttachments.Count); + Assert.All(storedAttachments, attachment => Assert.True(File.Exists(attachment.StoragePath))); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + } + [Fact] public async Task GetAvatar_RespectsProfilePhotoContactsVisibility() { @@ -144,6 +351,44 @@ public sealed class PrivacyAndStoriesControllerTests } } + [Fact] + public async Task GetStories_HidesContactOnlyAuthorProfileFields_ForNonPeerViewer() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var charlie = CreateUser("charlie"); + alice.PhoneNumber = "+10000000001"; + alice.AvatarStoragePath = "alice-avatar.png"; + alice.PrivacySettings = new UserPrivacySettings + { + UserId = alice.Id, + User = alice, + PhoneNumberVisibility = "contacts", + LastSeenVisibility = "contacts", + ProfilePhotoVisibility = "contacts" + }; + alice.Sessions.Add(new UserSession + { + Id = Guid.NewGuid(), + UserId = alice.Id, + User = alice, + LastSeenAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }); + var story = CreateStory(alice, "public", StoryVisibility.Everyone); + dbContext.AddRange(alice, charlie, story); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateStoriesController(dbContext, charlie.Id); + var response = await controller.GetStories(CancellationToken.None); + + var visibleStory = Assert.Single(ControllerTestFixture.ValueOf(response)); + Assert.Equal(alice.Id, visibleStory.Author.Id); + Assert.Null(visibleStory.Author.PhoneNumber); + Assert.Null(visibleStory.Author.LastSeenAt); + Assert.Null(visibleStory.Author.AvatarPath); + } + private static User CreateUser(string username) => new() { @@ -179,4 +424,14 @@ public sealed class PrivacyAndStoriesControllerTests CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) }; + + private static FormFile CreateFormFile(byte[] content, string fileName, string contentType) + { + var stream = new MemoryStream(content); + return new FormFile(stream, 0, stream.Length, "files", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } } diff --git a/src/Ikar.Server.Tests/PushNotificationDispatcherTests.cs b/src/Ikar.Server.Tests/PushNotificationDispatcherTests.cs index 6114c55c..b1aec7a1 100644 --- a/src/Ikar.Server.Tests/PushNotificationDispatcherTests.cs +++ b/src/Ikar.Server.Tests/PushNotificationDispatcherTests.cs @@ -7,6 +7,123 @@ namespace Ikar.Server.Tests; public sealed class PushNotificationDispatcherTests { + [Fact] + public async Task DispatchMessageAsync_DoesNotNotifyMutedRecipient() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var senderUser = CreateUser("sender"); + var mutedRecipient = CreateUser("muted"); + var recipientDevice = CreateAndroidDevice(mutedRecipient, "muted-device", notificationsEnabled: true); + var chat = CreateChat(senderUser, mutedRecipient); + chat.Members.Single(member => member.UserId == mutedRecipient.Id).MutedUntil = DateTimeOffset.UtcNow.AddHours(1); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = senderUser, + SenderId = senderUser.Id, + Text = "hello" + }; + + dbContext.AddRange(senderUser, mutedRecipient, chat, message, recipientDevice); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingPushNotificationSender(); + var dispatcher = new PushNotificationDispatcher( + dbContext, + sender, + NullLogger.Instance); + + await dispatcher.DispatchMessageAsync(chat, message); + + Assert.Empty(sender.MessageCalls); + } + + [Fact] + public async Task DispatchMessageAsync_NotifiesRecipientWhenMuteExpired() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var senderUser = CreateUser("sender"); + var recipient = CreateUser("recipient"); + var recipientDevice = CreateAndroidDevice(recipient, "recipient-device", notificationsEnabled: true); + var chat = CreateChat(senderUser, recipient); + chat.Members.Single(member => member.UserId == recipient.Id).MutedUntil = DateTimeOffset.UtcNow.AddMinutes(-1); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = senderUser, + SenderId = senderUser.Id, + Text = "hello" + }; + + dbContext.AddRange(senderUser, recipient, chat, message, recipientDevice); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingPushNotificationSender(); + var dispatcher = new PushNotificationDispatcher( + dbContext, + sender, + NullLogger.Instance); + + await dispatcher.DispatchMessageAsync(chat, message); + + var call = Assert.Single(sender.MessageCalls); + Assert.Equal(chat.Id, call.Chat.Id); + Assert.Equal(message.Id, call.Message.Id); + var device = Assert.Single(call.Devices); + Assert.Equal(recipientDevice.Id, device.Id); + } + + [Fact] + public async Task DispatchMessageAsync_HidesPreviewAndSenderPhoneForPrivateRecipient() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + + var senderUser = CreateUser("sender"); + senderUser.PhoneNumber = "+10000000000"; + var recipient = CreateUser("recipient"); + var recipientDevice = CreateAndroidDevice(recipient, "recipient-device", notificationsEnabled: true); + var chat = CreateChat(senderUser, recipient); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = senderUser, + SenderId = senderUser.Id, + Text = "secret" + }; + var privacy = new UserPrivacySettings + { + UserId = recipient.Id, + User = recipient, + ShowMessagePreview = false + }; + + dbContext.AddRange(senderUser, recipient, chat, message, recipientDevice, privacy); + await dbContext.SaveChangesAsync(); + + var sender = new RecordingPushNotificationSender(); + var dispatcher = new PushNotificationDispatcher( + dbContext, + sender, + NullLogger.Instance); + + await dispatcher.DispatchMessageAsync(chat, message); + + var call = Assert.Single(sender.MessageCalls); + Assert.Equal(message.Id, call.Message.Id); + Assert.Equal("Новое сообщение", call.Message.Text); + Assert.Null(call.Message.Sender.PhoneNumber); + var device = Assert.Single(call.Devices); + Assert.Equal(recipientDevice.Id, device.Id); + } + [Fact] public async Task DispatchMessageReactionAsync_SendsOnlyToMessageAuthorDevices() { @@ -126,14 +243,18 @@ public sealed class PushNotificationDispatcherTests private sealed class RecordingPushNotificationSender : IPushNotificationSender { + public List MessageCalls { get; } = []; public List ReactionCalls { get; } = []; public Task SendMessageAsync( Chat chat, Message message, IReadOnlyCollection devices, - CancellationToken cancellationToken = default) => - Task.CompletedTask; + CancellationToken cancellationToken = default) + { + MessageCalls.Add(new MessagePushCall(chat, message, devices.ToList())); + return Task.CompletedTask; + } public Task SendMessageReactionAsync( Chat chat, @@ -166,6 +287,11 @@ public sealed class PushNotificationDispatcherTests Task.CompletedTask; } + private sealed record MessagePushCall( + Chat Chat, + Message Message, + IReadOnlyCollection Devices); + private sealed record ReactionPushCall( Chat Chat, Message Message, diff --git a/src/Ikar.Server.Tests/SearchControllerTests.cs b/src/Ikar.Server.Tests/SearchControllerTests.cs index 2cbb2b69..1d207dbf 100644 --- a/src/Ikar.Server.Tests/SearchControllerTests.cs +++ b/src/Ikar.Server.Tests/SearchControllerTests.cs @@ -81,6 +81,109 @@ public sealed class SearchControllerTests Assert.Equal(["alpha one"], secondPage.Messages.Select(x => x.Text).ToArray()); } + [Fact] + public async Task GetChats_IgnoresExpiredMessagesForPreviewAndUnreadCount() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + var now = DateTimeOffset.UtcNow; + dbContext.AddRange( + alice, + bob, + chat, + CreateMessage(chat, bob, "visible", now.AddMinutes(-10)), + CreateMessage(chat, bob, "expired", now.AddMinutes(-5), expiresAt: now.AddMinutes(-1))); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var response = await controller.GetChats(CancellationToken.None); + + var summary = Assert.Single(ControllerTestFixture.ValueOf(response)); + Assert.Equal("bob: visible", summary.LastMessagePreview); + Assert.Equal(1, summary.UnreadCount); + } + + [Fact] + public async Task MarkChatRead_ClearsUnreadCountForChat() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + var first = CreateMessage(chat, bob, "first", new DateTimeOffset(2026, 1, 1, 10, 0, 0, TimeSpan.Zero)); + var second = CreateMessage(chat, bob, "second", new DateTimeOffset(2026, 1, 1, 10, 1, 0, TimeSpan.Zero)); + dbContext.AddRange(alice, bob, chat, first, second); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var before = Assert.Single(ControllerTestFixture.ValueOf(await controller.GetChats(CancellationToken.None))); + Assert.Equal(2, before.UnreadCount); + + await controller.MarkChatRead(chat.Id, CancellationToken.None); + + var after = Assert.Single(ControllerTestFixture.ValueOf(await controller.GetChats(CancellationToken.None))); + Assert.Equal(0, after.UnreadCount); + Assert.Equal(second.SentAt, chat.Members.First(member => member.UserId == alice.Id).LastReadAt); + } + + [Fact] + public async Task SearchMessagesPage_IgnoresExpiredMessages() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + var now = DateTimeOffset.UtcNow; + dbContext.AddRange( + alice, + bob, + chat, + CreateMessage(chat, bob, "alpha visible", now.AddMinutes(-10)), + CreateMessage(chat, bob, "alpha expired", now.AddMinutes(-5), expiresAt: now.AddMinutes(-1))); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var response = await controller.SearchMessagesPage(chat.Id, q: "alpha", page: 1, limit: 10); + + var page = ControllerTestFixture.ValueOf(response); + var message = Assert.Single(page.Messages); + Assert.Equal("alpha visible", message.Text); + } + + [Fact] + public async Task SearchMessagesPage_FiltersPinnedMessages() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var alice = CreateUser("alice"); + var bob = CreateUser("bob"); + var chat = CreateChat(alice, bob); + var pinned = CreateMessage(chat, bob, "pinned note", new DateTimeOffset(2026, 1, 2, 10, 0, 0, TimeSpan.Zero)); + pinned.PinnedAt = new DateTimeOffset(2026, 1, 2, 10, 5, 0, TimeSpan.Zero); + pinned.PinnedByUserId = alice.Id; + pinned.PinnedByUser = alice; + dbContext.AddRange( + alice, + bob, + chat, + CreateMessage(chat, bob, "regular note", new DateTimeOffset(2026, 1, 3, 10, 0, 0, TimeSpan.Zero)), + pinned); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, alice.Id); + var response = await controller.SearchMessagesPage(chat.Id, q: " ", filter: "pinned", page: 1, limit: 10); + + var page = ControllerTestFixture.ValueOf(response); + var message = Assert.Single(page.Messages); + Assert.Equal(pinned.Id, message.Id); + Assert.NotNull(message.PinnedAt); + } + [Fact] public async Task SearchPage_ReturnsPublicChannelForNonMember_ByAtUsername() { @@ -104,6 +207,49 @@ public sealed class SearchControllerTests Assert.False(result.Chat.IsSubscribed); } + [Fact] + public async Task GetPublicChannels_ReturnsPublicChannelsForNonMember_AndFiltersByTitle() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var viewer = CreateUser("viewer"); + var owner = CreateUser("owner"); + var publicChannel = CreateChannel(owner, "Daily News", "daily_news", "Public news channel"); + var privateChannel = CreateChannel(owner, "Daily News Private", null, "Private news channel"); + dbContext.AddRange(viewer, owner, publicChannel, privateChannel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.GetPublicChannels(q: "daily", limit: 10); + + var channels = ControllerTestFixture.ValueOf(response); + var channel = Assert.Single(channels); + Assert.Equal(publicChannel.Id, channel.Id); + Assert.Equal(ChatType.Channel, channel.Type); + Assert.Equal("daily_news", channel.PublicUsername); + Assert.True(channel.CanSubscribe); + Assert.False(channel.IsSubscribed); + } + + [Fact] + public async Task GetPublicChannels_FindsCyrillicTitleIgnoringCase() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var viewer = CreateUser("viewer"); + var owner = CreateUser("owner"); + var publicChannel = CreateChannel(owner, "РИА новости", "ria_news", "Новости из агрегатора"); + dbContext.AddRange(viewer, owner, publicChannel); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.GetPublicChannels(q: "риа", limit: 10); + + var channel = Assert.Single(ControllerTestFixture.ValueOf(response)); + Assert.Equal(publicChannel.Id, channel.Id); + Assert.Equal("ria_news", channel.PublicUsername); + } + private static User CreateUser(string username) => new() { @@ -129,7 +275,12 @@ public sealed class SearchControllerTests return chat; } - private static Message CreateMessage(Chat chat, User sender, string text, DateTimeOffset sentAt) + private static Message CreateMessage( + Chat chat, + User sender, + string text, + DateTimeOffset sentAt, + DateTimeOffset? expiresAt = null) { var message = new Message { @@ -139,7 +290,8 @@ public sealed class SearchControllerTests SenderId = sender.Id, Sender = sender, Text = text, - SentAt = sentAt + SentAt = sentAt, + ExpiresAt = expiresAt }; chat.Messages.Add(message); return message; diff --git a/src/Ikar.Server.Tests/StoryReplyControllerTests.cs b/src/Ikar.Server.Tests/StoryReplyControllerTests.cs new file mode 100644 index 00000000..920ccd71 --- /dev/null +++ b/src/Ikar.Server.Tests/StoryReplyControllerTests.cs @@ -0,0 +1,105 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; +using Microsoft.AspNetCore.Mvc; + +namespace Ikar.Server.Tests; + +public sealed class StoryReplyControllerTests +{ + [Fact] + public async Task SendMessage_WithStoryReplyStoryId_PersistsStoryReplySnapshot() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var author = CreateUser("author"); + var viewer = CreateUser("viewer"); + var chat = CreateDirectChat(author, viewer); + var story = new Story + { + Id = Guid.NewGuid(), + AuthorUserId = author.Id, + AuthorUser = author, + Text = "Morning update from the road", + Visibility = StoryVisibility.Everyone, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }; + dbContext.AddRange(author, viewer, chat, story); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.SendMessage( + chat.Id, + new SendMessageRequest("Looks good", StoryReplyStoryId: story.Id), + CancellationToken.None); + + var dto = ControllerTestFixture.ValueOf(response); + Assert.Equal("Looks good", dto.Text); + Assert.NotNull(dto.StoryReply); + Assert.Equal(story.Id, dto.StoryReply!.StoryId); + Assert.Equal(author.Id, dto.StoryReply.AuthorUserId); + Assert.Equal("Morning update from the road", dto.StoryReply.PreviewText); + Assert.Equal("text", dto.StoryReply.AttachmentKind); + + var storedMessage = Assert.Single(dbContext.Messages.Where(message => message.ChatId == chat.Id)); + Assert.Equal(story.Id, storedMessage.StoryReplyStoryId); + Assert.Equal(author.Id, storedMessage.StoryReplyAuthorUserId); + Assert.Equal("Morning update from the road", storedMessage.StoryReplyPreviewText); + Assert.Equal("text", storedMessage.StoryReplyAttachmentKind); + } + + [Fact] + public async Task SendMessage_WithStoryReplyStoryId_RejectsWrongDirectChat() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var author = CreateUser("author"); + var viewer = CreateUser("viewer"); + var other = CreateUser("other"); + var wrongChat = CreateDirectChat(viewer, other); + var story = new Story + { + Id = Guid.NewGuid(), + AuthorUserId = author.Id, + AuthorUser = author, + Text = "Morning update", + Visibility = StoryVisibility.Everyone, + CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }; + dbContext.AddRange(author, viewer, other, wrongChat, story); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); + var response = await controller.SendMessage( + wrongChat.Id, + new SendMessageRequest("Looks good", StoryReplyStoryId: story.Id), + CancellationToken.None); + + Assert.IsType(response.Result); + } + + private static User CreateUser(string username) => + new() + { + Id = Guid.NewGuid(), + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = username, + PasswordHash = "test" + }; + + private static Chat CreateDirectChat(User first, User second) + { + var chat = new Chat + { + Id = Guid.NewGuid(), + Type = ChatType.Direct, + CreatedById = first.Id, + CreatedBy = first + }; + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); + chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); + return chat; + } +} diff --git a/src/Ikar.Server/Controllers/AuthController.cs b/src/Ikar.Server/Controllers/AuthController.cs index 3abb808c..bef487d8 100644 --- a/src/Ikar.Server/Controllers/AuthController.cs +++ b/src/Ikar.Server/Controllers/AuthController.cs @@ -16,7 +16,9 @@ public sealed class AuthController( IkarDbContext dbContext, TokenService tokenService, PresenceTracker presenceTracker, - PhoneAuthChallengeStore phoneAuthChallengeStore) : ControllerBase + PhoneAuthChallengeStore phoneAuthChallengeStore, + EmailAuthChallengeStore emailAuthChallengeStore, + IEmailCodeSender emailCodeSender) : ControllerBase { private readonly PasswordHasher _passwordHasher = new(); @@ -146,12 +148,17 @@ public sealed class AuthController( var user = await dbContext.Users .AsNoTracking() .SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken); + if (user is null) + { + return NotFound("Phone code login is available only for existing accounts. Register by email."); + } + if (user?.IsBlocked == true) { return StatusCode(StatusCodes.Status423Locked, "User is blocked."); } - var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, user is not null); + var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, isRegistered: true); return Ok(new PhoneCodeChallengeDto( challenge.Id, challenge.ExpiresAt, @@ -186,13 +193,7 @@ public sealed class AuthController( if (user is null) { - var displayName = request.DisplayName?.Trim() ?? string.Empty; - if (string.IsNullOrWhiteSpace(displayName)) - { - return ValidationProblem("Display name is required for a new account."); - } - - user = await CreatePhoneOnlyUserAsync(normalizedPhoneNumber, request.PhoneNumber, displayName, cancellationToken); + return NotFound("Phone code login is available only for existing accounts. Register by email."); } if (user.IsBlocked) @@ -207,6 +208,147 @@ public sealed class AuthController( return Ok(session with { User = user.ToDto(presenceTracker) }); } + [HttpPost("request-email-code")] + [AllowAnonymous] + public async Task> RequestEmailCode( + RequestEmailCodeRequest request, + CancellationToken cancellationToken) + { + var normalizedEmail = EmailAddressNormalizer.Normalize(request.Email); + if (normalizedEmail is null) + { + return ValidationProblem("Valid email is required."); + } + + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); + if (normalizedPhoneNumber is null) + { + return ValidationProblem("Valid phone number is required."); + } + + var email = request.Email.Trim(); + var (user, conflictMessage) = await LoadEmailPhoneUserAsync( + normalizedEmail, + normalizedPhoneNumber, + cancellationToken); + if (conflictMessage is not null) + { + return Conflict(conflictMessage); + } + + if (RequiresAllowedRegistrationDomain(user, normalizedEmail) && + !EmailAddressNormalizer.IsAllowedRegistrationDomain(normalizedEmail)) + { + return BadRequest(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage); + } + + if (user?.IsBlocked == true) + { + return StatusCode(StatusCodes.Status423Locked, "User is blocked."); + } + + var challenge = emailAuthChallengeStore.Create(normalizedEmail, normalizedPhoneNumber, user is not null); + try + { + await emailCodeSender.SendLoginCodeAsync(email, challenge.Code, challenge.ExpiresAt, cancellationToken); + } + catch + { + emailAuthChallengeStore.Remove(challenge.Id); + throw; + } + + return Ok(new EmailCodeChallengeDto( + challenge.Id, + challenge.ExpiresAt, + challenge.IsRegistered)); + } + + [HttpPost("verify-email-code")] + [AllowAnonymous] + public async Task> VerifyEmailCode( + VerifyEmailCodeRequest request, + CancellationToken cancellationToken) + { + var normalizedEmail = EmailAddressNormalizer.Normalize(request.Email); + if (normalizedEmail is null) + { + return ValidationProblem("Valid email is required."); + } + + var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); + if (normalizedPhoneNumber is null) + { + return ValidationProblem("Valid phone number is required."); + } + + var challenge = emailAuthChallengeStore.Consume( + request.ChallengeId?.Trim() ?? string.Empty, + normalizedEmail, + normalizedPhoneNumber, + request.Code?.Trim() ?? string.Empty); + if (challenge is null) + { + return Unauthorized("Invalid or expired verification code."); + } + + var (user, conflictMessage) = await LoadEmailPhoneUserAsync( + normalizedEmail, + normalizedPhoneNumber, + cancellationToken); + if (conflictMessage is not null) + { + return Conflict(conflictMessage); + } + + if (RequiresAllowedRegistrationDomain(user, normalizedEmail) && + !EmailAddressNormalizer.IsAllowedRegistrationDomain(normalizedEmail)) + { + return BadRequest(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage); + } + + if (user is null) + { + var displayName = request.DisplayName?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(displayName)) + { + return ValidationProblem("Display name is required for a new account."); + } + + user = await CreateEmailPhoneUserAsync( + normalizedEmail, + request.Email, + normalizedPhoneNumber, + request.PhoneNumber, + displayName, + cancellationToken); + } + + if (user.IsBot) + { + return Unauthorized("Bot accounts cannot sign in through the user client."); + } + + if (user.IsBlocked) + { + return StatusCode(StatusCodes.Status423Locked, "User is blocked."); + } + + await EnsureEmailPhoneLinkedAsync( + user, + normalizedEmail, + request.Email, + normalizedPhoneNumber, + request.PhoneNumber, + cancellationToken); + + var session = await tokenService.CreateSessionAsync( + user, + ClientVersionRequestReader.Read(Request), + cancellationToken); + return Ok(session with { User = user.ToDto(presenceTracker) }); + } + [HttpPost("refresh")] [AllowAnonymous] public async Task> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken) @@ -258,6 +400,104 @@ public sealed class AuthController( return user; } + private async Task CreateEmailPhoneUserAsync( + string normalizedEmail, + string rawEmail, + string normalizedPhoneNumber, + string rawPhoneNumber, + string displayName, + CancellationToken cancellationToken) + { + var username = await GenerateInternalUsernameAsync(cancellationToken); + var user = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName, + Email = rawEmail.Trim(), + NormalizedEmail = normalizedEmail, + PhoneNumber = rawPhoneNumber.Trim(), + NormalizedPhoneNumber = normalizedPhoneNumber + }; + + user.PasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(cancellationToken); + return user; + } + + private async Task<(User? User, string? ConflictMessage)> LoadEmailPhoneUserAsync( + string normalizedEmail, + string normalizedPhoneNumber, + CancellationToken cancellationToken) + { + var users = await dbContext.Users + .Where(x => + x.NormalizedEmail == normalizedEmail || + x.NormalizedPhoneNumber == normalizedPhoneNumber) + .ToListAsync(cancellationToken); + + var emailUser = users.SingleOrDefault(x => x.NormalizedEmail == normalizedEmail); + var phoneUser = users.SingleOrDefault(x => x.NormalizedPhoneNumber == normalizedPhoneNumber); + if (emailUser is not null && phoneUser is not null && emailUser.Id != phoneUser.Id) + { + return (null, "Email and phone number belong to different accounts."); + } + + var user = emailUser ?? phoneUser; + if (user is null) + { + return (null, null); + } + + if (!string.IsNullOrWhiteSpace(user.NormalizedEmail) && + !string.Equals(user.NormalizedEmail, normalizedEmail, StringComparison.Ordinal)) + { + return (null, "Phone number is already linked to another email."); + } + + if (!string.IsNullOrWhiteSpace(user.NormalizedPhoneNumber) && + !string.Equals(user.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal)) + { + return (null, "Email is already linked to another phone number."); + } + + return (user, null); + } + + private static bool RequiresAllowedRegistrationDomain(User? user, string normalizedEmail) => + user is null || + !string.Equals(user.NormalizedEmail, normalizedEmail, StringComparison.Ordinal); + + private async Task EnsureEmailPhoneLinkedAsync( + User user, + string normalizedEmail, + string rawEmail, + string normalizedPhoneNumber, + string rawPhoneNumber, + CancellationToken cancellationToken) + { + var changed = false; + if (string.IsNullOrWhiteSpace(user.NormalizedEmail)) + { + user.Email = rawEmail.Trim(); + user.NormalizedEmail = normalizedEmail; + changed = true; + } + + if (string.IsNullOrWhiteSpace(user.NormalizedPhoneNumber)) + { + user.PhoneNumber = rawPhoneNumber.Trim(); + user.NormalizedPhoneNumber = normalizedPhoneNumber; + changed = true; + } + + if (changed) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + } + private async Task GenerateInternalUsernameAsync(CancellationToken cancellationToken) { while (true) diff --git a/src/Ikar.Server/Controllers/BotApiController.cs b/src/Ikar.Server/Controllers/BotApiController.cs index d9d7518b..1439de0c 100644 --- a/src/Ikar.Server/Controllers/BotApiController.cs +++ b/src/Ikar.Server/Controllers/BotApiController.cs @@ -4,8 +4,10 @@ using Ikar.Server.Data.Entities; using Ikar.Server.Infrastructure; using Ikar.Server.Infrastructure.Auth; using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Channels; using Ikar.Server.Infrastructure.Hubs; using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; using Ikar.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -23,8 +25,13 @@ public sealed class BotApiController( BotTokenService botTokenService, BotUpdateService botUpdateService, IHubContext hubContext, - PushDispatchQueue pushDispatchQueue) : ControllerBase + PushDispatchQueue pushDispatchQueue, + AttachmentStorageService attachmentStorageService) : ControllerBase { + private const long MaxBotAttachmentSizeBytes = 25 * 1024 * 1024; + private const long MaxBotMediaGroupSizeBytes = 100 * 1024 * 1024; + private const int MaxBotMediaGroupAttachments = 10; + [HttpGet("getMe")] public async Task> GetMe(string token, CancellationToken cancellationToken) { @@ -205,10 +212,334 @@ public sealed class BotApiController( chat.LastActivityAt = message.SentAt; dbContext.Messages.Add(message); + var discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + bot.User, + message.SentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + await dbContext.SaveChangesAsync(cancellationToken); await NotifyMessageCreatedAsync(chat, message, cancellationToken); await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (discussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken); + await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id); + } + + return Ok(message.ToDto(presenceTracker, bot.UserId)); + } + + [HttpPost("sendDocument")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)] + [RequestSizeLimit(MaxBotAttachmentSizeBytes)] + public Task> SendDocument( + string token, + [FromForm] BotSendAttachmentRequest request, + CancellationToken cancellationToken) => + SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: null, cancellationToken: cancellationToken); + + [HttpPost("sendPhoto")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)] + [RequestSizeLimit(MaxBotAttachmentSizeBytes)] + public Task> SendPhoto( + string token, + [FromForm] BotSendAttachmentRequest request, + CancellationToken cancellationToken) => + SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: BotAttachmentMediaKind.Photo, cancellationToken: cancellationToken); + + [HttpPost("sendVideo")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)] + [RequestSizeLimit(MaxBotAttachmentSizeBytes)] + public Task> SendVideo( + string token, + [FromForm] BotSendAttachmentRequest request, + CancellationToken cancellationToken) => + SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: BotAttachmentMediaKind.Video, cancellationToken: cancellationToken); + + [HttpPost("sendMediaGroup")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxBotMediaGroupSizeBytes)] + [RequestSizeLimit(MaxBotMediaGroupSizeBytes)] + public async Task> SendMediaGroup( + string token, + [FromForm] BotSendMediaGroupRequest request, + CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + if (request.ChatId == Guid.Empty) + { + return ValidationProblem("ChatId is required."); + } + + var files = request.Files + .Where(file => file is not null) + .ToList(); + + if (files.Count < 2) + { + return BadRequest("Media group must contain at least 2 files."); + } + + if (files.Count > MaxBotMediaGroupAttachments) + { + return BadRequest($"Media group can contain up to {MaxBotMediaGroupAttachments} files."); + } + + if (files.Any(file => file.Length <= 0)) + { + return BadRequest("Media group contains an empty file."); + } + + if (files.Any(file => file.Length > MaxBotAttachmentSizeBytes) || files.Sum(file => file.Length) > MaxBotMediaGroupSizeBytes) + { + return BadRequest($"Media group exceeds {MaxBotMediaGroupSizeBytes} bytes or contains a file over {MaxBotAttachmentSizeBytes} bytes."); + } + + if (files.Any(file => + !MatchesRequiredMediaKind(file, BotAttachmentMediaKind.Photo) && + !MatchesRequiredMediaKind(file, BotAttachmentMediaKind.Video))) + { + return BadRequest("sendMediaGroup accepts only image and video files."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync( + x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId), + cancellationToken); + if (chat is null) + { + return NotFound("Chat not found."); + } + + if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages) + { + return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat."); + } + + var sentAt = DateTimeOffset.UtcNow; + var message = new Message + { + ChatId = chat.Id, + Chat = chat, + SenderId = bot.UserId, + Sender = bot.User, + PostedAsChannel = chat.Type == ChatType.Channel, + AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled + ? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName + : null, + Text = request.Text?.Trim() ?? string.Empty, + MediaAlbumId = Guid.NewGuid(), + SentAt = sentAt + }; + + var savedAttachments = new List(); + Message? discussionRootMessage = null; + try + { + for (var index = 0; index < files.Count; index++) + { + var attachment = await attachmentStorageService.SaveAsync(message.Id, files[index], cancellationToken, sortOrder: index); + savedAttachments.Add(attachment); + message.Attachments.Add(attachment); + } + + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + bot.User, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + foreach (var attachment in savedAttachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + if (discussionRootMessage is not null) + { + DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath)); + } + + throw; + } + + await NotifyMessageCreatedAsync(chat, message, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (discussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken); + await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id); + } + + return Ok(message.ToDto(presenceTracker, bot.UserId)); + } + + [HttpPost("sendVoice")] + [RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)] + [RequestSizeLimit(MaxBotAttachmentSizeBytes)] + public Task> SendVoice( + string token, + [FromForm] BotSendAttachmentRequest request, + CancellationToken cancellationToken) => + SendAttachmentAsync(token, request, forceVoiceNote: true, requiredMediaKind: null, cancellationToken: cancellationToken); + + private async Task> SendAttachmentAsync( + string token, + BotSendAttachmentRequest request, + bool forceVoiceNote, + BotAttachmentMediaKind? requiredMediaKind, + CancellationToken cancellationToken) + { + var bot = await LoadBotByTokenAsync(token, cancellationToken); + if (bot is null) + { + return Unauthorized(); + } + + if (!bot.IsEnabled) + { + return StatusCode(StatusCodes.Status423Locked, "Bot is disabled."); + } + + if (request.ChatId == Guid.Empty) + { + return ValidationProblem("ChatId is required."); + } + + if (request.File is null) + { + return BadRequest("File is required."); + } + + if (request.File.Length <= 0) + { + return BadRequest("File is empty."); + } + + if (request.File.Length > MaxBotAttachmentSizeBytes) + { + return BadRequest($"File exceeds {MaxBotAttachmentSizeBytes} bytes."); + } + + if (requiredMediaKind is not null && !MatchesRequiredMediaKind(request.File, requiredMediaKind.Value)) + { + return BadRequest(requiredMediaKind == BotAttachmentMediaKind.Photo + ? "sendPhoto accepts only image files." + : "sendVideo accepts only video files."); + } + + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync( + x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId), + cancellationToken); + if (chat is null) + { + return NotFound("Chat not found."); + } + + if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages) + { + return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat."); + } + + var sentAt = DateTimeOffset.UtcNow; + var message = new Message + { + ChatId = chat.Id, + Chat = chat, + SenderId = bot.UserId, + Sender = bot.User, + PostedAsChannel = chat.Type == ChatType.Channel, + AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled + ? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName + : null, + Text = request.Text?.Trim() ?? string.Empty, + SentAt = sentAt + }; + + MessageAttachment? attachment = null; + Message? discussionRootMessage = null; + try + { + attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken); + if (forceVoiceNote || IsVoiceLikeAttachment(attachment)) + { + attachment.ContentType = ResolveVoiceContentType(attachment); + attachment.Kind = AttachmentKind.VoiceNote; + } + + message.Attachments.Add(attachment); + chat.LastActivityAt = message.SentAt; + dbContext.Messages.Add(message); + discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + bot.User, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + catch + { + if (attachment is not null) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + if (discussionRootMessage is not null) + { + DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath)); + } + + throw; + } + + await NotifyMessageCreatedAsync(chat, message, cancellationToken); + await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (discussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken); + await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id); + } return Ok(message.ToDto(presenceTracker, bot.UserId)); } @@ -257,6 +588,112 @@ public sealed class BotApiController( return Task.WhenAll(tasks); } + private void DeleteFiles(IEnumerable attachmentPaths) + { + foreach (var path in attachmentPaths) + { + attachmentStorageService.DeleteIfExists(path); + } + } + + private static string ResolveVoiceContentType(MessageAttachment attachment) + { + var normalized = attachment.ContentType + .Split(';', 2, StringSplitOptions.TrimEntries) + .FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(normalized) && + normalized.StartsWith("audio/", StringComparison.OrdinalIgnoreCase)) + { + return attachment.ContentType; + } + + var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(extension)) + { + var lastToken = attachment.OriginalFileName + .Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault() + ?.ToLowerInvariant(); + extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}"; + } + + return extension switch + { + ".aac" => "audio/aac", + ".flac" => "audio/flac", + ".m4a" => "audio/mp4", + ".mp3" => "audio/mpeg", + ".oga" or ".ogg" => "audio/ogg", + ".opus" => "audio/opus", + ".wav" => "audio/wav", + ".webm" => "audio/webm", + _ => "audio/ogg" + }; + } + + private static bool IsVoiceLikeAttachment(MessageAttachment attachment) + { + var normalized = attachment.ContentType + .Split(';', 2, StringSplitOptions.TrimEntries) + .FirstOrDefault() + ?.Trim() + .ToLowerInvariant(); + if (normalized is "audio/ogg" or "audio/opus" or "application/ogg" or "application/opus" or "application/x-ogg" or "video/ogg") + { + return true; + } + + var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(extension)) + { + var lastToken = attachment.OriginalFileName + .Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault() + ?.ToLowerInvariant(); + extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}"; + } + + return extension is ".ogg" or ".oga" or ".opus"; + } + + private static bool MatchesRequiredMediaKind(IFormFile file, BotAttachmentMediaKind requiredMediaKind) + { + var normalizedContentType = file.ContentType + ?.Split(';', 2, StringSplitOptions.TrimEntries) + .FirstOrDefault() + ?.Trim() + .ToLowerInvariant(); + + if (!string.IsNullOrWhiteSpace(normalizedContentType)) + { + if (requiredMediaKind == BotAttachmentMediaKind.Photo && + normalizedContentType.StartsWith("image/", StringComparison.Ordinal)) + { + return true; + } + + if (requiredMediaKind == BotAttachmentMediaKind.Video && + normalizedContentType.StartsWith("video/", StringComparison.Ordinal)) + { + return true; + } + } + + var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); + return requiredMediaKind switch + { + BotAttachmentMediaKind.Photo => extension is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp", + BotAttachmentMediaKind.Video => extension is ".mp4" or ".m4v" or ".mov" or ".webm" or ".mkv", + _ => false + }; + } + + private enum BotAttachmentMediaKind + { + Photo, + Video + } + private static List NormalizeCommands(IReadOnlyList commands) { var normalized = new List(); diff --git a/src/Ikar.Server/Controllers/BotsController.cs b/src/Ikar.Server/Controllers/BotsController.cs index 4c8e5ccb..561e2844 100644 --- a/src/Ikar.Server/Controllers/BotsController.cs +++ b/src/Ikar.Server/Controllers/BotsController.cs @@ -3,8 +3,9 @@ using Ikar.Server.Data; using Ikar.Server.Data.Entities; using Ikar.Server.Infrastructure; using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Bots; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Ikar.Shared; @@ -16,10 +17,10 @@ namespace Ikar.Server.Controllers; [Route("api/[controller]")] public sealed class BotsController( IkarDbContext dbContext, - BotTokenService botTokenService) : ControllerBase + BotTokenService botTokenService, + BotDeletionService botDeletionService) : ControllerBase { private static readonly Regex BotUsernameRegex = new("^[A-Za-z0-9_]{5,64}$", RegexOptions.Compiled); - private readonly PasswordHasher _passwordHasher = new(); [HttpGet("mine")] public async Task>> GetMine(CancellationToken cancellationToken) @@ -49,48 +50,12 @@ public sealed class BotsController( } [HttpPost] - public async Task> CreateBot( - CreateBotRequest request, - CancellationToken cancellationToken) + public ActionResult CreateBot(CreateBotRequest request) { - var ownerUserId = User.GetRequiredUserId(); - var validationError = await ValidateBotDefinitionAsync( - request.Username, - request.DisplayName, - request.About, - excludingBotId: null, - cancellationToken); - if (validationError is not null) - { - return ValidationProblem(validationError); - } - - var botUser = new User - { - Username = request.Username.Trim(), - NormalizedUsername = request.Username.Trim().ToUpperInvariant(), - DisplayName = request.DisplayName.Trim(), - About = string.IsNullOrWhiteSpace(request.About) ? null : request.About.Trim(), - IsBot = true - }; - botUser.PasswordHash = _passwordHasher.HashPassword(botUser, Guid.NewGuid().ToString("N")); - - var bot = new Bot - { - User = botUser, - OwnerUserId = ownerUserId - }; - - var rawToken = botTokenService.GenerateToken(bot.Id); - bot.TokenHash = botTokenService.HashToken(rawToken); - - dbContext.Bots.Add(bot); - await dbContext.SaveChangesAsync(cancellationToken); - - var created = await LoadOwnedBotAsync(bot.Id, ownerUserId, cancellationToken) - ?? throw new InvalidOperationException("Newly created bot was not found."); - - return Ok(new CreatedBotDto(created.ToDto(), rawToken)); + _ = request; + return StatusCode( + StatusCodes.Status410Gone, + "Создание ботов доступно только через чат с @botfatherbot. Напишите /создать_бота Имя бота | usernamebot | описание."); } [HttpPut("{botId:guid}")] @@ -185,36 +150,14 @@ public sealed class BotsController( [HttpDelete("{botId:guid}")] public async Task DeleteBot(Guid botId, CancellationToken cancellationToken) { - var ownerUserId = User.GetRequiredUserId(); - var bot = await dbContext.Bots - .Include(x => x.User) - .ThenInclude(x => x.Sessions) - .Include(x => x.User) - .ThenInclude(x => x.PushDevices) - .SingleOrDefaultAsync( - x => x.Id == botId && x.OwnerUserId == ownerUserId, - cancellationToken); - if (bot is null) + var deleted = await botDeletionService.DeleteBotAsync( + botId, + User.GetRequiredUserId(), + cancellationToken); + if (deleted is null) { return NotFound(); } - - var botCommands = await dbContext.BotCommands - .Where(x => x.BotId == bot.Id) - .ToListAsync(cancellationToken); - var botUpdates = await dbContext.BotUpdates - .Where(x => x.BotId == bot.Id) - .ToListAsync(cancellationToken); - - dbContext.BotUpdates.RemoveRange(botUpdates); - dbContext.BotCommands.RemoveRange(botCommands); - dbContext.UserSessions.RemoveRange(bot.User.Sessions); - dbContext.PushDevices.RemoveRange(bot.User.PushDevices); - bot.User.IsBlocked = true; - bot.User.BlockedAt = DateTimeOffset.UtcNow; - dbContext.Bots.Remove(bot); - - await dbContext.SaveChangesAsync(cancellationToken); return NoContent(); } diff --git a/src/Ikar.Server/Controllers/ChatsController.cs b/src/Ikar.Server/Controllers/ChatsController.cs index f1cf37a2..4db62791 100644 --- a/src/Ikar.Server/Controllers/ChatsController.cs +++ b/src/Ikar.Server/Controllers/ChatsController.cs @@ -6,6 +6,7 @@ using Ikar.Server.Data; using Ikar.Server.Data.Entities; using Ikar.Server.Infrastructure; using Ikar.Server.Infrastructure.Bots; +using Ikar.Server.Infrastructure.Channels; using Ikar.Server.Infrastructure.Hubs; using Ikar.Server.Infrastructure.Push; using Ikar.Server.Infrastructure.Storage; @@ -24,7 +25,8 @@ public sealed class ChatsController( IHubContext hubContext, AttachmentStorageService attachmentStorageService, PushDispatchQueue pushDispatchQueue, - BotUpdateService botUpdateService) : ControllerBase + BotUpdateService botUpdateService, + BotCreatorService botCreatorService) : ControllerBase { private const long MaxAttachmentSizeBytes = 25 * 1024 * 1024; private const long MaxAlbumSizeBytes = 100 * 1024 * 1024; @@ -98,6 +100,77 @@ public sealed class ChatsController( return Ok(folders); } + [HttpGet("public-channels")] + public async Task>> GetPublicChannels( + [FromQuery] string? q = null, + [FromQuery] int limit = 50, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + var channels = await LoadPublicChannelSearchResultsAsync( + q, + cancellationToken, + NormalizeSearchLimit(limit)); + return Ok(channels + .Select(chat => chat.ToSummaryDto(userId, presenceTracker)) + .ToList()); + } + + [HttpGet("public/{username}")] + public async Task> GetPublicChannelByUsername( + string username, + CancellationToken cancellationToken = default) + { + var userId = User.GetRequiredUserId(); + string? publicUsername; + try + { + publicUsername = NormalizePublicUsername(username); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + + if (publicUsername is null) + { + return BadRequest("Public username is required."); + } + + var chatId = await dbContext.Chats + .AsNoTracking() + .Where(chat => + chat.Type == ChatType.Channel && + chat.PublicUsername == publicUsername) + .Select(chat => (Guid?)chat.Id) + .SingleOrDefaultAsync(cancellationToken); + if (chatId is null) + { + return NotFound(); + } + + var chat = await LoadChatMetadata( + chatId.Value, + userId, + cancellationToken, + asNoTracking: false, + allowPublicChannelPreview: true); + if (chat is null) + { + return NotFound(); + } + + var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken); + return Ok(chat.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: page.Messages, + hasMoreMessages: page.HasMoreMessages, + pinnedMessagesOverride: pinnedMessages)); + } + [HttpGet("search")] public async Task>> Search( [FromQuery] string q, @@ -167,22 +240,20 @@ public sealed class ChatsController( var currentMembership = chat.Members.FirstOrDefault(member => member.UserId == userId); var previousReadAt = currentMembership?.LastReadAt; + var now = DateTimeOffset.UtcNow; var firstUnreadMessageId = previousReadAt is null ? null : await dbContext.Messages .Where(message => message.ChatId == chatId && message.SenderId != userId && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now) && message.SentAt > previousReadAt.Value) .OrderBy(message => message.SentAt) .Select(message => (Guid?)message.Id) .FirstOrDefaultAsync(cancellationToken); - if (currentMembership is not null && chat.Type != ChatType.Channel) - { - await UpdateReadReceiptForOpenedChatAsync(chat, userId, cancellationToken); - } - var page = await LoadMessagesPageAsync(chat, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken); var pinnedMessages = await LoadPinnedMessagesAsync(chat, cancellationToken); @@ -218,30 +289,44 @@ public sealed class ChatsController( hasMoreMessages: page.HasMoreMessages, pinnedMessagesOverride: pinnedMessages); + var now = DateTimeOffset.UtcNow; var mediaCount = await dbContext.MessageAttachments .CountAsync(x => x.Message.ChatId == chatId && x.Message.DeletedAt == null && + (x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) && (x.ContentType.StartsWith("image/") || x.ContentType.StartsWith("video/")), cancellationToken); var fileCount = await dbContext.MessageAttachments .CountAsync(x => x.Message.ChatId == chatId && x.Message.DeletedAt == null && + (x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) && !x.ContentType.StartsWith("image/") && !x.ContentType.StartsWith("video/") && !x.ContentType.StartsWith("audio/"), cancellationToken); var voiceCount = await dbContext.MessageAttachments - .CountAsync(x => x.Message.ChatId == chatId && x.Message.DeletedAt == null && x.ContentType.StartsWith("audio/"), cancellationToken); + .CountAsync(x => + x.Message.ChatId == chatId && + x.Message.DeletedAt == null && + (x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) && + x.ContentType.StartsWith("audio/"), + cancellationToken); var linkCount = await dbContext.Messages .CountAsync(x => x.ChatId == chatId && x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now) && (x.Text.Contains("http://") || x.Text.Contains("https://")), cancellationToken); var pinnedCount = await dbContext.Messages - .CountAsync(x => x.ChatId == chatId && x.DeletedAt == null && x.PinnedAt != null, cancellationToken); + .CountAsync(x => + x.ChatId == chatId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now) && + x.PinnedAt != null, + cancellationToken); var canInspectInviteLinks = ResolvePermissions(chat, userId).CanInviteUsers; var inviteLinks = canInspectInviteLinks ? await dbContext.ChatInviteLinks @@ -270,6 +355,7 @@ public sealed class ChatsController( chat.ChannelSignaturesEnabled, details.CanManageChannel, details.CanPostAsChannel, + canInspectInviteLinks, chat.Description)); } @@ -319,6 +405,20 @@ public sealed class ChatsController( return BadRequest("Direct chats do not support invite links."); } + DateTimeOffset? expiresAt; + int? maxUses; + int? memberLimit; + try + { + expiresAt = NormalizeInviteExpiresAt(request.ExpiresAt); + maxUses = NormalizeInviteLimit(request.MaxUses, nameof(request.MaxUses)); + memberLimit = NormalizeInviteLimit(request.MemberLimit, nameof(request.MemberLimit)); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + var link = new ChatInviteLink { ChatId = chatId, @@ -326,6 +426,9 @@ public sealed class ChatsController( Token = await GenerateInviteTokenAsync(cancellationToken), Title = TrimToNull(request.Title, 64), CreatesJoinRequest = request.CreatesJoinRequest, + ExpiresAt = expiresAt, + MaxUses = maxUses, + MemberLimit = memberLimit, CreatedAt = DateTimeOffset.UtcNow }; @@ -414,6 +517,7 @@ public sealed class ChatsController( } var joinRequest = await dbContext.ChatJoinRequests + .Include(x => x.InviteLink) .Include(x => x.User) .ThenInclude(x => x.Sessions) .Include(x => x.ResolvedByUser) @@ -435,7 +539,14 @@ public sealed class ChatsController( if (request.Approve && chat.Members.All(member => member.UserId != joinRequest.UserId)) { + var availabilityError = ValidateInviteAvailability(joinRequest.InviteLink, DateTimeOffset.UtcNow, chat.Members.Count); + if (availabilityError is not null) + { + return BadRequest(availabilityError); + } + chat.Members.Add(CreateSubscriberMembership(chat, joinRequest.UserId)); + joinRequest.InviteLink.UsesCount += 1; chat.LastActivityAt = DateTimeOffset.UtcNow; } @@ -453,7 +564,7 @@ public sealed class ChatsController( } [HttpPost("join/{token}")] - public async Task> JoinByInviteLink(string token, CancellationToken cancellationToken) + public async Task> JoinByInviteLink(string token, CancellationToken cancellationToken) { var userId = User.GetRequiredUserId(); var normalizedToken = token?.Trim() ?? string.Empty; @@ -472,13 +583,25 @@ public sealed class ChatsController( return BadRequest("Direct chats do not support invite links."); } + var isExistingMember = invite.Chat.Members.Any(x => x.UserId == userId); + if (!isExistingMember) + { + var availabilityError = ValidateInviteAvailability(invite, DateTimeOffset.UtcNow, invite.Chat.Members.Count); + if (availabilityError is not null) + { + return BadRequest(availabilityError); + } + } + if (invite.CreatesJoinRequest) { - if (invite.Chat.Members.Any(x => x.UserId == userId)) + if (isExistingMember) { var existingChat = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken) ?? throw new InvalidOperationException("Joined chat could not be loaded."); - return Ok(existingChat.ToDetailsDto(userId, presenceTracker)); + return Ok(new JoinChatResultDto( + JoinChatResultStatus.Joined, + existingChat.ToDetailsDto(userId, presenceTracker))); } var existingRequest = await dbContext.ChatJoinRequests @@ -504,24 +627,32 @@ public sealed class ChatsController( await dbContext.SaveChangesAsync(cancellationToken); } - return Accepted(new { joinRequestId = existingRequest.Id, status = existingRequest.Status.ToString() }); + return Accepted(new JoinChatResultDto( + JoinChatResultStatus.PendingApproval, + JoinRequestId: existingRequest.Id, + JoinRequestStatus: existingRequest.Status)); } - if (!invite.Chat.Members.Any(x => x.UserId == userId)) + if (!isExistingMember) { invite.Chat.Members.Add(CreateSubscriberMembership(invite.Chat, userId)); + invite.UsesCount += 1; invite.Chat.LastActivityAt = DateTimeOffset.UtcNow; await AddModerationLogAsync(userId, invite.ChatId, ModerationLogTargetType.InviteLink, invite.Id, "invite_link.joined", null, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); var joined = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken) ?? throw new InvalidOperationException("Joined chat could not be loaded."); await NotifyChatCreatedAsync(joined, cancellationToken); - return Ok(joined.ToDetailsDto(userId, presenceTracker)); + return Ok(new JoinChatResultDto( + JoinChatResultStatus.Joined, + joined.ToDetailsDto(userId, presenceTracker))); } var loaded = await LoadChatWithMessages(invite.ChatId, userId, cancellationToken) ?? throw new InvalidOperationException("Joined chat could not be loaded."); - return Ok(loaded.ToDetailsDto(userId, presenceTracker)); + return Ok(new JoinChatResultDto( + JoinChatResultStatus.Joined, + loaded.ToDetailsDto(userId, presenceTracker))); } [HttpPost("{chatId:guid}/subscribe")] @@ -651,6 +782,7 @@ public sealed class ChatsController( public async Task> GetMessages( Guid chatId, [FromQuery] Guid? beforeMessageId = null, + [FromQuery] Guid? aroundMessageId = null, [FromQuery] int limit = DefaultMessagePageSize, CancellationToken cancellationToken = default) { @@ -661,7 +793,9 @@ public sealed class ChatsController( return NotFound(); } - var page = await LoadMessagesPageAsync(chat, beforeMessageId, limit, cancellationToken); + var page = aroundMessageId is not null + ? await LoadMessagesAroundAsync(chat, aroundMessageId.Value, limit, cancellationToken) + : await LoadMessagesPageAsync(chat, beforeMessageId, limit, cancellationToken); await MarkChannelMessagesViewedAsync(chat, userId, page.Messages, cancellationToken); var messages = page.Messages .Select(message => message.ToDto(presenceTracker, userId)) @@ -770,6 +904,96 @@ public sealed class ChatsController( membership.FolderKey = NormalizeFolderKey(request.FolderKey); } + var notifyAllMembers = false; + if (request.Title is not null || + request.Description is not null || + request.ClearDescription || + request.PublicUsername is not null || + request.ClearPublicUsername) + { + if (chat.Type == ChatType.Direct) + { + return BadRequest("Direct chats do not support profile editing."); + } + + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can update chat profile."); + } + + if (request.Title is not null) + { + var title = request.Title.Trim(); + if (string.IsNullOrWhiteSpace(title)) + { + return BadRequest("Chat title is required."); + } + + chat.Title = title.Length <= 120 ? title : title[..120]; + } + + if (request.ClearDescription && request.Description is not null) + { + return BadRequest("Description cannot be set and cleared in the same request."); + } + + if (request.ClearDescription) + { + chat.Description = null; + } + else if (request.Description is not null) + { + chat.Description = TrimToNull(request.Description, 255); + } + + if (request.ClearPublicUsername && request.PublicUsername is not null) + { + return BadRequest("Public username cannot be set and cleared in the same request."); + } + + if (request.ClearPublicUsername || request.PublicUsername is not null) + { + if (chat.Type != ChatType.Channel) + { + return BadRequest("Only channels support public usernames."); + } + + string? publicUsername; + try + { + publicUsername = request.ClearPublicUsername + ? null + : NormalizePublicUsername(request.PublicUsername); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + + if (publicUsername is not null) + { + var usernameTaken = await dbContext.Chats + .AnyAsync(x => x.Id != chatId && x.PublicUsername == publicUsername, cancellationToken); + if (usernameTaken) + { + return Conflict("Public username is already taken."); + } + } + + chat.PublicUsername = publicUsername; + } + + notifyAllMembers = true; + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.Chat, + chatId, + "chat.profile.updated", + null, + cancellationToken); + } + if (request.HasProtectedContent is not null || request.DefaultMessageTtlSeconds is not null) { if (!CanManageMembers(chat, userId)) @@ -792,7 +1016,9 @@ public sealed class ChatsController( cancellationToken); } - if (request.ChannelSignaturesEnabled is not null || request.LinkedDiscussionChatId is not null) + if (request.ChannelSignaturesEnabled is not null || + request.LinkedDiscussionChatId is not null || + request.ClearLinkedDiscussionChat) { if (chat.Type != ChatType.Channel) { @@ -809,7 +1035,16 @@ public sealed class ChatsController( chat.ChannelSignaturesEnabled = request.ChannelSignaturesEnabled.Value; } - if (request.LinkedDiscussionChatId is not null) + if (request.ClearLinkedDiscussionChat && request.LinkedDiscussionChatId is not null) + { + return BadRequest("Linked discussion chat cannot be set and cleared in the same request."); + } + + if (request.ClearLinkedDiscussionChat) + { + chat.LinkedDiscussionChatId = null; + } + else if (request.LinkedDiscussionChatId is not null) { var discussionChatExists = await dbContext.Chats .AsNoTracking() @@ -840,7 +1075,7 @@ public sealed class ChatsController( var latestMessages = await LoadLatestMessagesByChatIdAsync([chatId], cancellationToken); var unreadCounts = await LoadUnreadCountsByChatIdAsync(userId, [chatId], cancellationToken); - await NotifyChatsChangedAsync([userId], cancellationToken); + await NotifyChatsChangedAsync(notifyAllMembers ? chat.Members.Select(member => member.UserId) : [userId], cancellationToken); return Ok(chat.ToSummaryDto( userId, presenceTracker, @@ -882,9 +1117,9 @@ public sealed class ChatsController( return NotFound(); } - if (chat.Type != ChatType.Channel) + if (chat.Type is not ChatType.Group and not ChatType.Channel) { - return BadRequest("Only channels support adding members with this endpoint."); + return BadRequest("Only groups and channels support adding members with this endpoint."); } if (!CanManageMembers(chat, userId)) @@ -923,8 +1158,18 @@ public sealed class ChatsController( return BadRequest("Some members do not exist."); } + foreach (var memberId in newMemberIds) + { + if (await IsBlockedByPrivacyAsync(userId, memberId, cancellationToken) || + await IsBlockedByPrivacyAsync(memberId, userId, cancellationToken)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Adding this member is blocked by privacy settings."); + } + } + var role = request.Role; var isAdmin = role == ChatMemberRole.Admin; + var defaultCanPostMessages = chat.Type == ChatType.Group && role == ChatMemberRole.Member; var joinedAt = DateTimeOffset.UtcNow; foreach (var memberId in newMemberIds) @@ -939,7 +1184,7 @@ public sealed class ChatsController( CanPinMessages = isAdmin || request.CanPinMessages, CanDeleteMessages = isAdmin || request.CanDeleteMessages, CanInviteUsers = isAdmin || request.CanInviteUsers, - CanPostMessages = isAdmin || request.CanPostMessages, + CanPostMessages = isAdmin || request.CanPostMessages || defaultCanPostMessages, JoinedAt = joinedAt }); @@ -1002,6 +1247,21 @@ public sealed class ChatsController( return BadRequest("Owner permissions cannot be changed by another member."); } + if (request.Role is not ChatMemberRole.Member and not ChatMemberRole.Admin and not ChatMemberRole.Owner) + { + return ValidationProblem("Unsupported member role."); + } + + if (request.Role == ChatMemberRole.Owner && !target.IsOwner) + { + return BadRequest("Ownership can only be transferred by a dedicated owner transfer action."); + } + + if (target.IsOwner && request.Role != ChatMemberRole.Owner) + { + return BadRequest("Owner role cannot be changed through member permissions."); + } + target.Role = request.Role; target.IsOwner = request.Role == ChatMemberRole.Owner; target.CanPinMessages = request.CanPinMessages; @@ -1020,6 +1280,166 @@ public sealed class ChatsController( return Ok(loaded.ToDetailsDto(userId, presenceTracker, messagesOverride: page.Messages, hasMoreMessages: page.HasMoreMessages, pinnedMessagesOverride: pinnedMessages)); } + [HttpPost("{chatId:guid}/members/{memberUserId:guid}/transfer-owner")] + public async Task> TransferOwnership( + Guid chatId, + Guid memberUserId, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync( + x => x.Id == chatId && + x.Members.Any(member => member.UserId == userId) && + x.Members.Any(member => member.UserId == memberUserId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (chat.Type is not ChatType.Group and not ChatType.Channel) + { + return BadRequest("Only groups and channels support ownership transfer."); + } + + var current = chat.Members.First(member => member.UserId == userId); + if (!current.IsOwner && current.Role != ChatMemberRole.Owner) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only owner can transfer ownership."); + } + + if (memberUserId == userId) + { + return BadRequest("Ownership target must be another member."); + } + + var target = chat.Members.First(member => member.UserId == memberUserId); + if (target.User.IsBot) + { + return BadRequest("Bot cannot become chat owner."); + } + + foreach (var member in chat.Members) + { + if (member.UserId == memberUserId) + { + member.IsOwner = true; + member.Role = ChatMemberRole.Owner; + member.CanPinMessages = true; + member.CanDeleteMessages = true; + member.CanInviteUsers = true; + member.CanPostMessages = true; + continue; + } + + if (member.IsOwner || member.Role == ChatMemberRole.Owner) + { + member.IsOwner = false; + member.Role = ChatMemberRole.Admin; + member.CanPinMessages = true; + member.CanDeleteMessages = true; + member.CanInviteUsers = true; + member.CanPostMessages = true; + } + } + + chat.CreatedById = memberUserId; + chat.LastActivityAt = DateTimeOffset.UtcNow; + + await AddModerationLogAsync( + userId, + chatId, + ModerationLogTargetType.User, + memberUserId, + "member.owner.transferred", + null, + cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId), cancellationToken); + + var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("The chat could not be reloaded."); + var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: page.Messages, + hasMoreMessages: page.HasMoreMessages, + pinnedMessagesOverride: pinnedMessages)); + } + + [HttpDelete("{chatId:guid}/members/{memberUserId:guid}")] + public async Task> RemoveMember( + Guid chatId, + Guid memberUserId, + CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .ThenInclude(x => x.User) + .SingleOrDefaultAsync( + x => x.Id == chatId && + x.Members.Any(member => member.UserId == userId) && + x.Members.Any(member => member.UserId == memberUserId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + if (chat.Type is not ChatType.Group and not ChatType.Channel) + { + return BadRequest("Only groups and channels support removing members."); + } + + var current = chat.Members.First(member => member.UserId == userId); + if (!CanManageMembers(chat, userId)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only admins can remove members."); + } + + if (memberUserId == userId) + { + return BadRequest("Use leave chat instead of removing yourself."); + } + + var target = chat.Members.First(member => member.UserId == memberUserId); + if (target.IsOwner || target.Role == ChatMemberRole.Owner) + { + return BadRequest("Owner cannot be removed."); + } + + if (!current.IsOwner && current.Role != ChatMemberRole.Owner && target.Role == ChatMemberRole.Admin) + { + return StatusCode(StatusCodes.Status403Forbidden, "Only owner can remove admins."); + } + + dbContext.ChatMembers.Remove(target); + chat.Members.Remove(target); + chat.LastActivityAt = DateTimeOffset.UtcNow; + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.User, memberUserId, "member.removed", null, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + await NotifyChatsChangedAsync(chat.Members.Select(member => member.UserId).Append(memberUserId), cancellationToken); + + var loaded = await LoadChatWithMessages(chatId, userId, cancellationToken) + ?? throw new InvalidOperationException("The chat could not be reloaded."); + var page = await LoadMessagesPageAsync(loaded, beforeMessageId: null, DefaultMessagePageSize, cancellationToken); + var pinnedMessages = await LoadPinnedMessagesAsync(loaded, cancellationToken); + return Ok(loaded.ToDetailsDto( + userId, + presenceTracker, + messagesOverride: page.Messages, + hasMoreMessages: page.HasMoreMessages, + pinnedMessagesOverride: pinnedMessages)); + } + [HttpDelete("{chatId:guid}")] public async Task DeleteChat( Guid chatId, @@ -1072,6 +1492,12 @@ public sealed class ChatsController( return NotFound(); } + if (chat.Type is ChatType.Group or ChatType.Channel && + (membership.IsOwner || membership.Role == ChatMemberRole.Owner)) + { + return BadRequest("Owner must transfer ownership or delete the chat for everyone."); + } + dbContext.ChatMembers.Remove(membership); chat.Members.Remove(membership); @@ -1416,6 +1842,27 @@ public sealed class ChatsController( return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish."); } + var storyReply = request.StoryReplyStoryId is null + ? null + : await LoadVisibleStoryForReplyAsync(request.StoryReplyStoryId.Value, userId, cancellationToken); + if (request.StoryReplyStoryId is not null && storyReply is null) + { + return NotFound("The story was not found."); + } + + if (storyReply is not null) + { + if (storyReply.AuthorUserId == userId) + { + return BadRequest("You cannot reply to your own story."); + } + + if (chat.Type != ChatType.Direct || chat.Members.All(member => member.UserId != storyReply.AuthorUserId)) + { + return BadRequest("Story replies can only be sent to the story author."); + } + } + var replyToMessage = await ResolveReplyTargetAsync(chatId, request.ReplyToMessageId, userId, cancellationToken); if (request.ReplyToMessageId is not null && replyToMessage is null) { @@ -1436,6 +1883,14 @@ public sealed class ChatsController( var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken); var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true); var sentAt = DateTimeOffset.UtcNow; + var commentedChannelPost = replyToMessage is null + ? null + : await LoadCommentedChannelPostAsync(replyToMessage, cancellationToken); + if (commentedChannelPost is not null) + { + commentedChannelPost.CommentCount++; + } + var message = new Message { ChatId = chatId, @@ -1451,20 +1906,59 @@ public sealed class ChatsController( HasProtectedContent = request.HasProtectedContent ?? chat.HasProtectedContent, TtlSeconds = ttlSeconds, ExpiresAt = ttlSeconds is null ? null : sentAt.AddSeconds(ttlSeconds.Value), - SentAt = sentAt + SentAt = sentAt, + StoryReplyStoryId = storyReply?.Id, + StoryReplyAuthorUserId = storyReply?.AuthorUserId, + StoryReplyPreviewText = storyReply is null ? null : BuildStoryReplyPreview(storyReply), + StoryReplyAttachmentKind = storyReply is null ? null : ResolveStoryReplyAttachmentKind(storyReply) }; chat.LastActivityAt = message.SentAt; dbContext.Messages.Add(message); + var discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + sender, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + await dbContext.SaveChangesAsync(cancellationToken); var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken) ?? throw new InvalidOperationException("The created message could not be reloaded."); + var loadedDiscussionRootMessage = discussionRootMessage is null + ? null + : await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken); + var updatedCommentedChannelPost = commentedChannelPost is null + ? null + : await LoadMessageForMemberAsync( + commentedChannelPost.ChatId, + commentedChannelPost.Id, + commentedChannelPost.SenderId, + cancellationToken); var dto = loadedMessage.ToDto(presenceTracker, userId); await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken); await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken); + await botCreatorService.TryHandleIncomingMessageAsync(loadedMessage.Id, cancellationToken); await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (loadedDiscussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id); + } + + if (updatedCommentedChannelPost is not null) + { + await NotifyMessageUpdatedAsync(updatedCommentedChannelPost.Chat, [updatedCommentedChannelPost], cancellationToken); + } return Ok(dto); } @@ -1530,6 +2024,7 @@ public sealed class ChatsController( }; var clonedAttachments = new List(); + Message? discussionRootMessage = null; try { foreach (var sourceAttachment in sourceMessage.Attachments) @@ -1544,6 +2039,19 @@ public sealed class ChatsController( targetChat.LastActivityAt = forwardedMessage.SentAt; dbContext.Messages.Add(forwardedMessage); + discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + targetChat, + forwardedMessage, + sender, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + forwardedMessage.DiscussionMessageId = discussionRootMessage.Id; + } + await dbContext.SaveChangesAsync(cancellationToken); } catch @@ -1553,16 +2061,31 @@ public sealed class ChatsController( attachmentStorageService.DeleteIfExists(clonedAttachment.StoragePath); } + if (discussionRootMessage is not null) + { + DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath)); + } + throw; } var loadedForwardedMessage = await LoadMessageForMemberAsync(targetChat.Id, forwardedMessage.Id, userId, cancellationToken) ?? throw new InvalidOperationException("The forwarded message could not be reloaded."); + var loadedDiscussionRootMessage = discussionRootMessage is null + ? null + : await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken); var dto = loadedForwardedMessage.ToDto(presenceTracker, userId); await NotifyMessageCreatedAsync(loadedForwardedMessage.Chat, loadedForwardedMessage, cancellationToken); await botUpdateService.EnqueueIncomingMessageAsync(loadedForwardedMessage.Id, cancellationToken); await pushDispatchQueue.EnqueueAsync(targetChat.Id, forwardedMessage.Id); + if (loadedDiscussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id); + } + return Ok(dto); } @@ -1580,6 +2103,25 @@ public sealed class ChatsController( CancellationToken cancellationToken) => AcknowledgeMessageReceiptAsync(chatId, request.MessageId, User.GetRequiredUserId(), markRead: true, cancellationToken); + [HttpPost("{chatId:guid}/read")] + public async Task MarkChatRead(Guid chatId, CancellationToken cancellationToken) + { + var userId = User.GetRequiredUserId(); + var chat = await dbContext.Chats + .Include(x => x.Members) + .SingleOrDefaultAsync( + x => x.Id == chatId && x.Members.Any(member => member.UserId == userId), + cancellationToken); + + if (chat is null) + { + return NotFound(); + } + + await UpdateReadReceiptForOpenedChatAsync(chat, userId, cancellationToken); + return Ok(); + } + [HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")] public async Task> SetReaction( Guid chatId, @@ -1721,6 +2263,14 @@ public sealed class ChatsController( var text = request.Text?.Trim() ?? string.Empty; var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true); var sentAt = DateTimeOffset.UtcNow; + var commentedChannelPost = replyToMessage is null + ? null + : await LoadCommentedChannelPostAsync(replyToMessage, cancellationToken); + if (commentedChannelPost is not null) + { + commentedChannelPost.CommentCount++; + } + var message = new Message { ChatId = chatId, @@ -1740,12 +2290,30 @@ public sealed class ChatsController( }; MessageAttachment? attachment = null; + Message? discussionRootMessage = null; try { attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken); + if (request.IsVoiceNote) + { + attachment.Kind = AttachmentKind.VoiceNote; + } message.Attachments.Add(attachment); chat.LastActivityAt = message.SentAt; dbContext.Messages.Add(message); + discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + sender, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + await dbContext.SaveChangesAsync(cancellationToken); } catch @@ -1755,16 +2323,42 @@ public sealed class ChatsController( attachmentStorageService.DeleteIfExists(attachment.StoragePath); } + if (discussionRootMessage is not null) + { + DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath)); + } + throw; } var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken) ?? throw new InvalidOperationException("The uploaded message could not be reloaded."); + var loadedDiscussionRootMessage = discussionRootMessage is null + ? null + : await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken); + var updatedCommentedChannelPost = commentedChannelPost is null + ? null + : await LoadMessageForMemberAsync( + commentedChannelPost.ChatId, + commentedChannelPost.Id, + commentedChannelPost.SenderId, + cancellationToken); var dto = loadedMessage.ToDto(presenceTracker, userId); await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken); await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken); await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (loadedDiscussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id); + } + + if (updatedCommentedChannelPost is not null) + { + await NotifyMessageUpdatedAsync(updatedCommentedChannelPost.Chat, [updatedCommentedChannelPost], cancellationToken); + } return Ok(dto); } @@ -1782,9 +2376,9 @@ public sealed class ChatsController( .Where(file => file is not null) .ToList(); - if (files.Count == 0) + if (files.Count < 2) { - return BadRequest("At least one file is required."); + return BadRequest("Albums must contain at least 2 files."); } if (files.Count > MaxAlbumAttachments) @@ -1802,6 +2396,11 @@ public sealed class ChatsController( return BadRequest($"Album exceeds {MaxAlbumSizeBytes} bytes or contains a file over {MaxAttachmentSizeBytes} bytes."); } + if (files.Any(file => !IsMediaAlbumFile(file))) + { + return BadRequest("Albums accept only image and video files."); + } + var chat = await dbContext.Chats .Include(x => x.Members) .FirstOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken); @@ -1836,6 +2435,14 @@ public sealed class ChatsController( var sender = await dbContext.Users.SingleAsync(x => x.Id == userId, cancellationToken); var ttlSeconds = NormalizeTtlSeconds(request.TtlSeconds ?? chat.DefaultMessageTtlSeconds, allowNull: true); var sentAt = DateTimeOffset.UtcNow; + var commentedChannelPost = replyToMessage is null + ? null + : await LoadCommentedChannelPostAsync(replyToMessage, cancellationToken); + if (commentedChannelPost is not null) + { + commentedChannelPost.CommentCount++; + } + var message = new Message { ChatId = chatId, @@ -1856,6 +2463,7 @@ public sealed class ChatsController( }; var savedAttachments = new List(); + Message? discussionRootMessage = null; try { for (var index = 0; index < files.Count; index++) @@ -1867,6 +2475,19 @@ public sealed class ChatsController( chat.LastActivityAt = message.SentAt; dbContext.Messages.Add(message); + discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync( + dbContext, + attachmentStorageService, + chat, + message, + sender, + sentAt, + cancellationToken); + if (discussionRootMessage is not null) + { + message.DiscussionMessageId = discussionRootMessage.Id; + } + await dbContext.SaveChangesAsync(cancellationToken); } catch @@ -1876,16 +2497,42 @@ public sealed class ChatsController( attachmentStorageService.DeleteIfExists(attachment.StoragePath); } + if (discussionRootMessage is not null) + { + DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath)); + } + throw; } var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken) ?? throw new InvalidOperationException("The uploaded album could not be reloaded."); + var loadedDiscussionRootMessage = discussionRootMessage is null + ? null + : await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken); + var updatedCommentedChannelPost = commentedChannelPost is null + ? null + : await LoadMessageForMemberAsync( + commentedChannelPost.ChatId, + commentedChannelPost.Id, + commentedChannelPost.SenderId, + cancellationToken); var dto = loadedMessage.ToDto(presenceTracker, userId); await NotifyMessageCreatedAsync(loadedMessage.Chat, loadedMessage, cancellationToken); await botUpdateService.EnqueueIncomingMessageAsync(loadedMessage.Id, cancellationToken); await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id); + if (loadedDiscussionRootMessage is not null) + { + await NotifyMessageCreatedAsync(loadedDiscussionRootMessage.Chat, loadedDiscussionRootMessage, cancellationToken); + await botUpdateService.EnqueueIncomingMessageAsync(loadedDiscussionRootMessage.Id, cancellationToken); + await pushDispatchQueue.EnqueueAsync(loadedDiscussionRootMessage.ChatId, loadedDiscussionRootMessage.Id); + } + + if (updatedCommentedChannelPost is not null) + { + await NotifyMessageUpdatedAsync(updatedCommentedChannelPost.Chat, [updatedCommentedChannelPost], cancellationToken); + } return Ok(dto); } @@ -1904,9 +2551,9 @@ public sealed class ChatsController( return NotFound(); } - if (message.SenderId != userId) + if (!CanEditMessage(message, userId)) { - return StatusCode(StatusCodes.Status403Forbidden, "Only the message author can edit it."); + return StatusCode(StatusCodes.Status403Forbidden, "You cannot edit this message."); } if (message.DeletedAt is not null) @@ -1922,13 +2569,28 @@ public sealed class ChatsController( message.Text = text; message.EditedAt = DateTimeOffset.UtcNow; + var discussionRootMessage = await LoadLinkedDiscussionRootForMutationAsync(message, cancellationToken); + if (discussionRootMessage is not null) + { + discussionRootMessage.Text = ChannelDiscussionMessageFactory.BuildDiscussionRootText(message); + discussionRootMessage.EditedAt = message.EditedAt; + } + await dbContext.SaveChangesAsync(cancellationToken); var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken) ?? throw new InvalidOperationException("The updated message could not be reloaded."); + var updatedDiscussionRootMessage = discussionRootMessage is null + ? null + : await LoadMessageForNotificationAsync(discussionRootMessage, cancellationToken); var dto = updatedMessage.ToDto(presenceTracker, userId); await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken); + if (updatedDiscussionRootMessage is not null) + { + await NotifyMessageUpdatedAsync(updatedDiscussionRootMessage.Chat, [updatedDiscussionRootMessage], cancellationToken); + } + return Ok(dto); } @@ -1952,6 +2614,7 @@ public sealed class ChatsController( return StatusCode(StatusCodes.Status403Forbidden, "You cannot delete this message."); } + var discussionRootMessage = await LoadLinkedDiscussionRootForMutationAsync(message, cancellationToken); var attachments = message.Attachments.ToList(); foreach (var attachment in attachments) { @@ -1972,14 +2635,46 @@ public sealed class ChatsController( message.PinnedAt = null; message.PinnedByUserId = null; message.PinnedByUser = null; + if (discussionRootMessage is not null && discussionRootMessage.DeletedAt is null) + { + var discussionAttachments = discussionRootMessage.Attachments.ToList(); + foreach (var attachment in discussionAttachments) + { + attachmentStorageService.DeleteIfExists(attachment.StoragePath); + } + + if (discussionRootMessage.Reactions.Count > 0) + { + dbContext.MessageReactions.RemoveRange(discussionRootMessage.Reactions); + discussionRootMessage.Reactions.Clear(); + } + + if (discussionRootMessage.Views.Count > 0) + { + dbContext.MessageViews.RemoveRange(discussionRootMessage.Views); + discussionRootMessage.Views.Clear(); + } + + dbContext.MessageAttachments.RemoveRange(discussionAttachments); + discussionRootMessage.Attachments.Clear(); + discussionRootMessage.Text = string.Empty; + discussionRootMessage.EditedAt = null; + discussionRootMessage.DeletedAt = message.DeletedAt; + discussionRootMessage.PinnedAt = null; + discussionRootMessage.PinnedByUserId = null; + discussionRootMessage.PinnedByUser = null; + } + await AddModerationLogAsync(userId, chatId, ModerationLogTargetType.Message, messageId, "message.deleted", null, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken) - ?? throw new InvalidOperationException("The deleted message could not be reloaded."); + var dto = message.ToDto(presenceTracker, userId); + await NotifyMessageUpdatedAsync(message.Chat, [message], cancellationToken); + if (discussionRootMessage is not null) + { + await NotifyMessageUpdatedAsync(discussionRootMessage.Chat, [discussionRootMessage], cancellationToken); + } - var dto = updatedMessage.ToDto(presenceTracker, userId); - await NotifyMessageUpdatedAsync(updatedMessage.Chat, [updatedMessage], cancellationToken); return Ok(dto); } @@ -1987,6 +2682,7 @@ public sealed class ChatsController( public async Task DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken) { var userId = User.GetRequiredUserId(); + var now = DateTimeOffset.UtcNow; var attachment = await dbContext.MessageAttachments .Include(x => x.Message) .ThenInclude(x => x.Chat) @@ -1994,6 +2690,8 @@ public sealed class ChatsController( .SingleOrDefaultAsync( x => x.Id == attachmentId && x.Message.ChatId == chatId && + x.Message.DeletedAt == null && + (x.Message.ExpiresAt == null || x.Message.ExpiresAt > now) && x.Message.Chat.Members.Any(m => m.UserId == userId), cancellationToken); @@ -2145,21 +2843,40 @@ public sealed class ChatsController( bool markRead, CancellationToken cancellationToken) { + var now = DateTimeOffset.UtcNow; var targetMessage = await dbContext.Messages .Include(x => x.Chat) .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Sender) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.Sessions) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.PrivacySettings) .Include(x => x.Attachments) .Include(x => x.Reactions) .Include(x => x.Views) .Include(x => x.ReplyToMessage) .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.ReplyToMessage) .ThenInclude(x => x!.Attachments) .SingleOrDefaultAsync( x => x.Id == messageId && x.ChatId == chatId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now) && x.Chat.Members.Any(member => member.UserId == userId), cancellationToken); @@ -2217,8 +2934,13 @@ public sealed class ChatsController( private async Task UpdateReadReceiptForOpenedChatAsync(Chat chat, Guid userId, CancellationToken cancellationToken) { + var now = DateTimeOffset.UtcNow; var latestIncomingAt = await dbContext.Messages - .Where(message => message.ChatId == chat.Id && message.SenderId != userId) + .Where(message => + message.ChatId == chat.Id && + message.SenderId != userId && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) .Select(message => (DateTimeOffset?)message.SentAt) .MaxAsync(cancellationToken); @@ -2328,21 +3050,40 @@ public sealed class ChatsController( DateTimeOffset changedBeforeOrAt, CancellationToken cancellationToken) { + var now = DateTimeOffset.UtcNow; var messages = await dbContext.Messages .Include(x => x.Chat) .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Sender) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.Sessions) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.PrivacySettings) .Include(x => x.Attachments) .Include(x => x.Reactions) .Include(x => x.Views) .Include(x => x.ReplyToMessage) .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.ReplyToMessage) .ThenInclude(x => x!.Attachments) .Where(x => x.ChatId == chatId && - x.SenderId != recipientUserId) + x.SenderId != recipientUserId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now)) .ToListAsync(cancellationToken); return messages @@ -2362,8 +3103,12 @@ public sealed class ChatsController( return new Dictionary(); } + var now = DateTimeOffset.UtcNow; var latestSentAtByChatQuery = dbContext.Messages - .Where(message => chatIds.Contains(message.ChatId)) + .Where(message => + chatIds.Contains(message.ChatId) && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) .GroupBy(message => message.ChatId) .Select(group => new { @@ -2372,6 +3117,9 @@ public sealed class ChatsController( }); var latestMessages = await dbContext.Messages + .Where(message => + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) .Join( latestSentAtByChatQuery, message => new { message.ChatId, message.SentAt }, @@ -2400,11 +3148,13 @@ public sealed class ChatsController( return new Dictionary(); } + var now = DateTimeOffset.UtcNow; var unreadCounts = await dbContext.Messages .Where(message => chatIds.Contains(message.ChatId) && message.SenderId != userId && - message.DeletedAt == null) + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) .Join( dbContext.ChatMembers.Where(member => member.UserId == userId), message => message.ChatId, @@ -2429,20 +3179,32 @@ public sealed class ChatsController( private async Task> LoadPinnedMessagesAsync(Chat chat, CancellationToken cancellationToken) { + var now = DateTimeOffset.UtcNow; var messages = await dbContext.Messages .Where(message => message.ChatId == chat.Id && message.PinnedAt != null && - message.DeletedAt == null) + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) .OrderByDescending(message => message.PinnedAt) .Take(5) .Include(message => message.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.Sender) + .ThenInclude(user => user.PrivacySettings) .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.Sessions) + .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.PrivacySettings) .Include(message => message.Attachments) .Include(message => message.Reactions) .Include(message => message.Views) .Include(message => message.ReplyToMessage) .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.PrivacySettings) .Include(message => message.ReplyToMessage) .ThenInclude(message => message!.Attachments) .AsNoTracking() @@ -2465,13 +3227,21 @@ public sealed class ChatsController( CancellationToken cancellationToken) { var limit = Math.Clamp(requestedLimit, 1, MaxMessagePageSize); - var query = dbContext.Messages.Where(message => message.ChatId == chat.Id); + var now = DateTimeOffset.UtcNow; + var query = dbContext.Messages.Where(message => + message.ChatId == chat.Id && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)); if (beforeMessageId is not null) { var boundary = await dbContext.Messages .AsNoTracking() - .Where(message => message.ChatId == chat.Id && message.Id == beforeMessageId.Value) + .Where(message => + message.ChatId == chat.Id && + message.Id == beforeMessageId.Value && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) .Select(message => new { message.SentAt }) .SingleOrDefaultAsync(cancellationToken); @@ -2487,12 +3257,22 @@ public sealed class ChatsController( .OrderByDescending(message => message.SentAt) .Take(limit + 1) .Include(message => message.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.Sender) + .ThenInclude(user => user.PrivacySettings) .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.Sessions) + .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.PrivacySettings) .Include(message => message.Attachments) .Include(message => message.Reactions) .Include(message => message.Views) .Include(message => message.ReplyToMessage) .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.PrivacySettings) .Include(message => message.ReplyToMessage) .ThenInclude(message => message!.Attachments) .AsNoTracking() @@ -2512,6 +3292,76 @@ public sealed class ChatsController( return new MessagesPage(pageMessages, hasMoreMessages); } + private async Task LoadMessagesAroundAsync( + Chat chat, + Guid aroundMessageId, + int requestedLimit, + CancellationToken cancellationToken) + { + var limit = Math.Clamp(requestedLimit, 1, MaxMessagePageSize); + var now = DateTimeOffset.UtcNow; + var boundary = await dbContext.Messages + .AsNoTracking() + .Where(message => + message.ChatId == chat.Id && + message.Id == aroundMessageId && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)) + .Select(message => new { message.SentAt }) + .SingleOrDefaultAsync(cancellationToken); + + if (boundary is null) + { + return new MessagesPage([], HasMoreMessages: false); + } + + var messages = await dbContext.Messages + .Where(message => + message.ChatId == chat.Id && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now) && + message.SentAt >= boundary.SentAt) + .OrderBy(message => message.SentAt) + .Take(limit) + .Include(message => message.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.Sender) + .ThenInclude(user => user.PrivacySettings) + .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.Sessions) + .Include(message => message.PinnedByUser) + .ThenInclude(user => user!.PrivacySettings) + .Include(message => message.Attachments) + .Include(message => message.Reactions) + .Include(message => message.Views) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.Sessions) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Sender) + .ThenInclude(user => user.PrivacySettings) + .Include(message => message.ReplyToMessage) + .ThenInclude(message => message!.Attachments) + .AsNoTracking() + .ToListAsync(cancellationToken); + + foreach (var message in messages) + { + message.Chat = chat; + } + + var hasMoreMessages = await dbContext.Messages + .AnyAsync( + message => + message.ChatId == chat.Id && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now) && + message.SentAt < boundary.SentAt, + cancellationToken); + + return new MessagesPage(messages, hasMoreMessages); + } + private async Task LoadChatMetadata( Guid chatId, Guid userId, @@ -2522,6 +3372,10 @@ public sealed class ChatsController( var query = dbContext.Chats .Include(x => x.Members) .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) .AsSplitQuery(); if (asNoTracking) @@ -2544,10 +3398,22 @@ public sealed class ChatsController( var query = dbContext.Chats .Include(x => x.Members) .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.Messages) .ThenInclude(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Messages) + .ThenInclude(x => x.Sender) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.Messages) .ThenInclude(x => x.PinnedByUser) + .ThenInclude(x => x!.Sessions) + .Include(x => x.Messages) + .ThenInclude(x => x.PinnedByUser) + .ThenInclude(x => x!.PrivacySettings) .Include(x => x.Messages) .ThenInclude(x => x.Attachments) .Include(x => x.Messages) @@ -2557,6 +3423,11 @@ public sealed class ChatsController( .Include(x => x.Messages) .ThenInclude(x => x.ReplyToMessage) .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Messages) + .ThenInclude(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.Messages) .ThenInclude(x => x.ReplyToMessage) .ThenInclude(x => x!.Attachments) @@ -2570,27 +3441,262 @@ public sealed class ChatsController( return await query.SingleOrDefaultAsync(x => x.Id == chatId && x.Members.Any(m => m.UserId == userId), cancellationToken); } + private async Task LoadVisibleStoryForReplyAsync( + Guid storyId, + Guid userId, + CancellationToken cancellationToken) + { + var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken); + var now = DateTimeOffset.UtcNow; + return await dbContext.Stories + .AsNoTracking() + .Include(x => x.Attachments) + .Where(x => + x.Id == storyId && + x.DeletedAt == null && + x.ExpiresAt > now) + .WhereVisibleTo(userId, peerAuthorIds) + .SingleOrDefaultAsync(cancellationToken); + } + + private async Task> LoadStoryPeerAuthorIdsAsync(Guid userId, CancellationToken cancellationToken) => + await dbContext.ChatMembers + .AsNoTracking() + .Where(member => member.UserId == userId) + .Join( + dbContext.ChatMembers.AsNoTracking().Where(member => member.UserId != userId), + mine => mine.ChatId, + peer => peer.ChatId, + (_, peer) => peer.UserId) + .Distinct() + .ToListAsync(cancellationToken); + + private static string BuildStoryReplyPreview(Story story) + { + var textPreview = story.Text + .Trim() + .ReplaceLineEndings(" ") + .Trim() + .Take(160) + .ToArray(); + var normalizedTextPreview = new string(textPreview); + if (!string.IsNullOrWhiteSpace(normalizedTextPreview)) + { + return normalizedTextPreview; + } + + if (story.Attachments.Count > 1) + { + return $"{story.Attachments.Count} медиа"; + } + + var contentType = story.Attachments.FirstOrDefault()?.ContentType?.Trim().ToLowerInvariant(); + return contentType switch + { + not null when contentType.StartsWith("image/", StringComparison.Ordinal) => "Фото сторис", + not null when contentType.StartsWith("video/", StringComparison.Ordinal) => "Видео сторис", + not null when contentType.StartsWith("audio/", StringComparison.Ordinal) => "Аудио сторис", + not null => "Медиа сторис", + _ => "Сторис" + }; + } + + private static string ResolveStoryReplyAttachmentKind(Story story) + { + if (story.Attachments.Count == 0) + { + return "text"; + } + + if (story.Attachments.Count > 1) + { + return "media"; + } + + var contentType = story.Attachments.First().ContentType.Trim().ToLowerInvariant(); + return contentType switch + { + _ when contentType.StartsWith("image/", StringComparison.Ordinal) => "image", + _ when contentType.StartsWith("video/", StringComparison.Ordinal) => "video", + _ when contentType.StartsWith("audio/", StringComparison.Ordinal) => "audio", + _ => "file" + }; + } + private async Task LoadMessageForMemberAsync(Guid chatId, Guid messageId, Guid userId, CancellationToken cancellationToken) { + var now = DateTimeOffset.UtcNow; return await dbContext.Messages .Include(x => x.Chat) .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Sender) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.Sessions) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.PrivacySettings) .Include(x => x.Attachments) .Include(x => x.Reactions) .Include(x => x.Views) .Include(x => x.ReplyToMessage) .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.ReplyToMessage) + .ThenInclude(x => x!.Sender) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.ReplyToMessage) .ThenInclude(x => x!.Attachments) .SingleOrDefaultAsync( x => x.Id == messageId && x.ChatId == chatId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now) && x.Chat.Members.Any(m => m.UserId == userId), cancellationToken); } + private Task LoadMessageForNotificationAsync(Message message, CancellationToken cancellationToken) + { + var viewerUserId = message.Chat.Members + .OrderByDescending(x => x.IsOwner) + .ThenBy(x => x.JoinedAt) + .Select(x => x.UserId) + .FirstOrDefault(); + if (viewerUserId == Guid.Empty) + { + viewerUserId = message.SenderId; + } + + return LoadMessageForMemberAsync(message.ChatId, message.Id, viewerUserId, cancellationToken); + } + + private async Task LoadCommentedChannelPostAsync(Message replyToMessage, CancellationToken cancellationToken) + { + var currentMessageId = (Guid?)replyToMessage.Id; + var visitedMessageIds = new HashSet(); + + while (currentMessageId is not null && visitedMessageIds.Add(currentMessageId.Value)) + { + var channelPost = await LoadChannelPostForDiscussionRootAsync(currentMessageId.Value, cancellationToken); + if (channelPost is not null) + { + return channelPost; + } + + currentMessageId = await dbContext.Messages + .Where(x => x.Id == currentMessageId.Value && x.ChatId == replyToMessage.ChatId) + .Select(x => x.ReplyToMessageId) + .SingleOrDefaultAsync(cancellationToken); + } + + return null; + } + + private async Task LoadLinkedDiscussionRootForMutationAsync(Message channelPost, CancellationToken cancellationToken) + { + if (channelPost.Chat.Type != ChatType.Channel || + !channelPost.PostedAsChannel || + channelPost.DiscussionMessageId is null) + { + return null; + } + + return await dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Sender) + .ThenInclude(x => x.PrivacySettings) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.Sessions) + .Include(x => x.PinnedByUser) + .ThenInclude(x => x!.PrivacySettings) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .Include(x => x.Views) + .SingleOrDefaultAsync( + x => x.Id == channelPost.DiscussionMessageId.Value && + x.ChatId == channelPost.Chat.LinkedDiscussionChatId && + x.Chat.Type == ChatType.Group, + cancellationToken); + } + + private async Task CreateLinkedDiscussionMessageAsync( + Chat channel, + Message channelMessage, + User sender, + DateTimeOffset sentAt, + CancellationToken cancellationToken) + { + if (channel.Type != ChatType.Channel || + !channelMessage.PostedAsChannel || + channel.LinkedDiscussionChatId is null) + { + return null; + } + + var discussionChat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync( + x => x.Id == channel.LinkedDiscussionChatId.Value && + x.Type == ChatType.Group && + x.Members.Any(member => member.UserId == sender.Id), + cancellationToken); + if (discussionChat is null) + { + return null; + } + + var rootMessage = new Message + { + ChatId = discussionChat.Id, + Chat = discussionChat, + SenderId = sender.Id, + Sender = sender, + Text = BuildDiscussionRootText(channelMessage), + SentAt = sentAt, + ForwardedFromDisplayName = string.IsNullOrWhiteSpace(channel.Title) ? "Канал" : channel.Title, + HasProtectedContent = channelMessage.HasProtectedContent, + TtlSeconds = channelMessage.TtlSeconds, + ExpiresAt = channelMessage.ExpiresAt + }; + + discussionChat.LastActivityAt = sentAt; + dbContext.Messages.Add(rootMessage); + return rootMessage; + } + + private Task LoadChannelPostForDiscussionRootAsync(Guid discussionMessageId, CancellationToken cancellationToken) => + dbContext.Messages + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .FirstOrDefaultAsync( + x => x.DiscussionMessageId == discussionMessageId && + x.Chat.Type == ChatType.Channel && + x.DeletedAt == null, + cancellationToken); + + private static string BuildDiscussionRootText(Message channelMessage) + { + var text = channelMessage.Text.Trim(); + return string.IsNullOrWhiteSpace(text) ? "Публикация" : text; + } + private Task ResolveReplyTargetAsync( Guid chatId, Guid? replyToMessageId, @@ -2602,12 +3708,15 @@ public sealed class ChatsController( return Task.FromResult(null); } + var now = DateTimeOffset.UtcNow; return dbContext.Messages .Include(x => x.Sender) .Include(x => x.Attachments) .SingleOrDefaultAsync( x => x.Id == replyToMessageId.Value && x.ChatId == chatId && + x.DeletedAt == null && + (x.ExpiresAt == null || x.ExpiresAt > now) && x.Chat.Members.Any(member => member.UserId == userId), cancellationToken); } @@ -2617,6 +3726,10 @@ public sealed class ChatsController( .Where(x => x.Members.Any(member => member.UserId == userId)) .Include(x => x.Members) .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.PrivacySettings) .AsNoTracking() .AsSplitQuery() .ToListAsync(cancellationToken); @@ -2708,26 +3821,70 @@ public sealed class ChatsController( } private async Task> LoadPublicChannelSearchResultsAsync( - string query, - CancellationToken cancellationToken) + string? query, + CancellationToken cancellationToken, + int? limit = null) { - var pattern = BuildLikePattern(query); - var usernameQuery = NormalizePublicSearchUsername(query); - var usernamePattern = usernameQuery is null ? null : BuildLikePattern(usernameQuery); - return await dbContext.Chats + var normalizedQuery = query?.Trim() ?? string.Empty; + var take = limit is null ? (int?)null : Math.Max(limit.Value, 1); + var candidateTake = take is null ? (int?)null : Math.Min(take.Value * 3, 300); + var channelsQuery = dbContext.Chats .Where(chat => chat.Type == ChatType.Channel && chat.PublicUsername != null && - chat.PublicUsername != string.Empty && - ((chat.Title != null && EF.Functions.Like(chat.Title, pattern, "\\")) || - EF.Functions.Like(chat.PublicUsername, pattern, "\\") || - (usernamePattern != null && EF.Functions.Like(chat.PublicUsername, usernamePattern, "\\")) || - (chat.Description != null && EF.Functions.Like(chat.Description, pattern, "\\")))) + chat.PublicUsername != string.Empty); + + if (!string.IsNullOrWhiteSpace(normalizedQuery)) + { + var queryPattern = BuildLikePattern(normalizedQuery); + var upperQueryPattern = BuildLikePattern(normalizedQuery.ToUpperInvariant()); + var lowerQueryPattern = BuildLikePattern(normalizedQuery.ToLowerInvariant()); + var usernameQuery = NormalizePublicSearchUsername(normalizedQuery); + var usernamePattern = string.IsNullOrWhiteSpace(usernameQuery) + ? null + : BuildLikePattern(usernameQuery); + + channelsQuery = channelsQuery.Where(chat => + EF.Functions.Like(chat.Title, queryPattern, "\\") || + EF.Functions.Like(chat.Title, upperQueryPattern, "\\") || + EF.Functions.Like(chat.Title, lowerQueryPattern, "\\") || + EF.Functions.Like(chat.PublicUsername!, queryPattern, "\\") || + EF.Functions.Like(chat.PublicUsername!, upperQueryPattern, "\\") || + EF.Functions.Like(chat.PublicUsername!, lowerQueryPattern, "\\") || + (usernamePattern != null && EF.Functions.Like(chat.PublicUsername!, usernamePattern, "\\")) || + (chat.Description != null && EF.Functions.Like(chat.Description, queryPattern, "\\")) || + (chat.Description != null && EF.Functions.Like(chat.Description, upperQueryPattern, "\\")) || + (chat.Description != null && EF.Functions.Like(chat.Description, lowerQueryPattern, "\\"))); + } + + channelsQuery = channelsQuery + .OrderByDescending(chat => chat.LastActivityAt ?? chat.CreatedAt) + .ThenBy(chat => chat.Title); + + if (candidateTake is not null) + { + channelsQuery = channelsQuery.Take(candidateTake.Value); + } + + var channels = await channelsQuery .Include(chat => chat.Members) .ThenInclude(member => member.User) .AsNoTracking() .AsSplitQuery() .ToListAsync(cancellationToken); + + IEnumerable matches = channels; + if (!string.IsNullOrWhiteSpace(normalizedQuery)) + { + matches = matches.Where(chat => MatchesPublicChannelSearch(chat, normalizedQuery)); + } + + if (take is not null) + { + matches = matches.Take(take.Value); + } + + return matches.ToList(); } private async Task SearchMessagesPageAsync( @@ -2794,8 +3951,12 @@ public sealed class ChatsController( return []; } + var now = DateTimeOffset.UtcNow; var messagesQuery = dbContext.Messages - .Where(message => chatIds.Contains(message.ChatId) && message.DeletedAt == null); + .Where(message => + chatIds.Contains(message.ChatId) && + message.DeletedAt == null && + (message.ExpiresAt == null || message.ExpiresAt > now)); if (!string.IsNullOrWhiteSpace(query)) { @@ -2840,6 +4001,7 @@ public sealed class ChatsController( !attachment.ContentType.StartsWith("audio/"))), "links" => messagesQuery.Where(message => message.Text.Contains("http://") || message.Text.Contains("https://")), "voice" => messagesQuery.Where(message => message.Attachments.Any(attachment => attachment.ContentType.StartsWith("audio/"))), + "pinned" => messagesQuery.Where(message => message.PinnedAt != null), _ => messagesQuery }; @@ -2896,6 +4058,31 @@ public sealed class ChatsController( (member.User.PhoneNumber?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false)); } + private static bool MatchesPublicChannelSearch(Chat chat, string query) + { + if (!string.IsNullOrWhiteSpace(chat.Title) && chat.Title.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(chat.PublicUsername) && + chat.PublicUsername.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var usernameQuery = NormalizePublicSearchUsername(query); + if (!string.IsNullOrWhiteSpace(usernameQuery) && + !string.IsNullOrWhiteSpace(chat.PublicUsername) && + chat.PublicUsername.Contains(usernameQuery, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return !string.IsNullOrWhiteSpace(chat.Description) && + chat.Description.Contains(query, StringComparison.OrdinalIgnoreCase); + } + private static string BuildSearchMatchText(Message message) { if (!string.IsNullOrWhiteSpace(message.Text)) @@ -2948,6 +4135,7 @@ public sealed class ChatsController( "image" or "images" or "video" or "videos" or "media" => "media", "link" or "links" or "url" or "urls" => "links", "audio" or "voice" or "voice-note" or "voice_note" or "voice-notes" or "voice_notes" => "voice", + "pin" or "pinned" or "pinned-message" or "pinned_message" or "pinned-messages" or "pinned_messages" => "pinned", "message" or "messages" or "text" => "text", _ => null }; @@ -3040,7 +4228,61 @@ public sealed class ChatsController( link.CreatesJoinRequest, link.IsRevoked, link.CreatedAt, - link.RevokedAt); + link.RevokedAt, + link.ExpiresAt, + link.MaxUses, + link.UsesCount, + link.MemberLimit); + + private static string? ValidateInviteAvailability(ChatInviteLink invite, DateTimeOffset now, int currentMemberCount) + { + if (invite.ExpiresAt is not null && invite.ExpiresAt <= now) + { + return "Invite link has expired."; + } + + if (invite.MaxUses is not null && invite.UsesCount >= invite.MaxUses.Value) + { + return "Invite link usage limit reached."; + } + + if (invite.MemberLimit is not null && currentMemberCount >= invite.MemberLimit.Value) + { + return "Chat member limit reached."; + } + + return null; + } + + private static DateTimeOffset? NormalizeInviteExpiresAt(DateTimeOffset? expiresAt) + { + if (expiresAt is null) + { + return null; + } + + if (expiresAt.Value <= DateTimeOffset.UtcNow) + { + throw new InvalidOperationException("Invite link expiration must be in the future."); + } + + return expiresAt.Value; + } + + private static int? NormalizeInviteLimit(int? value, string fieldName) + { + if (value is null) + { + return null; + } + + if (value.Value <= 0) + { + throw new InvalidOperationException($"{fieldName} must be greater than zero."); + } + + return value.Value; + } private ChatJoinRequestDto ToJoinRequestDto(ChatJoinRequest request) => new( @@ -3174,9 +4416,32 @@ public sealed class ChatsController( private static bool CanUserPublish(Chat chat, Guid userId) => ResolvePermissions(chat, userId).CanPostMessages; + private static bool CanEditMessage(Message message, Guid userId) => + message.SenderId == userId || + (message.Chat.Type == ChatType.Channel && ResolvePermissions(message.Chat, userId).CanPostMessages); + private static bool ShouldPostAsChannel(Chat chat) => chat.Type == ChatType.Channel; + private static bool IsMediaAlbumFile(IFormFile file) + { + var normalizedContentType = file.ContentType + ?.Split(';', 2, StringSplitOptions.TrimEntries) + .FirstOrDefault() + ?.Trim() + .ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(normalizedContentType) && + (normalizedContentType.StartsWith("image/", StringComparison.Ordinal) || + normalizedContentType.StartsWith("video/", StringComparison.Ordinal))) + { + return true; + } + + var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); + return extension is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp" or + ".mp4" or ".m4v" or ".mov" or ".webm" or ".mkv"; + } + private static string? ResolveChannelAuthorSignature(Chat chat, User sender) { if (chat.Type != ChatType.Channel || !chat.ChannelSignaturesEnabled) diff --git a/src/Ikar.Server/Controllers/StoriesController.cs b/src/Ikar.Server/Controllers/StoriesController.cs index 895bf7a8..d23c922d 100644 --- a/src/Ikar.Server/Controllers/StoriesController.cs +++ b/src/Ikar.Server/Controllers/StoriesController.cs @@ -1,10 +1,12 @@ using System.Net.Mime; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Ikar.Server.Data; using Ikar.Server.Data.Entities; using Ikar.Server.Infrastructure; +using Ikar.Server.Infrastructure.Hubs; using Ikar.Server.Infrastructure.Storage; using Ikar.Shared; @@ -16,6 +18,7 @@ namespace Ikar.Server.Controllers; public sealed class StoriesController( IkarDbContext dbContext, PresenceTracker presenceTracker, + IHubContext hubContext, AttachmentStorageService attachmentStorageService) : ControllerBase { private const int MaxStoryAttachmentCount = 10; @@ -32,6 +35,8 @@ public sealed class StoriesController( .AsNoTracking() .Include(x => x.AuthorUser) .ThenInclude(x => x.Sessions) + .Include(x => x.AuthorUser) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.Attachments) .Include(x => x.Views) .Where(x => @@ -42,7 +47,12 @@ public sealed class StoriesController( .Take(100) .ToListAsync(cancellationToken); - return Ok(stories.Select(x => x.ToDto(presenceTracker, userId)).ToList()); + return Ok(stories + .Select(x => x.ToDto( + presenceTracker, + userId, + sharesChatWithViewer: peerAuthorIds.Contains(x.AuthorUserId))) + .ToList()); } [HttpGet("{storyId:guid}")] @@ -55,6 +65,8 @@ public sealed class StoriesController( var story = await dbContext.Stories .Include(x => x.AuthorUser) .ThenInclude(x => x.Sessions) + .Include(x => x.AuthorUser) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.Attachments) .Include(x => x.Views) .Where(x => @@ -79,7 +91,10 @@ public sealed class StoriesController( await dbContext.SaveChangesAsync(cancellationToken); } - return Ok(story.ToDto(presenceTracker, userId)); + return Ok(story.ToDto( + presenceTracker, + userId, + sharesChatWithViewer: peerAuthorIds.Contains(story.AuthorUserId))); } [HttpPost] @@ -110,6 +125,7 @@ public sealed class StoriesController( var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken) ?? throw new InvalidOperationException("The created story could not be reloaded."); + await NotifyStoriesChangedAsync(loaded, cancellationToken); return Ok(loaded.ToDto(presenceTracker, userId)); } @@ -142,6 +158,12 @@ public sealed class StoriesController( return BadRequest($"Story upload is limited to {MaxStoryUploadSizeBytes} bytes."); } + var unsupportedFile = files.FirstOrDefault(file => !IsSupportedStoryMedia(file)); + if (unsupportedFile is not null) + { + return BadRequest($"Story media type is not supported: {unsupportedFile.ContentType}"); + } + var now = DateTimeOffset.UtcNow; var ttlSeconds = Math.Clamp(request.TtlSeconds, 60, 7 * 24 * 60 * 60); var story = new Story @@ -180,6 +202,7 @@ public sealed class StoriesController( var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken) ?? throw new InvalidOperationException("The created story could not be reloaded."); + await NotifyStoriesChangedAsync(loaded, cancellationToken); return Ok(loaded.ToDto(presenceTracker, userId)); } @@ -208,6 +231,7 @@ public sealed class StoriesController( story.DeletedAt = DateTimeOffset.UtcNow; await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.deleted", cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); + await NotifyStoriesChangedAsync(story, cancellationToken); } return NoContent(); @@ -217,6 +241,7 @@ public sealed class StoriesController( public async Task>> GetStoryViews(Guid storyId, CancellationToken cancellationToken) { var userId = User.GetRequiredUserId(); + var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken); var storyExists = await dbContext.Stories .AsNoTracking() .AnyAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken); @@ -229,13 +254,21 @@ public sealed class StoriesController( .AsNoTracking() .Include(x => x.ViewerUser) .ThenInclude(x => x.Sessions) + .Include(x => x.ViewerUser) + .ThenInclude(x => x.PrivacySettings) .Where(x => x.StoryId == storyId) .OrderByDescending(x => x.ViewedAt) .Take(250) .ToListAsync(cancellationToken); return Ok(views - .Select(x => new StoryViewDto(x.StoryId, x.ViewerUser.ToDto(presenceTracker), x.ViewedAt)) + .Select(x => new StoryViewDto( + x.StoryId, + x.ViewerUser.ToDtoForViewer( + presenceTracker, + userId, + sharesChatWithViewer: peerAuthorIds.Contains(x.ViewerUserId)), + x.ViewedAt)) .ToList()); } @@ -282,14 +315,53 @@ public sealed class StoriesController( .ToList(); } + private async Task NotifyStoriesChangedAsync(Story story, CancellationToken cancellationToken) + { + if (story.Visibility == StoryVisibility.Everyone) + { + await hubContext.Clients.All.SendAsync("StoriesChanged", cancellationToken); + return; + } + + var userIds = new List { story.AuthorUserId }; + if (story.Visibility is StoryVisibility.Contacts or StoryVisibility.CloseFriends) + { + userIds.AddRange(await LoadStoryPeerAuthorIdsAsync(story.AuthorUserId, cancellationToken)); + } + + await NotifyStoriesChangedAsync(userIds, cancellationToken); + } + + private Task NotifyStoriesChangedAsync(IEnumerable userIds, CancellationToken cancellationToken) + { + var affectedUsers = userIds + .Distinct() + .Select(userId => userId.ToString()) + .ToList(); + + return affectedUsers.Count == 0 + ? Task.CompletedTask + : hubContext.Clients.Users(affectedUsers).SendAsync("StoriesChanged", cancellationToken); + } + private async Task LoadStoryForAuthorAsync(Guid storyId, Guid userId, CancellationToken cancellationToken) => await dbContext.Stories .Include(x => x.AuthorUser) .ThenInclude(x => x.Sessions) + .Include(x => x.AuthorUser) + .ThenInclude(x => x.PrivacySettings) .Include(x => x.Attachments) .Include(x => x.Views) .SingleOrDefaultAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken); + private static bool IsSupportedStoryMedia(IFormFile file) + { + var contentType = file.ContentType?.Split(';', 2, StringSplitOptions.TrimEntries).FirstOrDefault(); + return !string.IsNullOrWhiteSpace(contentType) && + (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) || + contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)); + } + private async Task AddModerationLogAsync( Guid actorUserId, ModerationLogTargetType targetType, diff --git a/src/Ikar.Server/Controllers/UploadAttachmentRequest.cs b/src/Ikar.Server/Controllers/UploadAttachmentRequest.cs index 1539734f..7ba54bc3 100644 --- a/src/Ikar.Server/Controllers/UploadAttachmentRequest.cs +++ b/src/Ikar.Server/Controllers/UploadAttachmentRequest.cs @@ -14,6 +14,8 @@ public sealed class UploadAttachmentRequest public int? TtlSeconds { get; set; } + public bool IsVoiceNote { get; set; } + public IFormFile? File { get; set; } } @@ -32,6 +34,24 @@ public sealed class UploadAttachmentAlbumRequest public List Files { get; set; } = []; } +public sealed class BotSendAttachmentRequest +{ + public Guid ChatId { get; set; } + + public string? Text { get; set; } + + public IFormFile? File { get; set; } +} + +public sealed class BotSendMediaGroupRequest +{ + public Guid ChatId { get; set; } + + public string? Text { get; set; } + + public List Files { get; set; } = []; +} + public sealed class UploadStoryRequest { public string? Text { get; set; } diff --git a/src/Ikar.Server/Controllers/UsersController.cs b/src/Ikar.Server/Controllers/UsersController.cs index 3c051bf8..8ef93f86 100644 --- a/src/Ikar.Server/Controllers/UsersController.cs +++ b/src/Ikar.Server/Controllers/UsersController.cs @@ -19,6 +19,7 @@ public sealed class UsersController( AvatarStorageService avatarStorageService) : ControllerBase { private const long MaxAvatarSizeBytes = 5 * 1024 * 1024; + private const int MaxAboutLength = 200; private const int ResolveContactsBatchSize = 500; private static readonly HashSet AllowedAvatarContentTypes = [ @@ -58,6 +59,7 @@ public sealed class UsersController( var currentUserId = User.GetRequiredUserId(); var user = await dbContext.Users .Include(x => x.Sessions) + .Include(x => x.PrivacySettings) .AsNoTracking() .SingleOrDefaultAsync(x => x.Id == userId && !x.IsBlocked, cancellationToken); @@ -84,7 +86,7 @@ public sealed class UsersController( } } - return Ok(user.ToDto(presenceTracker)); + return Ok(await ToUserDtoForViewerAsync(user, currentUserId, cancellationToken)); } [HttpGet("{userId:guid}/bot-commands")] @@ -130,9 +132,9 @@ public sealed class UsersController( return ValidationProblem("Display name must be 120 characters or fewer."); } - if (about?.Length > 70) + if (about?.Length > MaxAboutLength) { - return ValidationProblem("About must be 70 characters or fewer."); + return ValidationProblem($"About must be {MaxAboutLength} characters or fewer."); } var user = await dbContext.Users @@ -208,7 +210,7 @@ public sealed class UsersController( public async Task> GetMyPrivacySettings(CancellationToken cancellationToken) { var settings = await GetOrCreatePrivacySettingsAsync(User.GetRequiredUserId(), cancellationToken); - return Ok(ToPrivacyDto(settings)); + return Ok(await ToPrivacyDtoAsync(settings, cancellationToken)); } [HttpPut("me/privacy")] @@ -239,7 +241,7 @@ public sealed class UsersController( settings.ShowMessagePreview = request.ShowMessagePreview; settings.UpdatedAt = DateTimeOffset.UtcNow; await dbContext.SaveChangesAsync(cancellationToken); - return Ok(ToPrivacyDto(settings)); + return Ok(await ToPrivacyDtoAsync(settings, cancellationToken)); } [HttpGet("me/sessions")] @@ -347,11 +349,33 @@ public sealed class UsersController( var users = await usersQuery .Include(x => x.Sessions) + .Include(x => x.PrivacySettings) .OrderBy(x => x.DisplayName) .Take(25) .ToListAsync(cancellationToken); - return Ok(users.Select(x => x.ToDto(presenceTracker)).ToList()); + var dtos = new List(); + foreach (var user in users) + { + if (normalizedPhone is not null && + string.Equals(user.NormalizedPhoneNumber, normalizedPhone, StringComparison.Ordinal) && + !await CanViewFieldByPrivacyAsync( + user.Id, + userId, + user.PrivacySettings?.PhoneNumberVisibility, + user.PrivacySettings?.AlwaysAllowUserIds, + user.PrivacySettings?.NeverAllowUserIds, + fallback: "contacts", + cancellationToken) && + !MatchesUserSearchWithoutPhone(user, query)) + { + continue; + } + + dtos.Add(await ToUserDtoForViewerAsync(user, userId, cancellationToken)); + } + + return Ok(dtos); } [HttpPost("contacts/resolve")] @@ -381,6 +405,7 @@ public sealed class UsersController( var batchPhones = batch.ToList(); var batchUsers = await dbContext.Users .Include(x => x.Sessions) + .Include(x => x.PrivacySettings) .AsNoTracking() .Where(x => x.Id != userId && @@ -392,13 +417,17 @@ public sealed class UsersController( users.AddRange(batchUsers); } - var matches = users + var orderedUsers = users .Where(x => x.NormalizedPhoneNumber is not null) .OrderBy(x => phoneOrder[x.NormalizedPhoneNumber!]) - .Select(x => new ContactMatchDto( - x.NormalizedPhoneNumber!, - x.ToDto(presenceTracker))) .ToList(); + var matches = new List(orderedUsers.Count); + foreach (var matchedUser in orderedUsers) + { + matches.Add(new ContactMatchDto( + matchedUser.NormalizedPhoneNumber!, + await ToUserDtoForViewerAsync(matchedUser, userId, cancellationToken))); + } return Ok(matches); } @@ -429,17 +458,61 @@ public sealed class UsersController( return settings; } - private static PrivacySettingsDto ToPrivacyDto(UserPrivacySettings settings) => - new( + private async Task ToPrivacyDtoAsync( + UserPrivacySettings settings, + CancellationToken cancellationToken) + { + var blockedUserIds = DeserializeUserIdList(settings.BlockedUserIds); + var alwaysAllowUserIds = DeserializeUserIdList(settings.AlwaysAllowUserIds); + var neverAllowUserIds = DeserializeUserIdList(settings.NeverAllowUserIds); + var usersById = await LoadPrivacyListUsersAsync( + blockedUserIds + .Concat(alwaysAllowUserIds) + .Concat(neverAllowUserIds) + .Distinct() + .ToList(), + cancellationToken); + + return new PrivacySettingsDto( settings.PhoneNumberVisibility, settings.LastSeenVisibility, settings.ProfilePhotoVisibility, - DeserializeUserIdList(settings.BlockedUserIds), - DeserializeUserIdList(settings.AlwaysAllowUserIds), - DeserializeUserIdList(settings.NeverAllowUserIds), + blockedUserIds, + alwaysAllowUserIds, + neverAllowUserIds, + ToOrderedPrivacyUsers(blockedUserIds, usersById), + ToOrderedPrivacyUsers(alwaysAllowUserIds, usersById), + ToOrderedPrivacyUsers(neverAllowUserIds, usersById), settings.PasscodeLockEnabled, settings.ShowMessagePreview, settings.UpdatedAt); + } + + private async Task> LoadPrivacyListUsersAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken) + { + if (userIds.Count == 0) + { + return []; + } + + var users = await dbContext.Users + .AsNoTracking() + .Include(x => x.Sessions) + .Where(x => userIds.Contains(x.Id) && !x.IsBlocked) + .ToListAsync(cancellationToken); + + return users.ToDictionary(x => x.Id, x => x.ToDto(presenceTracker)); + } + + private static IReadOnlyList ToOrderedPrivacyUsers( + IReadOnlyList userIds, + IReadOnlyDictionary usersById) => + userIds + .Select(userId => usersById.GetValueOrDefault(userId)) + .OfType() + .ToList(); private static string NormalizePrivacyVisibility(string? value, string fallback) { @@ -456,6 +529,64 @@ public sealed class UsersController( .Take(500) .Select(x => x.ToString("D"))); + private async Task ToUserDtoForViewerAsync( + User user, + Guid viewerUserId, + CancellationToken cancellationToken) + { + var dto = user.ToDto(presenceTracker); + if (user.Id == viewerUserId) + { + return dto; + } + + var privacy = user.PrivacySettings; + var canViewPhone = await CanViewFieldByPrivacyAsync( + user.Id, + viewerUserId, + privacy?.PhoneNumberVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "contacts", + cancellationToken); + var canViewLastSeen = await CanViewFieldByPrivacyAsync( + user.Id, + viewerUserId, + privacy?.LastSeenVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "contacts", + cancellationToken); + var canViewAvatar = await CanViewFieldByPrivacyAsync( + user.Id, + viewerUserId, + privacy?.ProfilePhotoVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "everyone", + cancellationToken); + + return dto with + { + PhoneNumber = canViewPhone ? dto.PhoneNumber : null, + LastSeenAt = canViewLastSeen ? dto.LastSeenAt : null, + IsOnline = canViewLastSeen && dto.IsOnline, + AvatarPath = canViewAvatar ? dto.AvatarPath : null + }; + } + + private static bool MatchesUserSearchWithoutPhone(User user, string? query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return true; + } + + var normalized = query.Trim().ToUpperInvariant(); + return user.NormalizedUsername.Contains(normalized, StringComparison.Ordinal) || + user.DisplayName.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase); + } + private Guid? TryGetAuthenticatedUserId() { var value = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -469,6 +600,25 @@ public sealed class UsersController( string? alwaysAllowUserIds, string? neverAllowUserIds, CancellationToken cancellationToken) + { + return await CanViewFieldByPrivacyAsync( + ownerUserId, + viewerUserId, + profilePhotoVisibility, + alwaysAllowUserIds, + neverAllowUserIds, + fallback: "everyone", + cancellationToken); + } + + private async Task CanViewFieldByPrivacyAsync( + Guid ownerUserId, + Guid? viewerUserId, + string? visibilityValue, + string? alwaysAllowUserIds, + string? neverAllowUserIds, + string fallback, + CancellationToken cancellationToken) { if (viewerUserId == ownerUserId) { @@ -481,7 +631,7 @@ public sealed class UsersController( return false; } - var visibility = NormalizePrivacyVisibility(profilePhotoVisibility, fallback: "everyone"); + var visibility = NormalizePrivacyVisibility(visibilityValue, fallback); if (viewerUserId is null) { return visibility == "everyone" && neverAllow.Count == 0; diff --git a/src/Ikar.Server/Data/Entities/Entities.cs b/src/Ikar.Server/Data/Entities/Entities.cs index 38faddcc..06c48bec 100644 --- a/src/Ikar.Server/Data/Entities/Entities.cs +++ b/src/Ikar.Server/Data/Entities/Entities.cs @@ -14,6 +14,8 @@ public sealed class User public DateTimeOffset? BlockedAt { get; set; } public string? PhoneNumber { get; set; } public string? NormalizedPhoneNumber { get; set; } + public string? Email { get; set; } + public string? NormalizedEmail { get; set; } public string? About { get; set; } public string? AvatarStoragePath { get; set; } public string? AvatarContentType { get; set; } @@ -96,6 +98,10 @@ public sealed class ChatInviteLink public bool IsRevoked { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? RevokedAt { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + public int? MaxUses { get; set; } + public int UsesCount { get; set; } + public int? MemberLimit { get; set; } } public sealed class ChatTopic @@ -170,11 +176,17 @@ public sealed class Message public Guid? MediaAlbumId { get; set; } public Guid? TopicId { get; set; } public ChatTopic? Topic { get; set; } + public Guid? StoryReplyStoryId { get; set; } + public Guid? StoryReplyAuthorUserId { get; set; } + public string? StoryReplyPreviewText { get; set; } + public string? StoryReplyAttachmentKind { get; set; } public bool HasProtectedContent { get; set; } public DateTimeOffset? ExpiresAt { get; set; } public int? TtlSeconds { get; set; } public bool PostedAsChannel { get; set; } public string? AuthorSignature { get; set; } + public Guid? DiscussionMessageId { get; set; } + public int CommentCount { get; set; } public ICollection Replies { get; set; } = new List(); public ICollection Attachments { get; set; } = new List(); @@ -190,6 +202,7 @@ public sealed class MessageAttachment public Message Message { get; set; } = null!; public string OriginalFileName { get; set; } = string.Empty; public string ContentType { get; set; } = "application/octet-stream"; + public AttachmentKind Kind { get; set; } = AttachmentKind.File; public long FileSizeBytes { get; set; } public string StoragePath { get; set; } = string.Empty; public int SortOrder { get; set; } diff --git a/src/Ikar.Server/Data/IkarDbContext.cs b/src/Ikar.Server/Data/IkarDbContext.cs index 8a755627..84cac056 100644 --- a/src/Ikar.Server/Data/IkarDbContext.cs +++ b/src/Ikar.Server/Data/IkarDbContext.cs @@ -1,4 +1,5 @@ using Ikar.Server.Data.Entities; +using Ikar.Shared; using System.Globalization; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -75,6 +76,9 @@ public sealed class IkarDbContext(DbContextOptions options) : DbC entity.HasIndex(x => x.NormalizedPhoneNumber) .IsUnique() .HasFilter("\"NormalizedPhoneNumber\" IS NOT NULL"); + entity.HasIndex(x => x.NormalizedEmail) + .IsUnique() + .HasFilter("\"NormalizedEmail\" IS NOT NULL"); entity.Property(x => x.Username).IsRequired(); entity.Property(x => x.NormalizedUsername).IsRequired(); @@ -248,6 +252,8 @@ public sealed class IkarDbContext(DbContextOptions options) : DbC entity.HasIndex(x => new { x.ChatId, x.PinnedAt }); entity.HasIndex(x => x.MediaAlbumId); entity.HasIndex(x => x.ExpiresAt); + entity.HasIndex(x => x.StoryReplyStoryId); + entity.HasIndex(x => x.DiscussionMessageId); entity.Property(x => x.Text).IsRequired(); @@ -295,6 +301,7 @@ public sealed class IkarDbContext(DbContextOptions options) : DbC entity.HasIndex(x => new { x.MessageId, x.SortOrder }); entity.Property(x => x.OriginalFileName).IsRequired(); entity.Property(x => x.ContentType).IsRequired(); + entity.Property(x => x.Kind).HasConversion().HasDefaultValue(AttachmentKind.File); entity.Property(x => x.StoragePath).IsRequired(); }); diff --git a/src/Ikar.Server/Infrastructure/Admin/AdminService.cs b/src/Ikar.Server/Infrastructure/Admin/AdminService.cs index 032e9dcc..2b50806a 100644 --- a/src/Ikar.Server/Infrastructure/Admin/AdminService.cs +++ b/src/Ikar.Server/Infrastructure/Admin/AdminService.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Ikar.Server.Data; using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Bots; using Ikar.Server.Infrastructure.Hubs; using Ikar.Server.Infrastructure.Storage; using Ikar.Shared; @@ -14,6 +15,7 @@ public sealed class AdminService( IWebHostEnvironment environment, ServerRuntimeState runtimeState, AttachmentStorageService attachmentStorageService, + BotDeletionService botDeletionService, IHubContext hubContext) { public async Task GetStatusAsync(CancellationToken cancellationToken) @@ -707,6 +709,27 @@ public sealed class AdminService( public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken) { + var userBotId = await dbContext.Bots + .AsNoTracking() + .Where(x => x.UserId == userId) + .Select(x => (Guid?)x.Id) + .SingleOrDefaultAsync(cancellationToken); + if (userBotId.HasValue) + { + var deletedBot = await botDeletionService.DeleteBotAsync(userBotId.Value, ownerUserId: null, cancellationToken); + return deletedBot is not null; + } + + var ownedBotIds = await dbContext.Bots + .AsNoTracking() + .Where(x => x.OwnerUserId == userId) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + foreach (var botId in ownedBotIds) + { + await botDeletionService.DeleteBotAsync(botId, ownerUserId: userId, cancellationToken); + } + var user = await dbContext.Users .Include(x => x.Sessions) .Include(x => x.PushDevices) @@ -812,10 +835,51 @@ public sealed class AdminService( dbContext.PushDevices.RemoveRange(user.PushDevices); } + var removedMessageIds = dbContext.ChangeTracker + .Entries() + .Where(x => x.State == EntityState.Deleted) + .Select(x => x.Entity.Id) + .ToHashSet(); + var remainingMessagesByUser = await dbContext.Messages + .Include(x => x.Attachments) + .Where(x => x.SenderId == userId && !removedMessageIds.Contains(x.Id)) + .ToListAsync(cancellationToken); + if (remainingMessagesByUser.Count > 0) + { + CollectAttachmentPaths(remainingMessagesByUser.SelectMany(x => x.Attachments), attachmentPathsToDelete); + dbContext.Messages.RemoveRange(remainingMessagesByUser); + } + + var storyAttachmentPaths = await dbContext.StoryAttachments + .Where(x => x.Story.AuthorUserId == userId) + .Select(x => x.StoragePath) + .ToListAsync(cancellationToken); + foreach (var path in storyAttachmentPaths.Where(x => !string.IsNullOrWhiteSpace(x))) + { + attachmentPathsToDelete.Add(path); + } + + var moderationEntries = await dbContext.ModerationLogEntries + .Where(x => x.ActorUserId == userId) + .ToListAsync(cancellationToken); + dbContext.ModerationLogEntries.RemoveRange(moderationEntries); + + var inviteLinks = await dbContext.ChatInviteLinks + .Where(x => x.CreatedById == userId) + .ToListAsync(cancellationToken); + dbContext.ChatInviteLinks.RemoveRange(inviteLinks); + + var topics = await dbContext.ChatTopics + .Where(x => x.CreatedById == userId) + .ToListAsync(cancellationToken); + dbContext.ChatTopics.RemoveRange(topics); + + var avatarPath = user.AvatarStoragePath; dbContext.Users.Remove(user); await dbContext.SaveChangesAsync(cancellationToken); DeleteFiles(attachmentPathsToDelete); + attachmentStorageService.DeleteIfExists(avatarPath); await NotifyChatsChangedAsync(affectedUserIds, cancellationToken); return true; diff --git a/src/Ikar.Server/Infrastructure/Auth/EmailAddressNormalizer.cs b/src/Ikar.Server/Infrastructure/Auth/EmailAddressNormalizer.cs new file mode 100644 index 00000000..6a22569f --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Auth/EmailAddressNormalizer.cs @@ -0,0 +1,48 @@ +using System.Net.Mail; + +namespace Ikar.Server.Infrastructure.Auth; + +public static class EmailAddressNormalizer +{ + private static readonly HashSet AllowedRegistrationDomains = new(StringComparer.OrdinalIgnoreCase) + { + "YANDEX.RU", + "MAIL.RU" + }; + + public const string UnsupportedRegistrationDomainMessage = + "Регистрация доступна только через почту yandex.ru или mail.ru."; + + public static string? Normalize(string? email) + { + var trimmed = email?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + try + { + var address = new MailAddress(trimmed); + return string.Equals(address.Address, trimmed, StringComparison.OrdinalIgnoreCase) + ? address.Address.ToUpperInvariant() + : null; + } + catch (FormatException) + { + return null; + } + } + + public static bool IsAllowedRegistrationDomain(string normalizedEmail) + { + var atIndex = normalizedEmail.LastIndexOf('@'); + if (atIndex < 0 || atIndex == normalizedEmail.Length - 1) + { + return false; + } + + var domain = normalizedEmail[(atIndex + 1)..]; + return AllowedRegistrationDomains.Contains(domain); + } +} diff --git a/src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs b/src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs new file mode 100644 index 00000000..5761448b --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs @@ -0,0 +1,76 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class EmailAuthChallengeStore +{ + private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(10); + private readonly ConcurrentDictionary _challenges = new(StringComparer.Ordinal); + + public EmailAuthChallenge Create(string normalizedEmail, string normalizedPhoneNumber, bool isRegistered) + { + CleanupExpired(); + + var challenge = new EmailAuthChallenge( + Guid.NewGuid().ToString("N"), + normalizedEmail, + normalizedPhoneNumber, + RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"), + DateTimeOffset.UtcNow.Add(ChallengeLifetime), + isRegistered); + + _challenges[challenge.Id] = challenge; + return challenge; + } + + public EmailAuthChallenge? Consume( + string challengeId, + string normalizedEmail, + string normalizedPhoneNumber, + string code) + { + CleanupExpired(); + + if (!_challenges.TryGetValue(challengeId, out var challenge)) + { + return null; + } + + if (!string.Equals(challenge.NormalizedEmail, normalizedEmail, StringComparison.Ordinal) || + !string.Equals(challenge.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal) || + !string.Equals(challenge.Code, code, StringComparison.Ordinal) || + challenge.ExpiresAt <= DateTimeOffset.UtcNow) + { + return null; + } + + _challenges.TryRemove(challenge.Id, out _); + return challenge; + } + + public void Remove(string challengeId) + { + _challenges.TryRemove(challengeId, out _); + } + + private void CleanupExpired() + { + var now = DateTimeOffset.UtcNow; + foreach (var pair in _challenges) + { + if (pair.Value.ExpiresAt <= now) + { + _challenges.TryRemove(pair.Key, out _); + } + } + } + + public sealed record EmailAuthChallenge( + string Id, + string NormalizedEmail, + string NormalizedPhoneNumber, + string Code, + DateTimeOffset ExpiresAt, + bool IsRegistered); +} diff --git a/src/Ikar.Server/Infrastructure/Auth/EmailAuthOptions.cs b/src/Ikar.Server/Infrastructure/Auth/EmailAuthOptions.cs new file mode 100644 index 00000000..5f2c9e7f --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Auth/EmailAuthOptions.cs @@ -0,0 +1,18 @@ +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class EmailAuthOptions +{ + public const string SectionName = "EmailAuth"; + + public string SmtpHost { get; init; } = string.Empty; + public int SmtpPort { get; init; } = 587; + public bool EnableSsl { get; init; } = true; + public string Username { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; + public string FromAddress { get; init; } = string.Empty; + public string FromName { get; init; } = "Ikar"; + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(SmtpHost) && + !string.IsNullOrWhiteSpace(FromAddress); +} diff --git a/src/Ikar.Server/Infrastructure/Auth/IEmailCodeSender.cs b/src/Ikar.Server/Infrastructure/Auth/IEmailCodeSender.cs new file mode 100644 index 00000000..a481c864 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Auth/IEmailCodeSender.cs @@ -0,0 +1,10 @@ +namespace Ikar.Server.Infrastructure.Auth; + +public interface IEmailCodeSender +{ + Task SendLoginCodeAsync( + string email, + string code, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default); +} diff --git a/src/Ikar.Server/Infrastructure/Auth/SmtpEmailCodeSender.cs b/src/Ikar.Server/Infrastructure/Auth/SmtpEmailCodeSender.cs new file mode 100644 index 00000000..70e47fa7 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Auth/SmtpEmailCodeSender.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Net.Mail; +using System.Text; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Infrastructure.Auth; + +public sealed class SmtpEmailCodeSender(IOptions options) : IEmailCodeSender +{ + private readonly EmailAuthOptions _options = options.Value; + + public async Task SendLoginCodeAsync( + string email, + string code, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default) + { + if (!_options.IsConfigured) + { + throw new InvalidOperationException("Email login is not configured on the server."); + } + + using var message = new MailMessage + { + From = new MailAddress(_options.FromAddress, _options.FromName), + Subject = "\u041a\u043e\u0434 \u0432\u0445\u043e\u0434\u0430 \u0432 \u0418\u043a\u0430\u0440", + SubjectEncoding = Encoding.UTF8, + Body = $"\u041a\u043e\u0434 \u0432\u0445\u043e\u0434\u0430 \u0432 \u0418\u043a\u0430\u0440: {code}{Environment.NewLine}\u041a\u043e\u0434 \u0434\u0435\u0439\u0441\u0442\u0432\u0443\u0435\u0442 \u0434\u043e {expiresAt:dd.MM.yyyy HH:mm:ss zzz}.", + BodyEncoding = Encoding.UTF8, + IsBodyHtml = false + }; + message.To.Add(new MailAddress(email)); + + using var client = new SmtpClient(_options.SmtpHost, _options.SmtpPort) + { + EnableSsl = _options.EnableSsl + }; + + if (!string.IsNullOrWhiteSpace(_options.Username)) + { + client.Credentials = new NetworkCredential(_options.Username, _options.Password); + } + + await client.SendMailAsync(message, cancellationToken); + } +} diff --git a/src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs b/src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs new file mode 100644 index 00000000..2de1541e --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs @@ -0,0 +1,490 @@ +using System.Text.RegularExpressions; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Push; +using Ikar.Shared; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Bots; + +public sealed class BotCreatorService( + IkarDbContext dbContext, + BotTokenService botTokenService, + BotDeletionService botDeletionService, + PresenceTracker presenceTracker, + IHubContext hubContext, + PushDispatchQueue pushDispatchQueue) +{ + public const string CreatorUsername = "botfatherbot"; + private const string CreatorDisplayName = "Создатель ботов"; + private static readonly Regex BotUsernameRegex = new("^[A-Za-z0-9_]{5,64}$", RegexOptions.Compiled); + private readonly PasswordHasher _passwordHasher = new(); + + public async Task EnsureCreatorBotAsync(CancellationToken cancellationToken = default) + { + var normalizedUsername = CreatorUsername.ToUpperInvariant(); + var user = await dbContext.Users + .Include(x => x.BotProfile) + .ThenInclude(x => x!.Commands) + .SingleOrDefaultAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken); + + if (user is null) + { + user = new User + { + Username = CreatorUsername, + NormalizedUsername = normalizedUsername, + DisplayName = CreatorDisplayName, + About = "Создаёт и настраивает ботов Икара через команды в чате.", + IsBot = true + }; + user.PasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(cancellationToken); + } + + if (!user.IsBot) + { + user.IsBot = true; + } + + user.DisplayName = CreatorDisplayName; + user.About = "Создаёт и настраивает ботов Икара через команды в чате."; + user.IsBlocked = false; + user.BlockedAt = null; + + var bot = user.BotProfile; + if (bot is null) + { + bot = new Bot + { + UserId = user.Id, + OwnerUserId = user.Id + }; + var rawToken = botTokenService.GenerateToken(bot.Id); + bot.TokenHash = botTokenService.HashToken(rawToken); + dbContext.Bots.Add(bot); + await dbContext.SaveChangesAsync(cancellationToken); + } + + bot.IsEnabled = true; + bot.UpdatedAt = DateTimeOffset.UtcNow; + await ReplaceCommandsAsync(bot.Id, CreatorCommands, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task TryHandleIncomingMessageAsync(Guid messageId, CancellationToken cancellationToken = default) + { + var message = await dbContext.Messages + .Include(x => x.Sender) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.BotProfile) + .SingleOrDefaultAsync(x => x.Id == messageId, cancellationToken); + + if (message is null || + message.Sender.IsBot || + message.Chat.Type != ChatType.Direct || + string.IsNullOrWhiteSpace(message.Text)) + { + return false; + } + + var creator = await LoadCreatorBotAsync(cancellationToken); + if (creator is null || message.Chat.Members.All(member => member.UserId != creator.UserId)) + { + return false; + } + + var replyText = await BuildReplyAsync(message.SenderId, message.Text.Trim(), cancellationToken); + await SendCreatorReplyAsync(message.Chat, creator.User, replyText, cancellationToken); + return true; + } + + private async Task BuildReplyAsync(Guid ownerUserId, string text, CancellationToken cancellationToken) + { + var parts = text.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var command = parts.FirstOrDefault()?.TrimStart('/').ToLowerInvariant(); + var payload = parts.Length > 1 ? parts[1].Trim() : string.Empty; + + return command switch + { + "start" or "help" or "помощь" => HelpText, + "newbot" or "создать_бота" => await CreateBotAsync(ownerUserId, payload, cancellationToken), + "mybots" or "мои_боты" => await ListBotsAsync(ownerUserId, cancellationToken), + "token" or "токен" => await RegenerateTokenAsync(ownerUserId, payload, cancellationToken), + "setcommands" or "команды" => await SetCommandsAsync(ownerUserId, payload, cancellationToken), + "disablebot" or "выключить_бота" => await SetBotEnabledAsync(ownerUserId, payload, isEnabled: false, cancellationToken), + "enablebot" or "включить_бота" => await SetBotEnabledAsync(ownerUserId, payload, isEnabled: true, cancellationToken), + "deletebot" or "удалить_бота" => await DeleteBotAsync(ownerUserId, payload, cancellationToken), + _ => "Я понимаю команды /создать_бота, /мои_боты, /токен, /команды, /выключить_бота, /включить_бота и /удалить_бота. Напишите /помощь для формата команд." + }; + } + + private async Task CreateBotAsync(Guid ownerUserId, string payload, CancellationToken cancellationToken) + { + var fields = SplitPipePayload(payload); + if (fields.Count < 2) + { + return "Формат: /создать_бота Имя бота | usernamebot | описание"; + } + + var displayName = fields[0]; + var username = NormalizeBotUsername(fields[1]); + var about = fields.Count >= 3 ? fields[2] : null; + + var validationError = await ValidateBotDefinitionAsync(username, displayName, about, excludingBotId: null, cancellationToken); + if (validationError is not null) + { + return validationError; + } + + var botUser = new User + { + Username = username, + NormalizedUsername = username.ToUpperInvariant(), + DisplayName = displayName, + About = string.IsNullOrWhiteSpace(about) ? null : about.Trim(), + IsBot = true + }; + botUser.PasswordHash = _passwordHasher.HashPassword(botUser, Guid.NewGuid().ToString("N")); + + var bot = new Bot + { + User = botUser, + OwnerUserId = ownerUserId + }; + + var rawToken = botTokenService.GenerateToken(bot.Id); + bot.TokenHash = botTokenService.HashToken(rawToken); + dbContext.Bots.Add(bot); + await dbContext.SaveChangesAsync(cancellationToken); + + return $"Бот создан: {displayName} (@{username})\nТокен:\n{rawToken}\n\nСохраните токен сейчас. Позже можно выпустить новый командой /токен {username}."; + } + + private async Task ListBotsAsync(Guid ownerUserId, CancellationToken cancellationToken) + { + var bots = await dbContext.Bots + .AsNoTracking() + .Include(x => x.User) + .Where(x => x.OwnerUserId == ownerUserId && x.User.NormalizedUsername != CreatorUsername.ToUpperInvariant()) + .OrderBy(x => x.User.DisplayName) + .ToListAsync(cancellationToken); + + if (bots.Count == 0) + { + return "У вас пока нет ботов. Создайте первого командой /создать_бота Имя | usernamebot | описание"; + } + + return "Ваши боты:\n" + string.Join( + "\n", + bots.Select(bot => $"{bot.User.DisplayName} (@{bot.User.Username}) — {(bot.IsEnabled ? "включён" : "выключен")}")); + } + + private async Task RegenerateTokenAsync(Guid ownerUserId, string payload, CancellationToken cancellationToken) + { + var bot = await LoadOwnedBotByUsernameAsync(ownerUserId, payload, cancellationToken); + if (bot is null) + { + return "Бот не найден. Формат: /токен usernamebot"; + } + + var rawToken = botTokenService.GenerateToken(bot.Id); + bot.TokenHash = botTokenService.HashToken(rawToken); + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return $"Новый токен для @{bot.User.Username}:\n{rawToken}"; + } + + private async Task SetCommandsAsync(Guid ownerUserId, string payload, CancellationToken cancellationToken) + { + var fields = SplitPipePayload(payload); + if (fields.Count < 2) + { + return "Формат: /команды usernamebot | старт - описание; помощь - описание"; + } + + var bot = await LoadOwnedBotByUsernameAsync(ownerUserId, fields[0], cancellationToken); + if (bot is null) + { + return "Бот не найден."; + } + + var commands = ParseCommandDefinitions(fields[1]); + if (commands.Count == 0) + { + return "Не нашёл корректных команд. Пример: /команды usernamebot | старт - старт; помощь - помощь"; + } + + await ReplaceCommandsAsync(bot.Id, commands, cancellationToken); + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return $"Команды @{bot.User.Username} обновлены."; + } + + private async Task SetBotEnabledAsync( + Guid ownerUserId, + string payload, + bool isEnabled, + CancellationToken cancellationToken) + { + var bot = await LoadOwnedBotByUsernameAsync(ownerUserId, payload, cancellationToken); + if (bot is null) + { + return $"Бот не найден. Формат: /{(isEnabled ? "включить_бота" : "выключить_бота")} usernamebot"; + } + + bot.IsEnabled = isEnabled; + bot.UpdatedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + return isEnabled ? $"@{bot.User.Username} включён." : $"@{bot.User.Username} выключен."; + } + + private async Task DeleteBotAsync(Guid ownerUserId, string payload, CancellationToken cancellationToken) + { + var bot = await LoadOwnedBotByUsernameAsync(ownerUserId, payload, cancellationToken, includeOwnedData: true); + if (bot is null) + { + return "Бот не найден. Формат: /удалить_бота usernamebot"; + } + + var username = bot.User.Username; + var deleted = await botDeletionService.DeleteBotAsync(bot.Id, ownerUserId, cancellationToken); + return deleted is null + ? "Бот не найден. Формат: /удалить_бота usernamebot" + : $"@{username} удалён."; + } + + private async Task LoadCreatorBotAsync(CancellationToken cancellationToken) => + await dbContext.Bots + .Include(x => x.User) + .SingleOrDefaultAsync(x => x.User.NormalizedUsername == CreatorUsername.ToUpperInvariant(), cancellationToken); + + private async Task LoadOwnedBotByUsernameAsync( + Guid ownerUserId, + string rawUsername, + CancellationToken cancellationToken, + bool includeOwnedData = false) + { + var username = NormalizeBotUsername(rawUsername); + if (string.IsNullOrWhiteSpace(username) || string.Equals(username, CreatorUsername, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var query = dbContext.Bots + .Include(x => x.User) + .Where(x => x.OwnerUserId == ownerUserId && x.User.NormalizedUsername == username.ToUpperInvariant()); + + if (includeOwnedData) + { + query = query + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.User) + .ThenInclude(x => x.PushDevices); + } + + return await query.SingleOrDefaultAsync(cancellationToken); + } + + private async Task ValidateBotDefinitionAsync( + string? username, + string? displayName, + string? about, + Guid? excludingBotId, + CancellationToken cancellationToken) + { + var trimmedUsername = username?.Trim() ?? string.Empty; + var trimmedDisplayName = displayName?.Trim() ?? string.Empty; + var normalizedUsername = trimmedUsername.ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(trimmedDisplayName)) + { + return "Укажите имя бота."; + } + + if (trimmedDisplayName.Length > 120) + { + return "Имя бота должно быть не длиннее 120 символов."; + } + + if (!string.IsNullOrWhiteSpace(about) && about.Trim().Length > 70) + { + return "Описание должно быть не длиннее 70 символов."; + } + + if (!BotUsernameRegex.IsMatch(trimmedUsername) || + !trimmedUsername.EndsWith("bot", StringComparison.OrdinalIgnoreCase)) + { + return "Username бота должен содержать латинские буквы, цифры или подчёркивание и заканчиваться на bot."; + } + + if (string.Equals(trimmedUsername, CreatorUsername, StringComparison.OrdinalIgnoreCase)) + { + return "Этот username зарезервирован системным ботом."; + } + + var usernameTaken = await dbContext.Users + .AsNoTracking() + .Include(x => x.BotProfile) + .AnyAsync( + x => x.NormalizedUsername == normalizedUsername && + (!excludingBotId.HasValue || x.BotProfile == null || x.BotProfile.Id != excludingBotId.Value), + cancellationToken); + return usernameTaken ? "Username уже занят." : null; + } + + private async Task ReplaceCommandsAsync(Guid botId, IReadOnlyList commands, CancellationToken cancellationToken) + { + var existingCommands = await dbContext.BotCommands + .Where(x => x.BotId == botId) + .ToListAsync(cancellationToken); + dbContext.BotCommands.RemoveRange(existingCommands); + + var normalizedCommands = NormalizeCommands(commands); + for (var index = 0; index < normalizedCommands.Count; index++) + { + dbContext.BotCommands.Add(new BotCommand + { + BotId = botId, + Command = normalizedCommands[index].Command, + Description = normalizedCommands[index].Description, + SortOrder = index + }); + } + } + + private async Task SendCreatorReplyAsync(Chat chat, User creatorUser, string text, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var reply = new Message + { + ChatId = chat.Id, + Chat = chat, + SenderId = creatorUser.Id, + Sender = creatorUser, + Text = text, + SentAt = now + }; + + chat.LastActivityAt = now; + dbContext.Messages.Add(reply); + await dbContext.SaveChangesAsync(cancellationToken); + + var loadedReply = await dbContext.Messages + .Include(x => x.Sender) + .ThenInclude(x => x.Sessions) + .Include(x => x.Chat) + .ThenInclude(x => x.Members) + .ThenInclude(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.Attachments) + .Include(x => x.Reactions) + .SingleAsync(x => x.Id == reply.Id, cancellationToken); + + var tasks = loadedReply.Chat.Members.Select(async member => + { + var dto = loadedReply.ToDto(presenceTracker, member.UserId); + await hubContext.Clients.User(member.UserId.ToString()) + .SendAsync("MessageCreated", dto, cancellationToken); + }); + await Task.WhenAll(tasks); + await pushDispatchQueue.EnqueueAsync(loadedReply.ChatId, loadedReply.Id); + } + + private static string NormalizeBotUsername(string value) => value.Trim().TrimStart('@'); + + private static List SplitPipePayload(string payload) => + payload + .Split('|', StringSplitOptions.TrimEntries) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToList(); + + private static List ParseCommandDefinitions(string value) => + value + .Split([';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(ParseCommandDefinition) + .Where(x => x is not null) + .Cast() + .ToList(); + + private static BotCommandDto? ParseCommandDefinition(string value) + { + var parts = value.Split(" - ", 2, StringSplitOptions.TrimEntries); + if (parts.Length < 2) + { + parts = value.Split(':', 2, StringSplitOptions.TrimEntries); + } + + if (parts.Length < 2) + { + return null; + } + + return new BotCommandDto(parts[0], parts[1]); + } + + private static List NormalizeCommands(IReadOnlyList commands) + { + var normalized = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var command in commands) + { + var normalizedCommand = (command.Command ?? string.Empty).Trim().TrimStart('/'); + var normalizedDescription = (command.Description ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedCommand) || string.IsNullOrWhiteSpace(normalizedDescription)) + { + continue; + } + + if (!Regex.IsMatch(normalizedCommand, @"^[\p{L}0-9_]{1,32}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + { + continue; + } + + if (!seen.Add(normalizedCommand)) + { + continue; + } + + normalized.Add(new BotCommandDto(normalizedCommand.ToLowerInvariant(), normalizedDescription)); + } + + return normalized; + } + + private static readonly IReadOnlyList CreatorCommands = + [ + new("помощь", "показать команды"), + new("создать_бота", "создать бота"), + new("мои_боты", "показать моих ботов"), + new("токен", "выпустить новый токен"), + new("команды", "настроить команды бота"), + new("выключить_бота", "выключить бота"), + new("включить_бота", "включить бота"), + new("удалить_бота", "удалить бота") + ]; + + private const string HelpText = + """ + Я создаю и настраиваю ботов Икара. + + /создать_бота Имя бота | usernamebot | описание + /мои_боты + /токен usernamebot + /команды usernamebot | старт - описание; помощь - описание + /выключить_бота usernamebot + /включить_бота usernamebot + /удалить_бота usernamebot + + Username должен быть латиницей и заканчиваться на bot. + """; +} diff --git a/src/Ikar.Server/Infrastructure/Bots/BotDeletionService.cs b/src/Ikar.Server/Infrastructure/Bots/BotDeletionService.cs new file mode 100644 index 00000000..a006b0e9 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Bots/BotDeletionService.cs @@ -0,0 +1,131 @@ +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Bots; + +public sealed class BotDeletionService( + IkarDbContext dbContext, + AttachmentStorageService attachmentStorageService) +{ + public async Task DeleteBotAsync( + Guid botId, + Guid? ownerUserId, + CancellationToken cancellationToken) + { + var bot = await dbContext.Bots + .Include(x => x.User) + .ThenInclude(x => x.Sessions) + .Include(x => x.User) + .ThenInclude(x => x.PushDevices) + .SingleOrDefaultAsync( + x => x.Id == botId && (!ownerUserId.HasValue || x.OwnerUserId == ownerUserId.Value), + cancellationToken); + if (bot is null) + { + return null; + } + + var result = new BotDeletionResult(bot.Id, bot.UserId, bot.User.Username); + var botUserId = bot.UserId; + var attachmentPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + var ownedBotIds = await dbContext.Bots + .AsNoTracking() + .Where(x => x.OwnerUserId == botUserId && x.Id != bot.Id) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + foreach (var ownedBotId in ownedBotIds) + { + await DeleteBotAsync(ownedBotId, ownerUserId: null, cancellationToken); + } + + var directChats = await dbContext.Chats + .Include(x => x.Members) + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .Where(x => x.Type == ChatType.Direct && x.Members.Any(member => member.UserId == botUserId)) + .ToListAsync(cancellationToken); + var directChatIds = directChats.Select(x => x.Id).ToHashSet(); + foreach (var chat in directChats) + { + CollectAttachmentPaths(chat.Messages.SelectMany(x => x.Attachments), attachmentPaths); + dbContext.Chats.Remove(chat); + } + + var botMessages = await dbContext.Messages + .Include(x => x.Attachments) + .Where(x => x.SenderId == botUserId && !directChatIds.Contains(x.ChatId)) + .ToListAsync(cancellationToken); + CollectAttachmentPaths(botMessages.SelectMany(x => x.Attachments), attachmentPaths); + dbContext.Messages.RemoveRange(botMessages); + + var memberships = await dbContext.ChatMembers + .Where(x => x.UserId == botUserId && !directChatIds.Contains(x.ChatId)) + .ToListAsync(cancellationToken); + dbContext.ChatMembers.RemoveRange(memberships); + + var storyAttachmentPaths = await dbContext.StoryAttachments + .Where(x => x.Story.AuthorUserId == botUserId) + .Select(x => x.StoragePath) + .ToListAsync(cancellationToken); + foreach (var path in storyAttachmentPaths) + { + if (!string.IsNullOrWhiteSpace(path)) + { + attachmentPaths.Add(path); + } + } + + var moderationEntries = await dbContext.ModerationLogEntries + .Where(x => x.ActorUserId == botUserId) + .ToListAsync(cancellationToken); + dbContext.ModerationLogEntries.RemoveRange(moderationEntries); + + var inviteLinks = await dbContext.ChatInviteLinks + .Where(x => x.CreatedById == botUserId) + .ToListAsync(cancellationToken); + dbContext.ChatInviteLinks.RemoveRange(inviteLinks); + + var topics = await dbContext.ChatTopics + .Where(x => x.CreatedById == botUserId) + .ToListAsync(cancellationToken); + dbContext.ChatTopics.RemoveRange(topics); + + var botCommands = await dbContext.BotCommands + .Where(x => x.BotId == bot.Id) + .ToListAsync(cancellationToken); + var botUpdates = await dbContext.BotUpdates + .Where(x => x.BotId == bot.Id) + .ToListAsync(cancellationToken); + dbContext.BotUpdates.RemoveRange(botUpdates); + dbContext.BotCommands.RemoveRange(botCommands); + dbContext.UserSessions.RemoveRange(bot.User.Sessions); + dbContext.PushDevices.RemoveRange(bot.User.PushDevices); + dbContext.Bots.Remove(bot); + dbContext.Users.Remove(bot.User); + + await dbContext.SaveChangesAsync(cancellationToken); + foreach (var path in attachmentPaths) + { + attachmentStorageService.DeleteIfExists(path); + } + + attachmentStorageService.DeleteIfExists(bot.User.AvatarStoragePath); + return result; + } + + private static void CollectAttachmentPaths(IEnumerable attachments, ISet target) + { + foreach (var path in attachments + .Select(x => x.StoragePath) + .Where(x => !string.IsNullOrWhiteSpace(x))) + { + target.Add(path); + } + } +} + +public sealed record BotDeletionResult(Guid BotId, Guid BotUserId, string Username); diff --git a/src/Ikar.Server/Infrastructure/Bots/BotUpdateService.cs b/src/Ikar.Server/Infrastructure/Bots/BotUpdateService.cs index 1ebee96b..936d9db9 100644 --- a/src/Ikar.Server/Infrastructure/Bots/BotUpdateService.cs +++ b/src/Ikar.Server/Infrastructure/Bots/BotUpdateService.cs @@ -1,5 +1,7 @@ using Ikar.Server.Data; using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure; +using Ikar.Shared; using Microsoft.EntityFrameworkCore; namespace Ikar.Server.Infrastructure.Bots; @@ -17,42 +19,66 @@ public sealed class BotUpdateService(IkarDbContext dbContext) .SingleOrDefaultAsync(x => x.Id == messageId, cancellationToken); if (message is null || - message.Sender.IsBot || - message.Chat.Type != Ikar.Shared.ChatType.Direct || - message.Chat.Members.Count != 2) + message.Sender.IsBot) { return; } - var botMember = message.Chat.Members - .FirstOrDefault(member => member.User.IsBot && member.User.BotProfile is not null); - if (botMember?.User.BotProfile is null || !botMember.User.BotProfile.IsEnabled) + var eligibleBotMembers = message.Chat.Members + .Where(member => + member.UserId != message.SenderId && + member.User.IsBot && + member.User.BotProfile is not null && + member.User.BotProfile.IsEnabled && + CanReceiveUpdates(message.Chat.Type, member)) + .ToList(); + if (eligibleBotMembers.Count == 0) { return; } - var updateExists = await dbContext.BotUpdates + var botIds = eligibleBotMembers + .Select(member => member.User.BotProfile!.Id) + .ToList(); + var existingBotIds = await dbContext.BotUpdates .AsNoTracking() - .AnyAsync( - x => x.BotId == botMember.User.BotProfile.Id && - x.MessageId == message.Id, - cancellationToken); - if (updateExists) - { - return; - } + .Where( + x => botIds.Contains(x.BotId) && + x.MessageId == message.Id) + .Select(x => x.BotId) + .ToListAsync(cancellationToken); + var existingBotIdSet = existingBotIds.ToHashSet(); - dbContext.BotUpdates.Add(new BotUpdate + foreach (var botMember in eligibleBotMembers) { - BotId = botMember.User.BotProfile.Id, - ChatId = message.ChatId, - MessageId = message.Id, - CreatedAt = message.SentAt - }); + var bot = botMember.User.BotProfile!; + if (!existingBotIdSet.Add(bot.Id)) + { + continue; + } + + dbContext.BotUpdates.Add(new BotUpdate + { + BotId = bot.Id, + ChatId = message.ChatId, + MessageId = message.Id, + CreatedAt = message.SentAt + }); + } await dbContext.SaveChangesAsync(cancellationToken); } + private static bool CanReceiveUpdates(ChatType chatType, ChatMember member) + { + if (chatType is ChatType.Direct or ChatType.Group) + { + return true; + } + + return DtoMapper.BuildPermissions(member, chatType) is { CanManageMembers: true } or { CanPostMessages: true }; + } + public async Task MarkMessagesReadByBotAsync( Guid botUserId, IReadOnlyCollection messages, diff --git a/src/Ikar.Server/Infrastructure/Channels/ChannelDiscussionMessageFactory.cs b/src/Ikar.Server/Infrastructure/Channels/ChannelDiscussionMessageFactory.cs new file mode 100644 index 00000000..c1341f20 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Channels/ChannelDiscussionMessageFactory.cs @@ -0,0 +1,94 @@ +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Channels; + +internal static class ChannelDiscussionMessageFactory +{ + public static async Task CreateLinkedDiscussionRootAsync( + IkarDbContext dbContext, + AttachmentStorageService? attachmentStorageService, + Chat channel, + Message channelMessage, + User sender, + DateTimeOffset sentAt, + CancellationToken cancellationToken) + { + if (channel.Type != ChatType.Channel || + !channelMessage.PostedAsChannel || + channel.LinkedDiscussionChatId is null) + { + return null; + } + + var discussionChat = await dbContext.Chats + .Include(x => x.Members) + .FirstOrDefaultAsync( + x => x.Id == channel.LinkedDiscussionChatId.Value && + x.Type == ChatType.Group, + cancellationToken); + if (discussionChat is null) + { + return null; + } + + var rootMessage = new Message + { + ChatId = discussionChat.Id, + Chat = discussionChat, + SenderId = sender.Id, + Sender = sender, + Text = BuildDiscussionRootText(channelMessage), + SentAt = sentAt, + ForwardedFromDisplayName = string.IsNullOrWhiteSpace(channel.Title) ? "Channel" : channel.Title, + MediaAlbumId = channelMessage.MediaAlbumId, + HasProtectedContent = channelMessage.HasProtectedContent, + TtlSeconds = channelMessage.TtlSeconds, + ExpiresAt = channelMessage.ExpiresAt + }; + + var clonedAttachments = new List(); + try + { + foreach (var sourceAttachment in channelMessage.Attachments + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.OriginalFileName)) + { + if (attachmentStorageService is null) + { + throw new InvalidOperationException("Attachment storage is required to mirror channel attachments to a discussion group."); + } + + var clonedAttachment = await attachmentStorageService.CloneAsync( + rootMessage.Id, + sourceAttachment, + cancellationToken, + sourceAttachment.SortOrder); + clonedAttachments.Add(clonedAttachment); + rootMessage.Attachments.Add(clonedAttachment); + } + } + catch + { + foreach (var clonedAttachment in clonedAttachments) + { + attachmentStorageService?.DeleteIfExists(clonedAttachment.StoragePath); + } + + throw; + } + + discussionChat.LastActivityAt = sentAt; + dbContext.Messages.Add(rootMessage); + return rootMessage; + } + + public static string BuildDiscussionRootText(Message channelMessage) + { + var text = channelMessage.Text.Trim(); + return string.IsNullOrWhiteSpace(text) ? "Publication" : text; + } +} diff --git a/src/Ikar.Server/Infrastructure/DtoMapper.cs b/src/Ikar.Server/Infrastructure/DtoMapper.cs index 102d5526..521663e6 100644 --- a/src/Ikar.Server/Infrastructure/DtoMapper.cs +++ b/src/Ikar.Server/Infrastructure/DtoMapper.cs @@ -5,19 +5,6 @@ namespace Ikar.Server.Infrastructure; public static class DtoMapper { - private static readonly HashSet AudioExtensions = - [ - ".aac", - ".flac", - ".m4a", - ".mp3", - ".oga", - ".ogg", - ".opus", - ".wav", - ".webm" - ]; - public static UserSummaryDto ToDto(this User user, PresenceTracker presenceTracker) => new( user.Id, @@ -33,6 +20,50 @@ public static class DtoMapper ResolveAvatarPath(user), user.IsBot); + public static UserSummaryDto ToDtoForViewer( + this User user, + PresenceTracker presenceTracker, + Guid currentUserId, + bool sharesChatWithViewer) + { + var dto = user.ToDto(presenceTracker); + if (user.Id == currentUserId) + { + return dto; + } + + var privacy = user.PrivacySettings; + var canViewPhone = CanViewFieldByPrivacy( + currentUserId, + privacy?.PhoneNumberVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "contacts", + sharesChatWithViewer); + var canViewLastSeen = CanViewFieldByPrivacy( + currentUserId, + privacy?.LastSeenVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "contacts", + sharesChatWithViewer); + var canViewAvatar = CanViewFieldByPrivacy( + currentUserId, + privacy?.ProfilePhotoVisibility, + privacy?.AlwaysAllowUserIds, + privacy?.NeverAllowUserIds, + fallback: "everyone", + sharesChatWithViewer); + + return dto with + { + PhoneNumber = canViewPhone ? dto.PhoneNumber : null, + LastSeenAt = canViewLastSeen ? dto.LastSeenAt : null, + IsOnline = canViewLastSeen && dto.IsOnline, + AvatarPath = canViewAvatar ? dto.AvatarPath : null + }; + } + public static BotSummaryDto ToDto(this Bot bot) => new( bot.Id, @@ -61,8 +92,10 @@ public static class DtoMapper { var sender = ResolveMessageSenderDto(message, presenceTracker, currentUserId); var displayAuthorName = ResolveDisplayAuthorName(message); - var viewCount = message.Chat?.Type == ChatType.Channel && message.PostedAsChannel - ? message.Views.Count + var isChannelPost = message.Chat?.Type == ChatType.Channel && message.PostedAsChannel; + var viewCount = isChannelPost ? message.Views.Count : (int?)null; + var commentCount = isChannelPost && message.DiscussionMessageId is not null + ? message.CommentCount : (int?)null; return new MessageDto( @@ -83,7 +116,10 @@ public static class DtoMapper message.ForwardedFromDisplayName, BuildReplyDto(message.ReplyToMessage, message.Chat), message.PinnedAt, - message.PinnedByUser?.ToDto(presenceTracker), + message.PinnedByUser?.ToDtoForViewer( + presenceTracker, + currentUserId, + SharesChatWithCurrentUser(message.Chat, currentUserId)), message.MediaAlbumId, BuildReactions(message, currentUserId), message.HasProtectedContent || message.Chat?.HasProtectedContent == true, @@ -91,7 +127,10 @@ public static class DtoMapper message.TtlSeconds, message.PostedAsChannel, displayAuthorName, - viewCount); + viewCount, + BuildStoryReplyDto(message), + message.DiscussionMessageId, + commentCount); } public static AttachmentDto ToDto(this MessageAttachment attachment, Guid chatId) => @@ -121,8 +160,9 @@ public static class DtoMapper .OrderBy(x => x.DisplayName) .ToList(); + var sharesChatWithCurrentUser = currentMembership is not null; var participants = orderedMembers - .Select(user => user.ToDto(presenceTracker)) + .Select(user => user.ToDtoForViewer(presenceTracker, currentUserId, sharesChatWithCurrentUser)) .ToList(); var counterpart = chat.Type == ChatType.Direct @@ -190,7 +230,8 @@ public static class DtoMapper chat.PublicUsername, chat.LinkedDiscussionChatId, chat.ChannelSignaturesEnabled, - chat.Description); + chat.Description, + CanDeleteForEveryone(chat, currentMembership)); } public static ChatDetailsDto ToDetailsDto( @@ -210,14 +251,14 @@ public static class DtoMapper var participants = visibleMembers .Select(x => x.User) .OrderBy(x => x.DisplayName) - .Select(x => x.ToDto(presenceTracker)) + .Select(x => x.ToDtoForViewer(presenceTracker, currentUserId, currentMembership is not null)) .ToList(); var members = visibleMembers .OrderByDescending(x => x.IsOwner || x.Role == ChatMemberRole.Owner) .ThenByDescending(x => x.Role == ChatMemberRole.Admin) .ThenBy(x => x.User.DisplayName) .Select(x => new ChatMemberDto( - x.User.ToDto(presenceTracker), + x.User.ToDtoForViewer(presenceTracker, currentUserId, currentMembership is not null), x.Role, x.IsOwner, BuildPermissions(x, chat.Type))) @@ -277,7 +318,8 @@ public static class DtoMapper chat.PublicUsername, chat.LinkedDiscussionChatId, chat.ChannelSignaturesEnabled, - chat.Description); + chat.Description, + CanDeleteForEveryone(chat, currentMembership)); } public static ModerationLogDto ToDto(this ModerationLogEntry entry, PresenceTracker presenceTracker) => @@ -294,10 +336,17 @@ public static class DtoMapper entry.Reason, entry.CreatedAt); - public static StoryDto ToDto(this Story story, PresenceTracker presenceTracker, Guid currentUserId) => + public static StoryDto ToDto( + this Story story, + PresenceTracker presenceTracker, + Guid currentUserId, + bool sharesChatWithViewer = false) => new( story.Id, - story.AuthorUser.ToDto(presenceTracker), + story.AuthorUser.ToDtoForViewer( + presenceTracker, + currentUserId, + story.AuthorUserId == currentUserId || sharesChatWithViewer), story.DeletedAt is null ? story.Text : string.Empty, story.DeletedAt is null ? story.Attachments @@ -332,7 +381,7 @@ public static class DtoMapper } var isManager = membership.IsOwner || membership.Role is ChatMemberRole.Owner or ChatMemberRole.Admin; - var canPost = chatType != ChatType.Channel || isManager || membership.CanPostMessages; + var canPost = chatType == ChatType.Direct || isManager || membership.CanPostMessages; return new ChatPermissionsDto( CanPostMessages: canPost, CanPinMessages: isManager || membership.CanPinMessages, @@ -381,6 +430,12 @@ public static class DtoMapper private static bool IsChannelManager(ChatMember member) => member.IsOwner || member.Role is ChatMemberRole.Owner or ChatMemberRole.Admin; + private static bool CanDeleteForEveryone(Chat chat, ChatMember? membership) => + membership is not null && + (chat.Type == ChatType.Direct || + membership.IsOwner || + membership.Role == ChatMemberRole.Owner); + private static string FormatSubscribers(int count) => $"{count} {FormatRussianPlural(count, "подписчик", "подписчика", "подписчиков")}"; @@ -410,7 +465,62 @@ public static class DtoMapper return BuildChannelIdentityDto(chat); } - return message.Sender.ToDto(presenceTracker); + return message.Sender.ToDtoForViewer( + presenceTracker, + currentUserId, + SharesChatWithCurrentUser(message.Chat, currentUserId)); + } + + private static bool SharesChatWithCurrentUser(Chat? chat, Guid currentUserId) => + chat?.Members.Any(member => member.UserId == currentUserId) == true; + + private static bool CanViewFieldByPrivacy( + Guid viewerUserId, + string? visibilityValue, + string? alwaysAllowUserIds, + string? neverAllowUserIds, + string fallback, + bool sharesChatWithViewer) + { + var neverAllow = DeserializeUserIdList(neverAllowUserIds).ToHashSet(); + if (neverAllow.Contains(viewerUserId)) + { + return false; + } + + var alwaysAllow = DeserializeUserIdList(alwaysAllowUserIds).ToHashSet(); + if (alwaysAllow.Contains(viewerUserId)) + { + return true; + } + + return NormalizePrivacyVisibility(visibilityValue, fallback) switch + { + "everyone" => true, + "contacts" => sharesChatWithViewer, + _ => false + }; + } + + private static string NormalizePrivacyVisibility(string? value, string fallback) + { + var normalized = value?.Trim().ToLowerInvariant(); + return normalized is "everyone" or "contacts" or "nobody" ? normalized : fallback; + } + + private static IReadOnlyList DeserializeUserIdList(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => Guid.TryParse(x, out var parsed) ? parsed : Guid.Empty) + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); } private static bool CanInspectChannelSender(Chat chat, Guid currentUserId) @@ -511,6 +621,10 @@ public static class DtoMapper var textPreview = lastMessage.Attachments.Count == 0 ? lastMessage.Text : $"{lastMessage.Text} ({BuildAttachmentPreview(lastMessage.Attachments)})"; + if (lastMessage.StoryReplyStoryId is not null) + { + textPreview = $"Ответ на сторис: {textPreview}"; + } return isChannelPost ? textPreview @@ -531,6 +645,20 @@ public static class DtoMapper BuildReplyPreviewText(replyToMessage)); } + private static StoryReplyDto? BuildStoryReplyDto(Message message) + { + if (message.StoryReplyStoryId is null || message.StoryReplyAuthorUserId is null) + { + return null; + } + + return new StoryReplyDto( + message.StoryReplyStoryId.Value, + message.StoryReplyAuthorUserId.Value, + string.IsNullOrWhiteSpace(message.StoryReplyPreviewText) ? "Story" : message.StoryReplyPreviewText!, + message.StoryReplyAttachmentKind); + } + private static IReadOnlyList BuildReactions(Message message, Guid currentUserId) => message.Reactions .Where(reaction => !string.IsNullOrWhiteSpace(reaction.Emoji)) @@ -592,10 +720,35 @@ public static class DtoMapper } private static AttachmentKind ResolveAttachmentKind(MessageAttachment attachment) => - IsAudioAttachment(attachment.ContentType, attachment.OriginalFileName) + attachment.Kind == AttachmentKind.VoiceNote || IsVoiceLikeAttachment(attachment) ? AttachmentKind.VoiceNote : AttachmentKind.File; + private static bool IsVoiceLikeAttachment(MessageAttachment attachment) + { + var contentType = attachment.ContentType + .Split(';', 2, StringSplitOptions.TrimEntries) + .FirstOrDefault() + ?.Trim() + .ToLowerInvariant(); + if (contentType is "audio/ogg" or "audio/opus" or "application/ogg" or "application/opus" or "application/x-ogg" or "video/ogg") + { + return true; + } + + var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(extension)) + { + var lastToken = attachment.OriginalFileName + .Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault() + ?.ToLowerInvariant(); + extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}"; + } + + return extension is ".ogg" or ".oga" or ".opus"; + } + private static MessageDeliveryState ResolveDeliveryState(Message message, Guid currentUserId) { if (message.SenderId != currentUserId) @@ -625,18 +778,6 @@ public static class DtoMapper return MessageDeliveryState.Sent; } - 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); - } - private static bool IsImageAttachment(string? contentType, string? fileName) { if (!string.IsNullOrWhiteSpace(contentType) && diff --git a/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs b/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs index 874e8c97..31131fbb 100644 --- a/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs +++ b/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs @@ -17,6 +17,9 @@ public sealed class FirebasePushNotificationSender( ILogger logger) : IPushNotificationSender { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private const string PersistentNotificationTtl = "604800s"; + private const string EphemeralCallTtl = "45s"; + private const string AppUpdateNotificationTtl = "3600s"; private readonly PushOptions _options = options.Value; private readonly SemaphoreSlim _tokenLock = new(1, 1); @@ -75,6 +78,7 @@ public sealed class FirebasePushNotificationSender( [PushDataKeys.SenderName] = message.Sender.DisplayName, [PushDataKeys.SenderPhoneNumber] = message.Sender.PhoneNumber ?? string.Empty }, + PersistentNotificationTtl, cancellationToken); } } @@ -130,6 +134,7 @@ public sealed class FirebasePushNotificationSender( [PushDataKeys.SenderPhoneNumber] = reactor.PhoneNumber ?? string.Empty, [PushDataKeys.ReactionEmoji] = emoji }, + PersistentNotificationTtl, cancellationToken); } } @@ -176,7 +181,7 @@ public sealed class FirebasePushNotificationSender( foreach (var device in androidDevices) { - await SendToAndroidAsync(projectId, accessToken, device, data, cancellationToken); + await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken); } } @@ -218,7 +223,7 @@ public sealed class FirebasePushNotificationSender( foreach (var device in androidDevices) { - await SendToAndroidAsync(projectId, accessToken, device, data, cancellationToken); + await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken); } } @@ -259,7 +264,7 @@ public sealed class FirebasePushNotificationSender( foreach (var device in androidDevices) { - await SendToAndroidAsync(projectId, accessToken, device, data, cancellationToken); + await SendToAndroidAsync(projectId, accessToken, device, data, AppUpdateNotificationTtl, cancellationToken); } } @@ -371,6 +376,7 @@ public sealed class FirebasePushNotificationSender( string accessToken, PushDevice device, IReadOnlyDictionary data, + string ttl, CancellationToken cancellationToken) { var payload = new @@ -382,7 +388,7 @@ public sealed class FirebasePushNotificationSender( android = new { priority = "high", - ttl = "45s" + ttl } } }; diff --git a/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs b/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs index 15dc7eaa..b21a614e 100644 --- a/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs +++ b/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs @@ -11,29 +11,58 @@ public sealed class PushNotificationDispatcher( { public async Task DispatchMessageAsync(Chat chat, Message message, CancellationToken cancellationToken = default) { + var now = DateTimeOffset.UtcNow; var recipientIds = chat.Members + .Where(x => x.UserId != message.SenderId && !IsMuted(x, now)) .Select(x => x.UserId) - .Where(x => x != message.SenderId) .Distinct() .ToList(); if (recipientIds.Count == 0) { + logger.LogDebug( + "Skipping push dispatch for message {MessageId} because chat {ChatId} has no recipients.", + message.Id, + chat.Id); return; } var devices = await dbContext.PushDevices + .Include(x => x.User) + .ThenInclude(x => x.PrivacySettings) .Where(x => recipientIds.Contains(x.UserId) && x.NotificationsEnabled) .ToListAsync(cancellationToken); if (devices.Count == 0) { + logger.LogDebug( + "Skipping push dispatch for message {MessageId} because {RecipientCount} recipient(s) have no enabled push devices.", + message.Id, + recipientIds.Count); return; } try { - await pushNotificationSender.SendMessageAsync(chat, message, devices, cancellationToken); + var previewDevices = devices + .Where(device => device.User.PrivacySettings?.ShowMessagePreview != false) + .ToList(); + if (previewDevices.Count > 0) + { + await pushNotificationSender.SendMessageAsync(chat, message, previewDevices, cancellationToken); + } + + var hiddenPreviewDevices = devices + .Where(device => device.User.PrivacySettings?.ShowMessagePreview == false) + .ToList(); + if (hiddenPreviewDevices.Count > 0) + { + await pushNotificationSender.SendMessageAsync( + chat, + BuildHiddenPreviewMessage(message), + hiddenPreviewDevices, + cancellationToken); + } } catch (Exception ex) { @@ -50,6 +79,19 @@ public sealed class PushNotificationDispatcher( { if (message.SenderId == reactor.Id || string.IsNullOrWhiteSpace(emoji)) { + logger.LogDebug( + "Skipping reaction push for message {MessageId}: self reaction or empty emoji.", + message.Id); + return; + } + + var authorMembership = chat.Members.FirstOrDefault(x => x.UserId == message.SenderId); + if (authorMembership is null || IsMuted(authorMembership, DateTimeOffset.UtcNow)) + { + logger.LogDebug( + "Skipping reaction push for message {MessageId} because recipient muted chat {ChatId} or is no longer a member.", + message.Id, + chat.Id); return; } @@ -59,6 +101,9 @@ public sealed class PushNotificationDispatcher( if (devices.Count == 0) { + logger.LogDebug( + "Skipping reaction push for message {MessageId} because recipient has no enabled push devices.", + message.Id); return; } @@ -80,4 +125,33 @@ public sealed class PushNotificationDispatcher( message.Id); } } + + private static bool IsMuted(ChatMember member, DateTimeOffset now) => + member.MutedUntil is not null && member.MutedUntil > now; + + private static Message BuildHiddenPreviewMessage(Message source) + { + var sender = new User + { + Id = source.Sender.Id, + Username = source.Sender.Username, + DisplayName = source.Sender.DisplayName, + PhoneNumber = null, + IsBot = source.Sender.IsBot + }; + + return new Message + { + Id = source.Id, + ChatId = source.ChatId, + Chat = source.Chat, + SenderId = source.SenderId, + Sender = sender, + Text = "Новое сообщение", + SentAt = source.SentAt, + PostedAsChannel = source.PostedAsChannel, + AuthorSignature = source.AuthorSignature, + DeletedAt = source.DeletedAt + }; + } } diff --git a/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs b/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs index 28931b92..24fd40a4 100644 --- a/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs +++ b/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs @@ -17,7 +17,8 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment) throw new InvalidOperationException("Attachment is empty."); } - var (attachmentId, physicalPath) = CreateTargetPath(file.FileName); + var originalFileName = NormalizeOriginalFileName(file.FileName, "attachment"); + var (attachmentId, physicalPath) = CreateTargetPath(originalFileName); await using (var source = file.OpenReadStream()) await using (var target = File.Create(physicalPath)) @@ -29,7 +30,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment) { Id = attachmentId, MessageId = messageId, - OriginalFileName = string.IsNullOrWhiteSpace(file.FileName) ? "attachment" : Path.GetFileName(file.FileName), + OriginalFileName = originalFileName, ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType, FileSizeBytes = file.Length, StoragePath = physicalPath, @@ -49,7 +50,8 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment) throw new InvalidOperationException("Attachment is empty."); } - var (attachmentId, physicalPath) = CreateTargetPath(file.FileName); + var originalFileName = NormalizeOriginalFileName(file.FileName, "story-attachment"); + var (attachmentId, physicalPath) = CreateTargetPath(originalFileName); await using (var source = file.OpenReadStream()) await using (var target = File.Create(physicalPath)) @@ -61,7 +63,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment) { Id = attachmentId, StoryId = storyId, - OriginalFileName = string.IsNullOrWhiteSpace(file.FileName) ? "story-attachment" : Path.GetFileName(file.FileName), + OriginalFileName = originalFileName, ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType, FileSizeBytes = file.Length, StoragePath = physicalPath, @@ -95,6 +97,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment) MessageId = messageId, OriginalFileName = sourceAttachment.OriginalFileName, ContentType = sourceAttachment.ContentType, + Kind = sourceAttachment.Kind, FileSizeBytes = sourceAttachment.FileSizeBytes, StoragePath = physicalPath, SortOrder = sortOrder ?? sourceAttachment.SortOrder, @@ -125,4 +128,33 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment) var physicalPath = Path.Combine(targetDirectory, storedFileName); return (attachmentId, physicalPath); } + + private static string NormalizeOriginalFileName(string? fileName, string fallback) + { + var safeFileName = string.IsNullOrWhiteSpace(fileName) + ? fallback + : Path.GetFileName(fileName); + if (string.IsNullOrWhiteSpace(safeFileName)) + { + return fallback; + } + + var decodedFileName = safeFileName.Contains('%') + ? TryDecodeFileName(safeFileName) + : safeFileName; + var normalizedFileName = Path.GetFileName(decodedFileName); + return string.IsNullOrWhiteSpace(normalizedFileName) ? fallback : normalizedFileName; + } + + private static string TryDecodeFileName(string fileName) + { + try + { + return Uri.UnescapeDataString(fileName); + } + catch (UriFormatException) + { + return fileName; + } + } } diff --git a/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs b/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs index 56f8e616..e7b41d9f 100644 --- a/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs +++ b/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs @@ -61,12 +61,21 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) await EnsureColumnAsync("Messages", "TtlSeconds", "INTEGER NULL", cancellationToken); await EnsureColumnAsync("Messages", "PostedAsChannel", "INTEGER NOT NULL DEFAULT 0", cancellationToken); await EnsureColumnAsync("Messages", "AuthorSignature", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "StoryReplyStoryId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "StoryReplyAuthorUserId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "StoryReplyPreviewText", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "StoryReplyAttachmentKind", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "DiscussionMessageId", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Messages", "CommentCount", "INTEGER NOT NULL DEFAULT 0", cancellationToken); await EnsureLegacyChannelPostsMarkedAsync(cancellationToken); await EnsureColumnAsync("MessageAttachments", "SortOrder", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("MessageAttachments", "Kind", "INTEGER NOT NULL DEFAULT 1", cancellationToken); + await EnsureLegacyAudioAttachmentsMarkedAsVoiceNotesAsync(cancellationToken); await EnsureMessagesPinnedIndexAsync(cancellationToken); await EnsureMessagesChatSenderSentAtIndexAsync(cancellationToken); await EnsureMessagesAlbumIndexAsync(cancellationToken); await EnsureMessagesTopicIndexAsync(cancellationToken); + await EnsureMessagesDiscussionIndexAsync(cancellationToken); await EnsureChatsPublicUsernameIndexAsync(cancellationToken); await EnsureChatsLinkedDiscussionIndexAsync(cancellationToken); await EnsureMessageAttachmentsSortIndexAsync(cancellationToken); @@ -75,6 +84,8 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) await EnsureColumnAsync("Users", "BlockedAt", "TEXT NULL", cancellationToken); await EnsureColumnAsync("Users", "PhoneNumber", "TEXT NULL", cancellationToken); await EnsureColumnAsync("Users", "NormalizedPhoneNumber", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "Email", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("Users", "NormalizedEmail", "TEXT NULL", cancellationToken); await EnsureColumnAsync("Users", "About", "TEXT NULL", cancellationToken); await EnsureColumnAsync("Users", "AvatarStoragePath", "TEXT NULL", cancellationToken); await EnsureColumnAsync("Users", "AvatarContentType", "TEXT NULL", cancellationToken); @@ -83,7 +94,9 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) await EnsureColumnAsync("UserPrivacySettings", "AlwaysAllowUserIds", "TEXT NOT NULL DEFAULT ''", cancellationToken); await EnsureColumnAsync("UserPrivacySettings", "NeverAllowUserIds", "TEXT NOT NULL DEFAULT ''", cancellationToken); await EnsureMessagesExpiresAtIndexAsync(cancellationToken); + await EnsureMessagesStoryReplyIndexAsync(cancellationToken); await EnsureUsersPhoneIndexAsync(cancellationToken); + await EnsureUsersEmailIndexAsync(cancellationToken); } private async Task EnableWriteAheadLoggingAsync(CancellationToken cancellationToken) @@ -100,6 +113,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) "MessageId" TEXT NOT NULL, "OriginalFileName" TEXT NOT NULL, "ContentType" TEXT NOT NULL, + "Kind" INTEGER NOT NULL DEFAULT 1, "FileSizeBytes" INTEGER NOT NULL, "StoragePath" TEXT NOT NULL, "UploadedAt" TEXT NOT NULL, @@ -460,12 +474,21 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) "IsRevoked" INTEGER NOT NULL DEFAULT 0, "CreatedAt" TEXT NOT NULL, "RevokedAt" TEXT NULL, + "ExpiresAt" TEXT NULL, + "MaxUses" INTEGER NULL, + "UsesCount" INTEGER NOT NULL DEFAULT 0, + "MemberLimit" INTEGER NULL, CONSTRAINT "FK_ChatInviteLinks_Chats_ChatId" FOREIGN KEY ("ChatId") REFERENCES "Chats" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_ChatInviteLinks_Users_CreatedById" FOREIGN KEY ("CreatedById") REFERENCES "Users" ("Id") ON DELETE RESTRICT ); """, cancellationToken); + await EnsureColumnAsync("ChatInviteLinks", "ExpiresAt", "TEXT NULL", cancellationToken); + await EnsureColumnAsync("ChatInviteLinks", "MaxUses", "INTEGER NULL", cancellationToken); + await EnsureColumnAsync("ChatInviteLinks", "UsesCount", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("ChatInviteLinks", "MemberLimit", "INTEGER NULL", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( """ CREATE UNIQUE INDEX IF NOT EXISTS "IX_ChatInviteLinks_Token" ON "ChatInviteLinks" ("Token"); @@ -582,6 +605,15 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) cancellationToken); } + private async Task EnsureMessagesDiscussionIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_DiscussionMessageId" ON "Messages" ("DiscussionMessageId"); + """, + cancellationToken); + } + private async Task EnsureMessagesExpiresAtIndexAsync(CancellationToken cancellationToken) { await dbContext.Database.ExecuteSqlRawAsync( @@ -600,6 +632,15 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) cancellationToken); } + private async Task EnsureMessagesStoryReplyIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_Messages_StoryReplyStoryId" ON "Messages" ("StoryReplyStoryId"); + """, + cancellationToken); + } + private async Task EnsureChatsLinkedDiscussionIndexAsync(CancellationToken cancellationToken) { await dbContext.Database.ExecuteSqlRawAsync( @@ -621,6 +662,25 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) cancellationToken); } + private async Task EnsureLegacyAudioAttachmentsMarkedAsVoiceNotesAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + UPDATE "MessageAttachments" + SET "Kind" = 2 + WHERE "Kind" = 1 + AND ( + lower("ContentType") LIKE 'audio/%' + OR lower("ContentType") IN ('application/ogg', 'application/opus', 'application/x-ogg', 'video/ogg') + OR lower("OriginalFileName") GLOB '*.ogg' + OR lower("OriginalFileName") GLOB '*.oga' + OR lower("OriginalFileName") GLOB '*.opus' + OR lower("OriginalFileName") GLOB '*.m4a' + ); + """, + cancellationToken); + } + private async Task EnsureMessageAttachmentsSortIndexAsync(CancellationToken cancellationToken) { await dbContext.Database.ExecuteSqlRawAsync( @@ -660,6 +720,15 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) cancellationToken); } + private async Task EnsureUsersEmailIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Users_NormalizedEmail" ON "Users" ("NormalizedEmail") WHERE "NormalizedEmail" IS NOT NULL; + """, + cancellationToken); + } + private async Task EnsureColumnAsync( string tableName, string columnName, diff --git a/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateMonitorService.cs b/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateMonitorService.cs index b25b6503..1744d589 100644 --- a/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateMonitorService.cs +++ b/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateMonitorService.cs @@ -79,7 +79,10 @@ public sealed class AndroidAppUpdateMonitorService( var pushNotificationSender = scope.ServiceProvider.GetRequiredService(); var devices = await dbContext.PushDevices - .Where(device => device.Platform == PushPlatform.Android && device.NotificationsEnabled) + .Where(device => + device.Platform == PushPlatform.Android && + device.NotificationsEnabled && + (device.ClientVersionCode == null || device.ClientVersionCode < release.VersionCode)) .ToListAsync(cancellationToken); if (devices.Count == 0) diff --git a/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateService.cs b/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateService.cs index 9efd61bd..30f36d03 100644 --- a/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateService.cs +++ b/src/Ikar.Server/Infrastructure/Updates/AndroidAppUpdateService.cs @@ -159,7 +159,12 @@ public sealed class AndroidAppUpdateService( release = null!; originalFileName = argusRelease.OriginalFileName; - if (!TryParseVersion(argusRelease.Version, argusRelease.OriginalFileName, out var versionCode, out var versionName)) + if (!TryParseVersion( + argusRelease.Version, + argusRelease.AndroidVersionCode, + argusRelease.OriginalFileName, + out var versionCode, + out var versionName)) { return false; } @@ -182,11 +187,12 @@ public sealed class AndroidAppUpdateService( private static bool TryParseVersion( string rawVersion, + int? androidVersionCode, string? originalFileName, out int versionCode, out string versionName) { - versionCode = 0; + versionCode = androidVersionCode is > 0 ? androidVersionCode.Value : 0; versionName = rawVersion.Trim(); var buildSeparatorIndex = versionName.IndexOf('+'); @@ -200,7 +206,8 @@ public sealed class AndroidAppUpdateService( versionName = parsedVersionName; } - if (int.TryParse(buildMetadata, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedVersionCode) && + if (versionCode <= 0 && + int.TryParse(buildMetadata, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedVersionCode) && parsedVersionCode > 0) { versionCode = parsedVersionCode; @@ -316,6 +323,7 @@ public sealed class AndroidAppUpdateService( string Channel, string Platform, string PackageKind, + int? AndroidVersionCode, string DownloadPath, string OriginalFileName, string ContentType, diff --git a/src/Ikar.Server/Program.cs b/src/Ikar.Server/Program.cs index c4046ffd..99f70239 100644 --- a/src/Ikar.Server/Program.cs +++ b/src/Ikar.Server/Program.cs @@ -49,6 +49,7 @@ builder.Services.Configure(builder.Configuration.GetSection(JwtOptio builder.Services.Configure(builder.Configuration.GetSection(PushOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(AdminOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(WebRtcOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(EmailAuthOptions.SectionName)); var jwtOptions = builder.Configuration .GetSection(JwtOptions.SectionName) @@ -168,11 +169,15 @@ builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow)); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -196,6 +201,7 @@ await using (var scope = app.Services.CreateAsyncScope()) { await scope.ServiceProvider.GetRequiredService().SeedAsync(); } + await scope.ServiceProvider.GetRequiredService().EnsureCreatorBotAsync(); } if (app.Environment.IsDevelopment()) diff --git a/src/Ikar.Shared/ChatContracts.cs b/src/Ikar.Shared/ChatContracts.cs index e365bb44..5db5631c 100644 --- a/src/Ikar.Shared/ChatContracts.cs +++ b/src/Ikar.Shared/ChatContracts.cs @@ -46,7 +46,8 @@ public sealed record ChatSummaryDto( string? PublicUsername = null, Guid? LinkedDiscussionChatId = null, bool ChannelSignaturesEnabled = false, - string? Description = null); + string? Description = null, + bool CanDeleteForEveryone = false); public sealed record ChatDetailsDto( Guid Id, @@ -71,7 +72,8 @@ public sealed record ChatDetailsDto( string? PublicUsername = null, Guid? LinkedDiscussionChatId = null, bool ChannelSignaturesEnabled = false, - string? Description = null); + string? Description = null, + bool CanDeleteForEveryone = false); public sealed record ChatInfoDto( ChatDetailsDto Chat, @@ -90,6 +92,7 @@ public sealed record ChatInfoDto( bool ChannelSignaturesEnabled = false, bool CanManageChannel = false, bool CanPostAsChannel = false, + bool CanInviteUsers = false, string? Description = null); public sealed record ChatMessagesPageDto( @@ -111,7 +114,13 @@ public sealed record ChatStateRequest( bool? HasProtectedContent = null, int? DefaultMessageTtlSeconds = null, bool? ChannelSignaturesEnabled = null, - Guid? LinkedDiscussionChatId = null); + Guid? LinkedDiscussionChatId = null, + bool ClearLinkedDiscussionChat = false, + string? Title = null, + string? Description = null, + string? PublicUsername = null, + bool ClearDescription = false, + bool ClearPublicUsername = false); public sealed record UpdateChatMemberPermissionsRequest( ChatMemberRole Role, @@ -137,11 +146,30 @@ public sealed record ChatInviteLinkDto( bool CreatesJoinRequest, bool IsRevoked, DateTimeOffset CreatedAt, - DateTimeOffset? RevokedAt = null); + DateTimeOffset? RevokedAt = null, + DateTimeOffset? ExpiresAt = null, + int? MaxUses = null, + int UsesCount = 0, + int? MemberLimit = null); public sealed record CreateChatInviteLinkRequest( string? Title = null, - bool CreatesJoinRequest = false); + bool CreatesJoinRequest = false, + DateTimeOffset? ExpiresAt = null, + int? MaxUses = null, + int? MemberLimit = null); + +public enum JoinChatResultStatus +{ + Joined = 0, + PendingApproval = 1 +} + +public sealed record JoinChatResultDto( + JoinChatResultStatus Status, + ChatDetailsDto? Chat = null, + Guid? JoinRequestId = null, + ChatJoinRequestStatus? JoinRequestStatus = null); public sealed record ChatTopicDto( Guid Id, diff --git a/src/Ikar.Shared/EmailAuthContracts.cs b/src/Ikar.Shared/EmailAuthContracts.cs new file mode 100644 index 00000000..9dc55a07 --- /dev/null +++ b/src/Ikar.Shared/EmailAuthContracts.cs @@ -0,0 +1,15 @@ +namespace Ikar.Shared; + +public sealed record RequestEmailCodeRequest(string Email, string PhoneNumber); + +public sealed record EmailCodeChallengeDto( + string ChallengeId, + DateTimeOffset ExpiresAt, + bool IsRegistered); + +public sealed record VerifyEmailCodeRequest( + string ChallengeId, + string Email, + string PhoneNumber, + string Code, + string? DisplayName); diff --git a/src/Ikar.Shared/MessageContracts.cs b/src/Ikar.Shared/MessageContracts.cs index 8fee0ef1..b5e4b676 100644 --- a/src/Ikar.Shared/MessageContracts.cs +++ b/src/Ikar.Shared/MessageContracts.cs @@ -22,6 +22,12 @@ public sealed record MessageReplyDto( string SenderDisplayName, string PreviewText); +public sealed record StoryReplyDto( + Guid StoryId, + Guid AuthorUserId, + string PreviewText, + string? AttachmentKind = null); + public sealed record MessageReactionDto( string Emoji, int Count, @@ -49,14 +55,18 @@ public sealed record MessageDto( int? TtlSeconds = null, bool PostedAsChannel = false, string? DisplayAuthorName = null, - int? ViewCount = null); + int? ViewCount = null, + StoryReplyDto? StoryReply = null, + Guid? DiscussionMessageId = null, + int? CommentCount = null); public sealed record SendMessageRequest( string Text, Guid? ReplyToMessageId = null, Guid? TopicId = null, bool? HasProtectedContent = null, - int? TtlSeconds = null); + int? TtlSeconds = null, + Guid? StoryReplyStoryId = null); public sealed record UpdateMessageRequest(string Text); diff --git a/src/Ikar.Shared/UserContracts.cs b/src/Ikar.Shared/UserContracts.cs index 618f8d65..164d06ed 100644 --- a/src/Ikar.Shared/UserContracts.cs +++ b/src/Ikar.Shared/UserContracts.cs @@ -28,6 +28,9 @@ public sealed record PrivacySettingsDto( IReadOnlyList BlockedUserIds, IReadOnlyList AlwaysAllowUserIds, IReadOnlyList NeverAllowUserIds, + IReadOnlyList BlockedUsers, + IReadOnlyList AlwaysAllowUsers, + IReadOnlyList NeverAllowUsers, bool PasscodeLockEnabled, bool ShowMessagePreview, DateTimeOffset UpdatedAt);