Expand Ikar messaging and Android release support
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
.git
|
||||
.vs
|
||||
.codex-*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/bin
|
||||
**/obj
|
||||
docker-data
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "<ssh-password>" `
|
||||
-Slug "ikar-android" `
|
||||
-Name "Ikar" `
|
||||
-Summary "<summary>" `
|
||||
-Description "<description>" `
|
||||
-Version "<semantic-version>" `
|
||||
-PackagePath "<path-to-apk>"
|
||||
```
|
||||
|
||||
- `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: "<lowercase-slug-with-hyphens-only>"
|
||||
name: "<public app name>"
|
||||
summary: "<short one-line summary>"
|
||||
description: "<full description>"
|
||||
repositoryUrl: "<optional https url or null>"
|
||||
homepageUrl: "<optional https url or null>"
|
||||
isListed: true
|
||||
|
||||
android:
|
||||
androidPackageName: "<java.package.name>"
|
||||
versionName: "<human-readable version, ideally semver>"
|
||||
versionCode: <positive integer>
|
||||
targetSdkVersion: <integer>
|
||||
abiKind: "<universal|arm64|armv7|x86_64|x86>"
|
||||
|
||||
release:
|
||||
version: "<same semantic release label as versionName, if applicable>"
|
||||
channel: "stable"
|
||||
platform: "<android-universal|android-arm64|android-armv7|android-x86_64|android-x86>"
|
||||
packageKind: "apk"
|
||||
notes: "<optional release notes or null>"
|
||||
packagePath: "<absolute or repo-relative path to final .apk>"
|
||||
|
||||
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 "<admin>" `
|
||||
-Password "<password>" `
|
||||
-Slug "<slug>" `
|
||||
-Name "<name>" `
|
||||
-Summary "<summary>" `
|
||||
-Description "<description>" `
|
||||
-AndroidPackageName "<java.package.name>" `
|
||||
-Version "<version>" `
|
||||
-Channel "stable" `
|
||||
-Platform "android-universal" `
|
||||
-PackageKind "apk" `
|
||||
-AndroidVersionCode <positive integer> `
|
||||
-PackagePath "<path-to.apk>"
|
||||
```
|
||||
|
||||
Источник: `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.
|
||||
|
||||
@@ -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 "<ssh-password>" -Slug "ikar-android" -Name "Ikar" -Summary "<summary>" -Description "<description>" -Version "<version>" -PackagePath "<apk>"`
|
||||
|
||||
Скрипт подготовки собирает `release` APK, проверяет подпись и извлекает из самого APK:
|
||||
- `androidPackageName`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -42,6 +42,41 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:host="ikar.kusoft.xyz"
|
||||
android:pathPrefix="/join"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="ikar.kusoft.xyz"
|
||||
android:pathPrefix="/chat"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="ikar.kusoft.xyz"
|
||||
android:pathPrefix="/channel"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="ikar.kusoft.xyz"
|
||||
android:pathPrefix="/@"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:host="join"
|
||||
android:scheme="ikar" />
|
||||
<data
|
||||
android:host="chat"
|
||||
android:scheme="ikar" />
|
||||
<data
|
||||
android:host="channel"
|
||||
android:scheme="ikar" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = "|")
|
||||
}
|
||||
|
||||
@@ -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<String?>(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("Разблокировать")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+47
-13
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -45,6 +45,15 @@ interface CachedChatDao {
|
||||
)
|
||||
suspend fun loadChats(ownerUserId: String): List<CachedChatEntity>
|
||||
|
||||
@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<CachedChatEntity>)
|
||||
|
||||
|
||||
+20
@@ -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<ChatSummaryDto>(entity.payloadJson)
|
||||
json.encodeToString(chat.copy(unreadCount = 0))
|
||||
}.getOrElse {
|
||||
entity.payloadJson
|
||||
}
|
||||
|
||||
dao.upsert(
|
||||
listOf(
|
||||
entity.copy(
|
||||
unreadCount = 0,
|
||||
payloadJson = payload,
|
||||
updatedAtMillis = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+96
-7
@@ -53,6 +53,22 @@ object ChatJoinRequestStatusSerializer : KSerializer<ChatJoinRequestStatus> {
|
||||
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<JoinChatResultStatus> {
|
||||
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<String> = emptyList(),
|
||||
val alwaysAllowUserIds: List<String> = emptyList(),
|
||||
val neverAllowUserIds: List<String> = emptyList(),
|
||||
val blockedUsers: List<UserSummaryDto> = emptyList(),
|
||||
val alwaysAllowUsers: List<UserSummaryDto> = emptyList(),
|
||||
val neverAllowUsers: List<UserSummaryDto> = emptyList(),
|
||||
val passcodeLockEnabled: Boolean = false,
|
||||
val showMessagePreview: Boolean = true,
|
||||
val updatedAt: String
|
||||
|
||||
+42
@@ -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<PendingLinkNavigation?>(null)
|
||||
val pendingNavigation: StateFlow<PendingLinkNavigation?> = _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
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
-27
@@ -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<ChatSummaryDto> =
|
||||
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<ChatSearchResultDto> =
|
||||
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<ChatJoinRequestDto> =
|
||||
@@ -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<ModerationLogDto> =
|
||||
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<PushDeviceDto> =
|
||||
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 <T> executeJsonRequest(request: Request, serializer: kotlinx.serialization.KSerializer<T>): 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-4
@@ -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<NotificationSettingsSnapshot> = _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 <T : Enum<T>> getEnum(key: String, defaultValue: T): T {
|
||||
|
||||
+72
@@ -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
|
||||
}
|
||||
+9
-6
@@ -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) {
|
||||
|
||||
+125
-9
@@ -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)
|
||||
|
||||
+6
-2
@@ -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(
|
||||
|
||||
+39
-1
@@ -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<Unit>(extraBufferCapacity = 8)
|
||||
val chatsChanged: SharedFlow<Unit> = _chatsChanged
|
||||
|
||||
private val _storiesChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
|
||||
val storiesChanged: SharedFlow<Unit> = _storiesChanged
|
||||
|
||||
private val _appUpdateAvailable = MutableSharedFlow<AndroidAppReleaseDto>(extraBufferCapacity = 8)
|
||||
val appUpdateAvailable: SharedFlow<AndroidAppReleaseDto> = _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)
|
||||
|
||||
+8
-3
@@ -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 <T : Enum<T>> 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"
|
||||
|
||||
+52
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -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)
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+27
-17
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+137
-2
@@ -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<String>
|
||||
) : Comparable<SemanticVersion> {
|
||||
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<String>, right: List<String>): 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 {
|
||||
|
||||
+22
-9
@@ -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)
|
||||
|
||||
+45
-35
@@ -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<BotEditorUiState> = _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<BotCommandDto>): 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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -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
|
||||
)
|
||||
|
||||
+117
@@ -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() }
|
||||
?: "Браузер"
|
||||
+147
-2
@@ -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<String>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+111
-29
@@ -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<VoiceNotePlaybackEvent>(extraBufferCapacity = 32)
|
||||
val events: SharedFlow<VoiceNotePlaybackEvent> = _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()
|
||||
}
|
||||
}
|
||||
|
||||
+47
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -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<Unit>(extraBufferCapacity = 16)
|
||||
|
||||
val changes: SharedFlow<Unit> = _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
|
||||
}
|
||||
}
|
||||
+1441
-90
File diff suppressed because it is too large
Load Diff
+382
-18
@@ -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<String, VoiceAttachmentPlaybackUiState> = emptyMap(),
|
||||
val botCommands: List<BotCommandDto> = emptyList(),
|
||||
val previewImageAttachmentId: String? = null,
|
||||
val previewVideoAttachmentId: String? = null,
|
||||
val firstUnreadMessageId: String? = null,
|
||||
val selectedMessageIds: Set<String> = 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<ChatUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val cachedAttachmentFiles = mutableMapOf<String, LocalAttachmentFile>()
|
||||
@@ -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<MessageDto>) {
|
||||
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<MessageDto>, 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<BotQuickAction> {
|
||||
if (!message.sender.isBot || message.text.isBlank()) {
|
||||
return emptyList()
|
||||
|
||||
+194
@@ -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<PickerMediaItem>
|
||||
)
|
||||
|
||||
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=="
|
||||
+289
-26
@@ -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} участников"
|
||||
}
|
||||
|
||||
+205
-6
@@ -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<ChatSummaryDto> = 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<ChatInfoUiState> = _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)
|
||||
}
|
||||
|
||||
+175
@@ -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 -> "В этом чате пока нет закреплённых сообщений."
|
||||
}
|
||||
+97
@@ -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<MessageDto> = 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<ChatMaterialsUiState> = _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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+255
-23
@@ -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<StoryDto>
|
||||
) {
|
||||
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<StoryDto>.toAuthorGroups(): List<StoryAuthorGroup> =
|
||||
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 чат"
|
||||
|
||||
+138
-8
@@ -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<ChatSummaryDto>()
|
||||
private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap()
|
||||
private var refreshJob: Job? = null
|
||||
private var hadActiveStoryTransfers = false
|
||||
val uiState: StateFlow<ChatsUiState> = _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>): 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()
|
||||
|
||||
+44
-17
@@ -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 "уведомления выключены"
|
||||
|
||||
+10
-23
@@ -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<UserSessionDto> = emptyList(),
|
||||
val devices: List<PushDeviceDto> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+214
-12
@@ -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<ChatSummaryDto>,
|
||||
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
|
||||
|
||||
+88
-9
@@ -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<DiscoverUserItem> = emptyList(),
|
||||
val publicChannels: List<ChatSummaryDto> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val pendingChatId: String? = null,
|
||||
val selectionMode: DiscoverSelectionMode? = null,
|
||||
val selectedUserIds: Set<String> = 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)
|
||||
}
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+92
@@ -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("К чатам")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -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<JoinInviteUiState> = _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)
|
||||
}
|
||||
}
|
||||
+23
-20
@@ -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 или телефон")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-16
@@ -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<LoginUiState> = _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,
|
||||
|
||||
+42
-3
@@ -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<ChatSummaryDto> = 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<ChatSummaryDto>) {
|
||||
_uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats))
|
||||
}
|
||||
|
||||
private fun countUnreadChats(chats: List<ChatSummaryDto>): Int =
|
||||
chats.count { chat -> chat.unreadCount > 0 }
|
||||
countUnreadChats(chats, container.pushSettings.snapshot.value)
|
||||
|
||||
private fun countUnreadChats(
|
||||
chats: List<ChatSummaryDto>,
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-3
@@ -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" -> "Сообщение откреплено"
|
||||
|
||||
+96
-1
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
data class ChatMembersUiState(
|
||||
val chat: ChatDetailsDto? = null,
|
||||
val currentUserId: String? = null,
|
||||
val joinRequests: List<ChatJoinRequestDto> = emptyList(),
|
||||
val moderationLog: List<ModerationLogDto> = 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<ChatJoinRequestDto>,
|
||||
val moderationLog: List<ModerationLogDto>
|
||||
)
|
||||
|
||||
+361
-18
@@ -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<PasscodeDialogMode?>(null) }
|
||||
var listDialogMode by remember { mutableStateOf<PrivacyListDialogMode?>(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<String>,
|
||||
users: List<UserSummaryDto>,
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+123
@@ -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<UserSummaryDto> = 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<PrivacySecurityUiState> = _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) {
|
||||
|
||||
+30
-15
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-6
@@ -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<ProfileUiState> = _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
|
||||
}
|
||||
}
|
||||
|
||||
+37
-21
@@ -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 подписчиков"
|
||||
}
|
||||
|
||||
+6
-12
@@ -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<ChatSearchResultDto>
|
||||
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 =
|
||||
|
||||
+20
-95
@@ -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
|
||||
}
|
||||
+58
-90
@@ -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
|
||||
}
|
||||
|
||||
+20
-23
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-78
@@ -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<NotificationSoundChoice>,
|
||||
callRingtoneOptions: List<NotificationSoundChoice>,
|
||||
repeatIntervalOptions: List<NotificationRepeatIntervalChoice>,
|
||||
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)
|
||||
}
|
||||
|
||||
+15
-14
@@ -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<NotificationSettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
val messageSoundOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.messageSoundChoices
|
||||
val callRingtoneOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.callRingtoneChoices
|
||||
val repeatIntervalOptions: List<NotificationRepeatIntervalChoice> = 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)
|
||||
}
|
||||
}
|
||||
|
||||
+45
-58
@@ -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<String>
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-67
@@ -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,
|
||||
|
||||
+3
-98
@@ -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<ShareChatTargetItemUiState> = emptyList(),
|
||||
val contacts: List<DiscoverUserItem> = 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<ChatSummaryDto>()
|
||||
private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap()
|
||||
private var currentUserId: String? = container.sessionStore.read()?.user?.id
|
||||
private var searchJob: Job? = null
|
||||
|
||||
val uiState: StateFlow<ShareTargetUiState> = _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<ChatSummaryDto>, query: String): List<ShareChatTargetItemUiState> {
|
||||
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<DiscoverUserItem> {
|
||||
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<String, String> {
|
||||
if (!hasContactsPermission()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
return container.contactDiscoveryService.getContacts().associate { contact ->
|
||||
contact.normalizedPhoneNumber to contact.displayName
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasContactsPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(
|
||||
container.appContext,
|
||||
|
||||
+465
-28
@@ -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<StoryDto>,
|
||||
accessToken: String?,
|
||||
isOwnStory: Boolean,
|
||||
replyText: String,
|
||||
isSendingReply: Boolean,
|
||||
replyErrorMessage: String?,
|
||||
replySent: Boolean,
|
||||
storyViews: List<StoryViewDto>,
|
||||
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<StoryDto>,
|
||||
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<StoryDto>) {
|
||||
private fun StoryProgressSegments(
|
||||
story: StoryDto,
|
||||
stories: List<StoryDto>,
|
||||
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<StoryViewDto>,
|
||||
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<StoryViewDto>,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+162
-5
@@ -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<StoryViewDto> = 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
|
||||
}
|
||||
|
||||
+30
-8
@@ -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)
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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 ?: "?"})."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<MessengerHub>());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<BadRequestObjectResult>(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<ConflictObjectResult>(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<UnauthorizedObjectResult>(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<NotFoundObjectResult>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.ObjectResult>(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<MessengerHub>(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<MessengerHub>(),
|
||||
pushQueue);
|
||||
}
|
||||
|
||||
private static User CreateUser(string username, string displayName)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Username = username,
|
||||
NormalizedUsername = username.ToUpperInvariant(),
|
||||
DisplayName = displayName
|
||||
};
|
||||
user.PasswordHash = new PasswordHasher<User>().HashPassword(user, "password");
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ObjectResult>(response.Result);
|
||||
Assert.Equal(410, result.StatusCode);
|
||||
var message = Assert.IsType<string>(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]
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<MessengerHub>();
|
||||
var botAttachmentStorageService = attachmentStorageService ?? CreateAttachmentStorageService(Path.GetTempPath());
|
||||
var controller = new ChatsController(
|
||||
dbContext,
|
||||
new PresenceTracker(),
|
||||
new NoopHubContext<MessengerHub>(),
|
||||
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<MessengerHub>());
|
||||
}
|
||||
|
||||
public static StoriesController CreateStoriesController(
|
||||
IkarDbContext dbContext,
|
||||
Guid userId,
|
||||
IHubContext<MessengerHub> 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<T>(ActionResult<T> actionResult)
|
||||
{
|
||||
var ok = Assert.IsType<OkObjectResult>(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<string> Targets,
|
||||
string Method,
|
||||
int ArgumentCount);
|
||||
|
||||
internal sealed class RecordingHubContext<THub> : IHubContext<THub>
|
||||
where THub : Hub
|
||||
{
|
||||
private readonly RecordingHubClients _clients = new();
|
||||
|
||||
public IHubClients Clients => _clients;
|
||||
|
||||
public IGroupManager Groups { get; } = new RecordingGroupManager();
|
||||
|
||||
public IReadOnlyList<RecordedHubSend> Sends => _clients.Sends;
|
||||
|
||||
private sealed class RecordingHubClients : IHubClients
|
||||
{
|
||||
private readonly List<RecordedHubSend> _sends = [];
|
||||
|
||||
public IReadOnlyList<RecordedHubSend> Sends => _sends;
|
||||
|
||||
public IClientProxy All => Proxy("All", []);
|
||||
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("AllExcept", excludedConnectionIds);
|
||||
|
||||
public IClientProxy Client(string connectionId) => Proxy("Client", [connectionId]);
|
||||
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy("Clients", connectionIds);
|
||||
|
||||
public IClientProxy Group(string groupName) => Proxy("Group", [groupName]);
|
||||
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
|
||||
Proxy("GroupExcept", [groupName, .. excludedConnectionIds]);
|
||||
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy("Groups", groupNames);
|
||||
|
||||
public IClientProxy User(string userId) => Proxy("User", [userId]);
|
||||
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy("Users", userIds);
|
||||
|
||||
private IClientProxy Proxy(string targetKind, IReadOnlyList<string> targets) =>
|
||||
new RecordingClientProxy(_sends, targetKind, targets);
|
||||
}
|
||||
|
||||
private sealed class RecordingClientProxy(
|
||||
List<RecordedHubSend> sends,
|
||||
string targetKind,
|
||||
IReadOnlyList<string> 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<THub> : IHubContext<THub>
|
||||
|
||||
@@ -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<MessengerHub>();
|
||||
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<MessengerHub>();
|
||||
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<MessengerHub>(),
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PushNotificationDispatcher>.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<PushNotificationDispatcher>.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<PushNotificationDispatcher>.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<MessagePushCall> MessageCalls { get; } = [];
|
||||
public List<ReactionPushCall> ReactionCalls { get; } = [];
|
||||
|
||||
public Task SendMessageAsync(
|
||||
Chat chat,
|
||||
Message message,
|
||||
IReadOnlyCollection<PushDevice> 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<PushDevice> Devices);
|
||||
|
||||
private sealed record ReactionPushCall(
|
||||
Chat Chat,
|
||||
Message Message,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BadRequestObjectResult>(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;
|
||||
}
|
||||
}
|
||||
@@ -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<User> _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<ActionResult<EmailCodeChallengeDto>> 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<ActionResult<AuthSessionDto>> 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<ActionResult<AuthSessionDto>> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken)
|
||||
@@ -258,6 +400,104 @@ public sealed class AuthController(
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<User> 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<string> GenerateInternalUsernameAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
|
||||
@@ -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<MessengerHub> 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<ActionResult<UserSummaryDto>> 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<ActionResult<MessageDto>> 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<ActionResult<MessageDto>> 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<ActionResult<MessageDto>> 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<ActionResult<MessageDto>> 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<MessageAttachment>();
|
||||
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<ActionResult<MessageDto>> SendVoice(
|
||||
string token,
|
||||
[FromForm] BotSendAttachmentRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAttachmentAsync(token, request, forceVoiceNote: true, requiredMediaKind: null, cancellationToken: cancellationToken);
|
||||
|
||||
private async Task<ActionResult<MessageDto>> 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<string> 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<BotCommandDto> NormalizeCommands(IReadOnlyList<BotCommandDto> commands)
|
||||
{
|
||||
var normalized = new List<BotCommandDto>();
|
||||
|
||||
@@ -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<User> _passwordHasher = new();
|
||||
|
||||
[HttpGet("mine")]
|
||||
public async Task<ActionResult<IReadOnlyList<BotSummaryDto>>> GetMine(CancellationToken cancellationToken)
|
||||
@@ -49,48 +50,12 @@ public sealed class BotsController(
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<CreatedBotDto>> CreateBot(
|
||||
CreateBotRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
public ActionResult<CreatedBotDto> 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<IActionResult> 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();
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<MessengerHub> 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<ActionResult<IReadOnlyList<StoryViewDto>>> 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<Guid> { 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<Guid> 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<Story?> 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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user