Expand Ikar messaging and Android release support
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
.git
|
.git
|
||||||
.vs
|
.vs
|
||||||
.codex-*
|
.codex-*
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
**/bin
|
**/bin
|
||||||
**/obj
|
**/obj
|
||||||
docker-data
|
docker-data
|
||||||
|
|||||||
@@ -11,3 +11,10 @@ Push__ServiceAccountJsonPath=
|
|||||||
Push__ServiceAccountJson=
|
Push__ServiceAccountJson=
|
||||||
Admin__Username=
|
Admin__Username=
|
||||||
Admin__Password=
|
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)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$Password,
|
[string]$Password,
|
||||||
[string]$ArgusBaseUrl = 'https://argus.kusoft.xyz',
|
[string]$ArgusBaseUrl = 'https://argus.kusoft.xyz',
|
||||||
[string]$ArgusInternalBaseUrl = 'http://127.0.0.1:5105',
|
[string]$ArgusInternalBaseUrl = 'unused',
|
||||||
[string]$ArgusEnvPath = '/home/sevenhill/argus-deploy/.env.server',
|
[string]$ArgusEnvPath = 'unused',
|
||||||
[string]$Slug = 'ikar-android',
|
[string]$Slug = 'ikar-android',
|
||||||
[string]$Notes = 'Android client update for Ikar',
|
[string]$Notes = 'Android client update for Ikar',
|
||||||
[string]$AppName = 'Ikar Android',
|
[string]$AppName = 'Ikar',
|
||||||
[string]$Summary = 'Android client build',
|
[string]$Summary = 'Standalone Android client for Ikar on Kotlin and Jetpack Compose.',
|
||||||
[string]$Description = 'Android release channel for the Ikar client.',
|
[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]$HomepageUrl = 'https://ikar.kusoft.xyz',
|
||||||
|
[string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/Ikar.git',
|
||||||
[string]$Platform = 'android',
|
[string]$Platform = 'android',
|
||||||
[string]$PackageKind = 'apk',
|
[string]$PackageKind = 'apk',
|
||||||
[string]$Channel = 'stable'
|
[string]$Channel = 'stable',
|
||||||
|
[switch]$Unlisted
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$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
|
if (-not (Test-Path $prepareScript)) {
|
||||||
$projectPath = '.\src\Ikar.Client\Ikar.Client.csproj'
|
throw "Argus preparation script was not found: $prepareScript"
|
||||||
|
}
|
||||||
[xml]$projectXml = Get-Content $projectPath
|
if (-not (Test-Path $publishScript)) {
|
||||||
$versionName = $projectXml.Project.PropertyGroup.ApplicationDisplayVersion | Select-Object -First 1
|
throw "Argus SSH publication script was not found: $publishScript"
|
||||||
$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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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'
|
& $prepareScript @prepareArgs
|
||||||
if (-not (Test-Path $sourceApk)) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "Signed APK not found: $sourceApk"
|
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. жёсткие требования, которые реально проверяет сервер;
|
`G:\Repos\Marketplace\ARGUS_PUBLICATION.md`
|
||||||
2. дополнительные требования, без которых приложение не будет нормально устанавливаться через `Argus Store`;
|
|
||||||
3. условия, при которых `Argus Store` сможет предлагать автообновления.
|
|
||||||
|
|
||||||
Если цель публикации именно в 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`: `ikar-android`
|
||||||
- `slug`;
|
- `channel`: `stable`
|
||||||
- `name`;
|
- `platform`: `android`
|
||||||
- `summary`;
|
- `packageKind`: `apk`
|
||||||
- `description`;
|
- public base URL: `https://argus.kusoft.xyz`
|
||||||
- `version`;
|
|
||||||
- `channel`;
|
|
||||||
- `platform`;
|
|
||||||
- `packageKind`;
|
|
||||||
- `notes` при наличии;
|
|
||||||
- `repositoryUrl` при наличии;
|
|
||||||
- `homepageUrl` при наличии;
|
|
||||||
- `androidPackageName` для Android-релизов;
|
|
||||||
- `androidVersionCode` для Android-релизов;
|
|
||||||
- решение, будет ли приложение публичным (`isListed=true`) или скрытым (`isListed=false`).
|
|
||||||
|
|
||||||
Причина: именно эти поля использует текущий publish-скрипт и серверные DTO.
|
## Local workflow
|
||||||
Источник: `scripts/publish-release.ps1:2-46`, `Contracts/ArgusDtos.cs:62-94`.
|
|
||||||
|
|
||||||
## 2. Жёсткие серверные требования
|
1. Build and stage a signed APK:
|
||||||
|
|
||||||
### 2.1. Требования к `slug`
|
```powershell
|
||||||
|
.\scripts\prepare-argus-publication.ps1
|
||||||
|
```
|
||||||
|
|
||||||
- `slug` обязателен.
|
2. Publish the staged APK through SSH/SQLite:
|
||||||
- Разрешены только строчные латинские буквы, цифры и дефисы.
|
|
||||||
- Максимальная длина `slug` — `100` символов.
|
|
||||||
- Значение должно уже быть нормализовано; сервер не принимает произвольный mixed-case `slug`.
|
|
||||||
|
|
||||||
Фактическое правило валидации:
|
```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, заменяет любые неалфавитно-цифровые символы на `-`, схлопывает повторные дефисы и обрезает дефисы по краям.
|
3. Verify the public manifest:
|
||||||
- `SlugUtility.IsValid(...)` принимает только строки, которые уже совпадают со своим нормализованным вариантом и имеют длину до `100`.
|
|
||||||
|
|
||||||
Источник: `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`
|
- treat HTTP 404 from the manifest endpoint as "no compatible release";
|
||||||
- `summary`
|
- compare the installed app version to `release.version`;
|
||||||
- `description`
|
- 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;
|
||||||
Источник: `Program.cs:372-379`, `Program.cs:582-607`.
|
- submit the APK to the Android package installer;
|
||||||
|
- record the new installed version only after Android reports a successful install.
|
||||||
### 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`.
|
|
||||||
|
|||||||
@@ -24,10 +24,14 @@
|
|||||||
- Локальная release-подпись хранится в gitignored файлах `argus-signing.local.properties` и `signing/`.
|
- Локальная release-подпись хранится в gitignored файлах `argus-signing.local.properties` и `signing/`.
|
||||||
- Единая координата публикации для клиента Икар: slug `ikar-android`, channel `stable`, platform `android`.
|
- Единая координата публикации для клиента Икар: slug `ikar-android`, channel `stable`, platform `android`.
|
||||||
Именно этот manifest запрашивают текущий Android-клиент и серверный proxy обновлений.
|
Именно этот 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:
|
- Первый запуск локального signing pipeline:
|
||||||
- `.\scripts\new-argus-upload-keystore.ps1`
|
- `.\scripts\new-argus-upload-keystore.ps1`
|
||||||
- Подготовка signed APK и metadata JSON для Argus:
|
- Подготовка signed APK и metadata JSON для Argus:
|
||||||
- `.\scripts\prepare-argus-publication.ps1`
|
- `.\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:
|
Скрипт подготовки собирает `release` APK, проверяет подпись и извлекает из самого APK:
|
||||||
- `androidPackageName`
|
- `androidPackageName`
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ android {
|
|||||||
applicationId = "com.seven.ikar.kotlin"
|
applicationId = "com.seven.ikar.kotlin"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 180
|
versionCode = 209
|
||||||
versionName = "3.67"
|
versionName = "3.96"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
@@ -88,6 +88,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
val composeBom = platform("androidx.compose:compose-bom:2026.03.00")
|
val composeBom = platform("androidx.compose:compose-bom:2026.03.00")
|
||||||
val roomVersion = "2.8.4"
|
val roomVersion = "2.8.4"
|
||||||
|
val media3Version = "1.10.1"
|
||||||
|
|
||||||
implementation("androidx.core:core-ktx:1.17.0")
|
implementation("androidx.core:core-ktx:1.17.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
|
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:okhttp:4.12.0")
|
||||||
implementation("com.squareup.okhttp3:logging-interceptor: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-compose:2.7.0")
|
||||||
|
implementation("io.coil-kt:coil-gif:2.7.0")
|
||||||
implementation("com.microsoft.signalr:signalr:8.0.17")
|
implementation("com.microsoft.signalr:signalr:8.0.17")
|
||||||
implementation("com.google.firebase:firebase-messaging:25.0.1")
|
implementation("com.google.firebase:firebase-messaging:25.0.1")
|
||||||
implementation("io.github.webrtc-sdk:android:144.7559.01")
|
implementation("io.github.webrtc-sdk:android:144.7559.01")
|
||||||
|
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
||||||
|
implementation("androidx.media3:media3-ui:$media3Version")
|
||||||
|
|
||||||
implementation(composeBom)
|
implementation(composeBom)
|
||||||
androidTestImplementation(composeBom)
|
androidTestImplementation(composeBom)
|
||||||
|
|||||||
@@ -42,6 +42,41 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="*/*" />
|
<data android:mimeType="*/*" />
|
||||||
</intent-filter>
|
</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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import androidx.core.view.WindowInsetsControllerCompat
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.seven.ikar.kotlin.app.IkarApp
|
import com.seven.ikar.kotlin.app.IkarApp
|
||||||
import com.seven.ikar.kotlin.app.IkarNativeApp
|
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.AndroidAppVisibilityState
|
||||||
|
import com.seven.ikar.kotlin.core.push.ChatNotificationDismissalManager
|
||||||
import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupHelper
|
import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupHelper
|
||||||
import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupResult
|
import com.seven.ikar.kotlin.core.push.IncomingCallFullScreenSetupResult
|
||||||
import com.seven.ikar.kotlin.core.push.InitialCallPermissionPromptStore
|
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.pendingIncomingCallInviteFromIntent
|
||||||
import com.seven.ikar.kotlin.core.push.PushDataKeys
|
import com.seven.ikar.kotlin.core.push.PushDataKeys
|
||||||
import com.seven.ikar.kotlin.core.push.PushDataKinds
|
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.feature.share.PendingExternalShare
|
||||||
import com.seven.ikar.kotlin.ui.SecureWindowFlagController
|
import com.seven.ikar.kotlin.ui.SecureWindowFlagController
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -95,6 +98,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
AndroidAppVisibilityState.setVisible(true)
|
AndroidAppVisibilityState.setVisible(true)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
runCatching { applicationContainer.pushRegistrationManager.registerCurrentDeviceAsync() }
|
||||||
|
}
|
||||||
|
ChatNotificationDismissalManager.cancelChatNotifications(this, AndroidActiveChatState.getActiveChatId())
|
||||||
enforceAppLockIfNeeded()
|
enforceAppLockIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +125,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applicationContainer.appLockState.setLocked(true)
|
applicationContainer.appLockState.setLocked(true)
|
||||||
|
if (applicationContainer.privacyRuntimeSettings.hasPasscode()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
SecureWindowFlagController.setSecure(window, AppLockSecureToken, true)
|
SecureWindowFlagController.setSecure(window, AppLockSecureToken, true)
|
||||||
val keyguardManager = getSystemService(KeyguardManager::class.java)
|
val keyguardManager = getSystemService(KeyguardManager::class.java)
|
||||||
val unlockIntent = if (keyguardManager.isDeviceSecure) {
|
val unlockIntent = if (keyguardManager.isDeviceSecure) {
|
||||||
@@ -176,6 +187,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
private fun handleIncomingIntent(intent: Intent?) {
|
private fun handleIncomingIntent(intent: Intent?) {
|
||||||
val incomingIntent = intent ?: return
|
val incomingIntent = intent ?: return
|
||||||
if (incomingIntent.action == Intent.ACTION_VIEW) {
|
if (incomingIntent.action == Intent.ACTION_VIEW) {
|
||||||
|
if (handleIkarDeepLink(incomingIntent.data)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val archiveUri = incomingIntent.data ?: incomingIntent.readSingleStream()
|
val archiveUri = incomingIntent.data ?: incomingIntent.readSingleStream()
|
||||||
if (archiveUri != null) {
|
if (archiveUri != null) {
|
||||||
lifecycleScope.launch {
|
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? {
|
private suspend fun extractPendingShare(intent: Intent): PendingExternalShare? {
|
||||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim()?.takeIf { it.isNotBlank() }
|
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim()?.takeIf { it.isNotBlank() }
|
||||||
val attachmentUris = when (intent.action) {
|
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.ChatMessageCache
|
||||||
import com.seven.ikar.kotlin.core.local.IkarLocalDatabase
|
import com.seven.ikar.kotlin.core.local.IkarLocalDatabase
|
||||||
import com.seven.ikar.kotlin.core.model.AuthSessionDto
|
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.ApiClient
|
||||||
import com.seven.ikar.kotlin.core.network.ClientVersionProvider
|
import com.seven.ikar.kotlin.core.network.ClientVersionProvider
|
||||||
import com.seven.ikar.kotlin.core.push.AndroidPushSettings
|
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.settings.AppSettingsSnapshot
|
||||||
import com.seven.ikar.kotlin.core.session.AuthorizedSessionManager
|
import com.seven.ikar.kotlin.core.session.AuthorizedSessionManager
|
||||||
import com.seven.ikar.kotlin.core.session.SessionStore
|
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.core.update.ArgusAppUpdateManager
|
||||||
import com.seven.ikar.kotlin.feature.archive.AndroidArchiveFileService
|
import com.seven.ikar.kotlin.feature.archive.AndroidArchiveFileService
|
||||||
import com.seven.ikar.kotlin.feature.archive.IncomingArchiveStore
|
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.AndroidAttachmentFileService
|
||||||
import com.seven.ikar.kotlin.feature.chat.AndroidVoiceNotePlayerService
|
import com.seven.ikar.kotlin.feature.chat.AndroidVoiceNotePlayerService
|
||||||
import com.seven.ikar.kotlin.feature.chat.AndroidVoiceNoteRecorderService
|
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.chat.ChatScrollStateStore
|
||||||
import com.seven.ikar.kotlin.feature.share.IncomingShareStore
|
import com.seven.ikar.kotlin.feature.share.IncomingShareStore
|
||||||
import com.seven.ikar.kotlin.feature.transfer.TransferQueueRepository
|
import com.seven.ikar.kotlin.feature.transfer.TransferQueueRepository
|
||||||
@@ -74,6 +77,8 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var activeHttpClient: OkHttpClient = buildHttpClient(appSettings.loadSnapshot())
|
private var activeHttpClient: OkHttpClient = buildHttpClient(appSettings.loadSnapshot())
|
||||||
|
@Volatile
|
||||||
|
private var activeProxySignature: String = proxySignature(appSettings.loadSnapshot())
|
||||||
|
|
||||||
val httpClient: OkHttpClient
|
val httpClient: OkHttpClient
|
||||||
get() = activeHttpClient
|
get() = activeHttpClient
|
||||||
@@ -89,7 +94,10 @@ class AppContainer(context: Context) {
|
|||||||
dao = localDatabase.cachedChatDao(),
|
dao = localDatabase.cachedChatDao(),
|
||||||
json = json
|
json = json
|
||||||
)
|
)
|
||||||
val appUpdateManager = ArgusAppUpdateManager(appContext)
|
val appUpdateManager = ArgusAppUpdateManager(
|
||||||
|
context = appContext,
|
||||||
|
client = ArgusAppUpdateClient(proxyProvider = { buildProxy(appSettings.loadSnapshot()) })
|
||||||
|
)
|
||||||
val apiClient = ApiClient(
|
val apiClient = ApiClient(
|
||||||
httpClientProvider = { httpClient },
|
httpClientProvider = { httpClient },
|
||||||
json = json,
|
json = json,
|
||||||
@@ -114,13 +122,20 @@ class AppContainer(context: Context) {
|
|||||||
scheduler = transferWorkScheduler
|
scheduler = transferWorkScheduler
|
||||||
)
|
)
|
||||||
val chatScrollStateStore = ChatScrollStateStore(appContext, json)
|
val chatScrollStateStore = ChatScrollStateStore(appContext, json)
|
||||||
|
val chatDraftStore = ChatDraftStore(appContext)
|
||||||
val incomingShareStore = IncomingShareStore()
|
val incomingShareStore = IncomingShareStore()
|
||||||
val incomingArchiveStore = IncomingArchiveStore()
|
val incomingArchiveStore = IncomingArchiveStore()
|
||||||
val incomingPushNavigationStore = IncomingPushNavigationStore()
|
val incomingPushNavigationStore = IncomingPushNavigationStore()
|
||||||
|
val incomingLinkNavigationStore = IncomingLinkNavigationStore()
|
||||||
val incomingCallInviteStore = IncomingCallInviteStore()
|
val incomingCallInviteStore = IncomingCallInviteStore()
|
||||||
val voiceNotePlayerService = AndroidVoiceNotePlayerService()
|
val voiceNotePlayerService = AndroidVoiceNotePlayerService(appContext)
|
||||||
val voiceNoteRecorderService = AndroidVoiceNoteRecorderService(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 webRtcCallService = AndroidWebRtcCallService(appContext)
|
||||||
val callTonePlayer = AndroidCallTonePlayer()
|
val callTonePlayer = AndroidCallTonePlayer()
|
||||||
val callManager = CallManager(
|
val callManager = CallManager(
|
||||||
@@ -141,7 +156,12 @@ class AppContainer(context: Context) {
|
|||||||
init {
|
init {
|
||||||
applicationScope.launch {
|
applicationScope.launch {
|
||||||
appSettings.snapshot.collect { snapshot ->
|
appSettings.snapshot.collect { snapshot ->
|
||||||
|
val nextProxySignature = proxySignature(snapshot)
|
||||||
activeHttpClient = buildHttpClient(snapshot)
|
activeHttpClient = buildHttpClient(snapshot)
|
||||||
|
if (nextProxySignature != activeProxySignature) {
|
||||||
|
activeProxySignature = nextProxySignature
|
||||||
|
realtimeService.reconnectForNetworkSettings()
|
||||||
|
}
|
||||||
runCatching {
|
runCatching {
|
||||||
attachmentFileService.enforceCachePolicy(
|
attachmentFileService.enforceCachePolicy(
|
||||||
maxCacheMb = snapshot.maxCacheMb,
|
maxCacheMb = snapshot.maxCacheMb,
|
||||||
@@ -180,4 +200,12 @@ class AppContainer(context: Context) {
|
|||||||
}
|
}
|
||||||
return Proxy(proxyType, InetSocketAddress(host, settings.proxyPort))
|
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
|
package com.seven.ikar.kotlin.app
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.background
|
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.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.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
@@ -18,6 +24,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
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.core.settings.AppLocaleManager
|
||||||
import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorScreen
|
import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorScreen
|
||||||
import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorViewModel
|
import com.seven.ikar.kotlin.feature.archive.ArchiveExtractorViewModel
|
||||||
import com.seven.ikar.kotlin.feature.bots.BotEditorScreen
|
import com.seven.ikar.kotlin.feature.browser.InAppBrowserScreen
|
||||||
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.call.CallScreen
|
import com.seven.ikar.kotlin.feature.call.CallScreen
|
||||||
import com.seven.ikar.kotlin.core.push.AndroidActiveChatState
|
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.ChatScreen
|
||||||
import com.seven.ikar.kotlin.feature.chat.ChatViewModel
|
import com.seven.ikar.kotlin.feature.chat.ChatViewModel
|
||||||
import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoScreen
|
import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoScreen
|
||||||
import com.seven.ikar.kotlin.feature.chatinfo.ChatInfoViewModel
|
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.ChatsScreen
|
||||||
import com.seven.ikar.kotlin.feature.chats.ChatsViewModel
|
import com.seven.ikar.kotlin.feature.chats.ChatsViewModel
|
||||||
import com.seven.ikar.kotlin.feature.contacts.ContactsScreen
|
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.folders.ChatFoldersViewModel
|
||||||
import com.seven.ikar.kotlin.feature.forward.ForwardScreen
|
import com.seven.ikar.kotlin.feature.forward.ForwardScreen
|
||||||
import com.seven.ikar.kotlin.feature.forward.ForwardViewModel
|
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.LoginScreen
|
||||||
import com.seven.ikar.kotlin.feature.login.LoginViewModel
|
import com.seven.ikar.kotlin.feature.login.LoginViewModel
|
||||||
import com.seven.ikar.kotlin.feature.mainmenu.MainMenuViewModel
|
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.ChatWallpaperScreen
|
||||||
import com.seven.ikar.kotlin.feature.settings.ChatSettingsViewModel
|
import com.seven.ikar.kotlin.feature.settings.ChatSettingsViewModel
|
||||||
import com.seven.ikar.kotlin.feature.settings.AppSettingsViewModel
|
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.DataStorageSettingsScreen
|
||||||
import com.seven.ikar.kotlin.feature.settings.LanguagePlaceholderScreen
|
|
||||||
import com.seven.ikar.kotlin.feature.settings.NotificationSettingsScreen
|
import com.seven.ikar.kotlin.feature.settings.NotificationSettingsScreen
|
||||||
import com.seven.ikar.kotlin.feature.settings.NotificationSettingsViewModel
|
import com.seven.ikar.kotlin.feature.settings.NotificationSettingsViewModel
|
||||||
import com.seven.ikar.kotlin.feature.settings.PrivacyExceptionsPlaceholderScreen
|
import com.seven.ikar.kotlin.feature.settings.ProxySettingsScreen
|
||||||
import com.seven.ikar.kotlin.feature.settings.ProxyPlaceholderScreen
|
|
||||||
import com.seven.ikar.kotlin.feature.settings.SettingsScreen
|
import com.seven.ikar.kotlin.feature.settings.SettingsScreen
|
||||||
import com.seven.ikar.kotlin.feature.settings.SettingsViewModel
|
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.StoriesScreen
|
||||||
import com.seven.ikar.kotlin.feature.stories.StoriesViewModel
|
import com.seven.ikar.kotlin.feature.stories.StoriesViewModel
|
||||||
import com.seven.ikar.kotlin.feature.stories.StoryComposerScreen
|
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.feature.update.AppUpdateViewModel
|
||||||
import com.seven.ikar.kotlin.ui.LocalIkarAccessToken
|
import com.seven.ikar.kotlin.ui.LocalIkarAccessToken
|
||||||
import com.seven.ikar.kotlin.ui.LocalUnreadChatsCount
|
import com.seven.ikar.kotlin.ui.LocalUnreadChatsCount
|
||||||
|
import com.seven.ikar.kotlin.ui.SecureWindowEffect
|
||||||
import com.seven.ikar.kotlin.ui.theme.IkarKotlinTheme
|
import com.seven.ikar.kotlin.ui.theme.IkarKotlinTheme
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val ChatRoutePattern = "chat/{chatId}?messageId={messageId}&scrollToLatest={scrollToLatest}&scrollRequestId={scrollRequestId}"
|
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(
|
private fun chatRoute(
|
||||||
chatId: String,
|
chatId: String,
|
||||||
@@ -117,6 +132,10 @@ private fun chatRoute(
|
|||||||
return if (query.isEmpty()) route else "$route?${query.joinToString("&")}"
|
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
|
@Composable
|
||||||
fun IkarApp() {
|
fun IkarApp() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -128,8 +147,10 @@ fun IkarApp() {
|
|||||||
val pendingArchive by application.container.incomingArchiveStore.pendingArchive.collectAsStateWithLifecycle()
|
val pendingArchive by application.container.incomingArchiveStore.pendingArchive.collectAsStateWithLifecycle()
|
||||||
val pendingCallInvite by application.container.incomingCallInviteStore.pendingInvite.collectAsStateWithLifecycle()
|
val pendingCallInvite by application.container.incomingCallInviteStore.pendingInvite.collectAsStateWithLifecycle()
|
||||||
val pendingPushNavigation by application.container.incomingPushNavigationStore.pendingNavigation.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 appSettings by application.container.appSettings.snapshot.collectAsStateWithLifecycle()
|
||||||
val isAppLocked by application.container.appLockState.isLocked.collectAsStateWithLifecycle()
|
val isAppLocked by application.container.appLockState.isLocked.collectAsStateWithLifecycle()
|
||||||
|
SecureWindowEffect(enabled = isAppLocked)
|
||||||
val mainMenuViewModel: MainMenuViewModel = viewModel(
|
val mainMenuViewModel: MainMenuViewModel = viewModel(
|
||||||
factory = viewModelFactory {
|
factory = viewModelFactory {
|
||||||
initializer { MainMenuViewModel(application.container) }
|
initializer { MainMenuViewModel(application.container) }
|
||||||
@@ -201,12 +222,13 @@ fun IkarApp() {
|
|||||||
}
|
}
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
state = state,
|
state = state,
|
||||||
|
onEmailChanged = viewModel::updateEmail,
|
||||||
onPhoneNumberChanged = viewModel::updatePhoneNumber,
|
onPhoneNumberChanged = viewModel::updatePhoneNumber,
|
||||||
onDisplayNameChanged = viewModel::updateDisplayName,
|
onDisplayNameChanged = viewModel::updateDisplayName,
|
||||||
onVerificationCodeChanged = viewModel::updateVerificationCode,
|
onVerificationCodeChanged = viewModel::updateVerificationCode,
|
||||||
onRequestCodeClick = viewModel::requestCode,
|
onRequestCodeClick = viewModel::requestCode,
|
||||||
onVerifyCodeClick = viewModel::verifyCode,
|
onVerifyCodeClick = viewModel::verifyCode,
|
||||||
onChangePhoneNumberClick = viewModel::changePhoneNumber
|
onChangeEmailClick = viewModel::changeEmail
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +240,16 @@ fun IkarApp() {
|
|||||||
)
|
)
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val chatSettings by application.container.chatSettings.snapshot.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) {
|
LaunchedEffect(state.pendingShareNavigation?.requestId) {
|
||||||
val target = state.pendingShareNavigation ?: return@LaunchedEffect
|
val target = state.pendingShareNavigation ?: return@LaunchedEffect
|
||||||
viewModel.consumePendingShareNavigation()
|
viewModel.consumePendingShareNavigation()
|
||||||
@@ -249,6 +281,11 @@ fun IkarApp() {
|
|||||||
onPinSelectedChatsClick = viewModel::togglePinSelectedChats,
|
onPinSelectedChatsClick = viewModel::togglePinSelectedChats,
|
||||||
onArchiveSelectedChatsClick = viewModel::archiveSelectedChats,
|
onArchiveSelectedChatsClick = viewModel::archiveSelectedChats,
|
||||||
onMuteSelectedChatsClick = viewModel::muteSelectedChats,
|
onMuteSelectedChatsClick = viewModel::muteSelectedChats,
|
||||||
|
onTogglePinChat = viewModel::togglePinChat,
|
||||||
|
onToggleArchiveChat = viewModel::toggleArchiveChat,
|
||||||
|
onToggleMuteChat = viewModel::toggleMuteChat,
|
||||||
|
onMarkChatRead = viewModel::markChatRead,
|
||||||
|
onSwipeDeleteChat = viewModel::showSwipeDeleteDialog,
|
||||||
onDismissDeleteDialog = viewModel::dismissDeleteDialog,
|
onDismissDeleteDialog = viewModel::dismissDeleteDialog,
|
||||||
onDeleteForEveryoneChanged = viewModel::updateDeleteForEveryone,
|
onDeleteForEveryoneChanged = viewModel::updateDeleteForEveryone,
|
||||||
onConfirmDeleteSelected = viewModel::confirmDeleteSelected,
|
onConfirmDeleteSelected = viewModel::confirmDeleteSelected,
|
||||||
@@ -269,10 +306,99 @@ fun IkarApp() {
|
|||||||
onSettingsClick = { navigatePrimary("settings") },
|
onSettingsClick = { navigatePrimary("settings") },
|
||||||
onNotificationsClick = { navController.navigate("settings/notifications") },
|
onNotificationsClick = { navController.navigate("settings/notifications") },
|
||||||
onGlobalSearchClick = { navController.navigate("search") },
|
onGlobalSearchClick = { navController.navigate("search") },
|
||||||
|
onFilterChanged = viewModel::updateFilter,
|
||||||
onQueryChanged = viewModel::updateQuery
|
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") {
|
composable("search") {
|
||||||
val viewModel: GlobalSearchViewModel = viewModel(
|
val viewModel: GlobalSearchViewModel = viewModel(
|
||||||
factory = viewModelFactory {
|
factory = viewModelFactory {
|
||||||
@@ -345,6 +471,8 @@ fun IkarApp() {
|
|||||||
state = state,
|
state = state,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
chatSettings = chatSettings,
|
chatSettings = chatSettings,
|
||||||
|
largeEmojiEnabled = appSettings.largeEmoji,
|
||||||
|
animatedBackgroundsEnabled = appSettings.animatedBackgrounds,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onPeerProfileClick = state.peer?.id?.let { userId ->
|
onPeerProfileClick = state.peer?.id?.let { userId ->
|
||||||
{ navController.navigate("profile/user/$userId") }
|
{ navController.navigate("profile/user/$userId") }
|
||||||
@@ -359,6 +487,14 @@ fun IkarApp() {
|
|||||||
navController.navigate("chat/$chatId/notifications?title=$encodedTitle")
|
navController.navigate("chat/$chatId/notifications?title=$encodedTitle")
|
||||||
},
|
},
|
||||||
onChatWallpaperClick = { navController.navigate("settings/chats/wallpaper") },
|
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 ->
|
onForwardMessage = { message ->
|
||||||
if (!message.hasProtectedContent) {
|
if (!message.hasProtectedContent) {
|
||||||
navController.navigate("forward/${message.chatId}/${message.id}")
|
navController.navigate("forward/${message.chatId}/${message.id}")
|
||||||
@@ -424,6 +560,8 @@ fun IkarApp() {
|
|||||||
onToggleCanPin = viewModel::toggleCanPin,
|
onToggleCanPin = viewModel::toggleCanPin,
|
||||||
onToggleCanDelete = viewModel::toggleCanDelete,
|
onToggleCanDelete = viewModel::toggleCanDelete,
|
||||||
onToggleCanInvite = viewModel::toggleCanInvite,
|
onToggleCanInvite = viewModel::toggleCanInvite,
|
||||||
|
onTransferOwnership = viewModel::transferOwnership,
|
||||||
|
onRemoveMember = viewModel::removeMember,
|
||||||
onMemberSearchChanged = viewModel::updateMemberSearchQuery,
|
onMemberSearchChanged = viewModel::updateMemberSearchQuery,
|
||||||
onAddMember = { user -> viewModel.addMember(user, asAdmin = false) },
|
onAddMember = { user -> viewModel.addMember(user, asAdmin = false) },
|
||||||
onAddAdmin = { user -> viewModel.addMember(user, asAdmin = true) },
|
onAddAdmin = { user -> viewModel.addMember(user, asAdmin = true) },
|
||||||
@@ -450,12 +588,47 @@ fun IkarApp() {
|
|||||||
onRefreshClick = viewModel::refresh,
|
onRefreshClick = viewModel::refresh,
|
||||||
onMembersClick = { navController.navigate("chat/$chatId/members") },
|
onMembersClick = { navController.navigate("chat/$chatId/members") },
|
||||||
onSearchClick = { navController.navigate("search") },
|
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,
|
onInviteTitleChanged = viewModel::updateInviteTitle,
|
||||||
onInviteJoinRequestChanged = viewModel::updateInviteCreatesJoinRequest,
|
onInviteJoinRequestChanged = viewModel::updateInviteCreatesJoinRequest,
|
||||||
|
onInviteExpiresInHoursChanged = viewModel::updateInviteExpiresInHours,
|
||||||
|
onInviteMaxUsesChanged = viewModel::updateInviteMaxUses,
|
||||||
|
onInviteMemberLimitChanged = viewModel::updateInviteMemberLimit,
|
||||||
onCreateInviteClick = viewModel::createInviteLink,
|
onCreateInviteClick = viewModel::createInviteLink,
|
||||||
onRevokeInviteClick = viewModel::revokeInviteLink,
|
onRevokeInviteClick = viewModel::revokeInviteLink,
|
||||||
onTopicTitleChanged = viewModel::updateTopicTitle,
|
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() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onDisplayNameChanged = viewModel::updateDisplayName,
|
onDisplayNameChanged = viewModel::updateDisplayName,
|
||||||
onAboutChanged = viewModel::updateAbout,
|
onAboutChanged = viewModel::updateAbout,
|
||||||
onSaveClick = viewModel::save,
|
|
||||||
onDeleteAvatarClick = viewModel::deleteAvatar,
|
onDeleteAvatarClick = viewModel::deleteAvatar,
|
||||||
onAvatarSelected = viewModel::uploadAvatar
|
onAvatarSelected = viewModel::uploadAvatar
|
||||||
)
|
)
|
||||||
@@ -560,9 +732,13 @@ fun IkarApp() {
|
|||||||
onQueryChanged = viewModel::updateQuery,
|
onQueryChanged = viewModel::updateQuery,
|
||||||
onRefreshClick = viewModel::refresh,
|
onRefreshClick = viewModel::refresh,
|
||||||
onUserClick = viewModel::handleUserTap,
|
onUserClick = viewModel::handleUserTap,
|
||||||
|
onChannelClick = viewModel::openPublicChannel,
|
||||||
onBeginGroupSelection = viewModel::beginGroupSelection,
|
onBeginGroupSelection = viewModel::beginGroupSelection,
|
||||||
onBeginChannelSelection = viewModel::beginChannelSelection,
|
onBeginChannelSelection = viewModel::beginChannelSelection,
|
||||||
onCancelSelection = viewModel::cancelSelection,
|
onCancelSelection = viewModel::cancelSelection,
|
||||||
|
onGroupBackClick = viewModel::previousGroupWizardStep,
|
||||||
|
onGroupTitleChanged = viewModel::updateGroupTitle,
|
||||||
|
onContinueGroupWizard = viewModel::continueGroupWizard,
|
||||||
onChannelBackClick = viewModel::previousChannelWizardStep,
|
onChannelBackClick = viewModel::previousChannelWizardStep,
|
||||||
onChannelTitleChanged = viewModel::updateChannelTitle,
|
onChannelTitleChanged = viewModel::updateChannelTitle,
|
||||||
onChannelDescriptionChanged = viewModel::updateChannelDescription,
|
onChannelDescriptionChanged = viewModel::updateChannelDescription,
|
||||||
@@ -621,7 +797,6 @@ fun IkarApp() {
|
|||||||
onChatSettingsClick = { navController.navigate("settings/chats") },
|
onChatSettingsClick = { navController.navigate("settings/chats") },
|
||||||
onPrivacyClick = { navController.navigate("settings/privacy") },
|
onPrivacyClick = { navController.navigate("settings/privacy") },
|
||||||
onNotificationsClick = { navController.navigate("settings/notifications") },
|
onNotificationsClick = { navController.navigate("settings/notifications") },
|
||||||
onBotsClick = { navController.navigate("bots") },
|
|
||||||
onFoldersClick = { navController.navigate("settings/folders") },
|
onFoldersClick = { navController.navigate("settings/folders") },
|
||||||
onDevicesClick = { navController.navigate("settings/devices") },
|
onDevicesClick = { navController.navigate("settings/devices") },
|
||||||
onUpdatesClick = { navController.navigate("settings/update") },
|
onUpdatesClick = { navController.navigate("settings/update") },
|
||||||
@@ -643,21 +818,19 @@ fun IkarApp() {
|
|||||||
PrivacySecurityScreen(
|
PrivacySecurityScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onRefreshClick = viewModel::refresh,
|
onUpdate = viewModel::update,
|
||||||
onBlockedUsersClick = { navController.navigate("settings/privacy/blocked") },
|
onUserSearchQueryChanged = viewModel::updateUserSearchQuery,
|
||||||
onExceptionsClick = { navController.navigate("settings/privacy/exceptions") },
|
onBlockUser = viewModel::blockUser,
|
||||||
onUpdate = viewModel::update
|
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") {
|
composable("settings/data-storage") {
|
||||||
val viewModel: AppSettingsViewModel = viewModel(
|
val viewModel: AppSettingsViewModel = viewModel(
|
||||||
factory = viewModelFactory {
|
factory = viewModelFactory {
|
||||||
@@ -681,7 +854,7 @@ fun IkarApp() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
ThemePlaceholderScreen(
|
ThemeSettingsScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onSnapshotChanged = viewModel::updateSnapshot,
|
onSnapshotChanged = viewModel::updateSnapshot,
|
||||||
@@ -696,7 +869,7 @@ fun IkarApp() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
LanguagePlaceholderScreen(
|
LanguageSettingsScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onSnapshotChanged = viewModel::updateSnapshot
|
onSnapshotChanged = viewModel::updateSnapshot
|
||||||
@@ -710,7 +883,7 @@ fun IkarApp() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
ProxyPlaceholderScreen(
|
ProxySettingsScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onSnapshotChanged = viewModel::updateSnapshot
|
onSnapshotChanged = viewModel::updateSnapshot
|
||||||
@@ -724,6 +897,16 @@ fun IkarApp() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
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(
|
StoriesScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
@@ -734,6 +917,11 @@ fun IkarApp() {
|
|||||||
onCloseStoryPreview = viewModel::closeStoryPreview,
|
onCloseStoryPreview = viewModel::closeStoryPreview,
|
||||||
onNextStory = viewModel::openNextStory,
|
onNextStory = viewModel::openNextStory,
|
||||||
onPreviousStory = viewModel::openPreviousStory,
|
onPreviousStory = viewModel::openPreviousStory,
|
||||||
|
onStoryReplyTextChanged = viewModel::updateStoryReplyText,
|
||||||
|
onSendStoryReply = viewModel::sendStoryReply,
|
||||||
|
onSendStoryReaction = viewModel::sendStoryReaction,
|
||||||
|
onShowStoryViews = viewModel::loadStoryViews,
|
||||||
|
onCloseStoryViews = viewModel::closeStoryViews,
|
||||||
onRetryTransfer = viewModel::retryTransfer,
|
onRetryTransfer = viewModel::retryTransfer,
|
||||||
onCancelTransfer = viewModel::cancelTransfer
|
onCancelTransfer = viewModel::cancelTransfer
|
||||||
)
|
)
|
||||||
@@ -767,6 +955,11 @@ fun IkarApp() {
|
|||||||
},
|
},
|
||||||
onNextStory = viewModel::openNextStory,
|
onNextStory = viewModel::openNextStory,
|
||||||
onPreviousStory = viewModel::openPreviousStory,
|
onPreviousStory = viewModel::openPreviousStory,
|
||||||
|
onStoryReplyTextChanged = viewModel::updateStoryReplyText,
|
||||||
|
onSendStoryReply = viewModel::sendStoryReply,
|
||||||
|
onSendStoryReaction = viewModel::sendStoryReaction,
|
||||||
|
onShowStoryViews = viewModel::loadStoryViews,
|
||||||
|
onCloseStoryViews = viewModel::closeStoryViews,
|
||||||
onRetryTransfer = viewModel::retryTransfer,
|
onRetryTransfer = viewModel::retryTransfer,
|
||||||
onCancelTransfer = viewModel::cancelTransfer
|
onCancelTransfer = viewModel::cancelTransfer
|
||||||
)
|
)
|
||||||
@@ -805,8 +998,7 @@ fun IkarApp() {
|
|||||||
DevicesScreen(
|
DevicesScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onRefreshClick = viewModel::refresh,
|
onRefreshClick = viewModel::refresh
|
||||||
onRevokeClick = viewModel::revoke
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,82 +1151,13 @@ fun IkarApp() {
|
|||||||
NotificationSettingsScreen(
|
NotificationSettingsScreen(
|
||||||
state = state,
|
state = state,
|
||||||
messageSoundOptions = viewModel.messageSoundOptions,
|
messageSoundOptions = viewModel.messageSoundOptions,
|
||||||
callRingtoneOptions = viewModel.callRingtoneOptions,
|
|
||||||
repeatIntervalOptions = viewModel.repeatIntervalOptions,
|
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onSnapshotChanged = { snapshot -> viewModel.updateSnapshot { snapshot } },
|
onSnapshotChanged = { snapshot -> viewModel.updateSnapshot { snapshot } },
|
||||||
onMessageSoundChanged = viewModel::setMessageSound,
|
onMessageSoundChanged = viewModel::setMessageSound,
|
||||||
onCallRingtoneChanged = viewModel::setCallRingtone,
|
|
||||||
onRepeatIntervalChanged = viewModel::setRepeatInterval,
|
|
||||||
onSaveClick = viewModel::save,
|
|
||||||
onResetClick = viewModel::reset
|
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) {
|
LaunchedEffect(pendingShare?.id, currentBackStackEntry?.destination?.route) {
|
||||||
@@ -1044,11 +1167,13 @@ fun IkarApp() {
|
|||||||
if (application.container.sessionStore.read() == null) {
|
if (application.container.sessionStore.read() == null) {
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
if (currentBackStackEntry?.destination?.route == "chats") {
|
if (currentBackStackEntry?.destination?.route == "share") {
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
navigatePrimary("chats")
|
navController.navigate("share") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(pendingArchive?.id, currentBackStackEntry?.destination?.route) {
|
LaunchedEffect(pendingArchive?.id, currentBackStackEntry?.destination?.route) {
|
||||||
@@ -1082,6 +1207,48 @@ fun IkarApp() {
|
|||||||
application.container.incomingCallInviteStore.clear(invite.requestId)
|
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(
|
LaunchedEffect(
|
||||||
pendingPushNavigation?.requestId,
|
pendingPushNavigation?.requestId,
|
||||||
currentBackStackEntry?.destination?.route,
|
currentBackStackEntry?.destination?.route,
|
||||||
@@ -1125,6 +1292,9 @@ fun IkarApp() {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
AndroidActiveChatState.setActiveChatId(activeChatId)
|
AndroidActiveChatState.setActiveChatId(activeChatId)
|
||||||
|
if (activeChatId != null) {
|
||||||
|
ChatNotificationDismissalManager.cancelChatNotifications(context, activeChatId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
activeCall?.let { call ->
|
activeCall?.let { call ->
|
||||||
@@ -1137,7 +1307,16 @@ fun IkarApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAppLocked) {
|
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
|
@Composable
|
||||||
private fun AppLockedOverlay() {
|
private fun AppLockedOverlay(onUnlock: (String) -> Boolean) {
|
||||||
|
var passcode by rememberSaveable { mutableStateOf("") }
|
||||||
|
var errorMessage by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF101820)),
|
.background(Color(0xFF101820)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(
|
||||||
text = "Икар заблокирован",
|
modifier = Modifier.padding(horizontal = 28.dp),
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
color = Color.White
|
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
|
package com.seven.ikar.kotlin.app
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
import coil.disk.DiskCache
|
import coil.disk.DiskCache
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
import coil.request.CachePolicy
|
import coil.request.CachePolicy
|
||||||
@@ -47,5 +50,12 @@ class IkarNativeApp : Application(), ImageLoaderFactory {
|
|||||||
.networkCachePolicy(CachePolicy.ENABLED)
|
.networkCachePolicy(CachePolicy.ENABLED)
|
||||||
.respectCacheHeaders(false)
|
.respectCacheHeaders(false)
|
||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
|
.components {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-13
@@ -67,22 +67,25 @@ class ResolvedContactsRepository(
|
|||||||
return@withContext emptyList()
|
return@withContext emptyList()
|
||||||
}
|
}
|
||||||
val contacts = loadResolvedContacts()
|
val contacts = loadResolvedContacts()
|
||||||
if (query.isBlank()) {
|
|
||||||
return@withContext contacts
|
|
||||||
}
|
|
||||||
|
|
||||||
val merged = contacts.toMutableList()
|
val merged = contacts.toMutableList()
|
||||||
val existingIds = merged.map { it.id }.toMutableSet()
|
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)) {
|
if (shouldShowCreatorBotShortcut(query)) {
|
||||||
merged += DiscoverUserItem(
|
loadCreatorBotShortcut()?.let { user ->
|
||||||
user = user,
|
if (existingIds.add(user.id)) {
|
||||||
displayName = user.displayName,
|
merged += user.toDiscoverItem()
|
||||||
normalizedPhoneNumber = PhoneNumberNormalizer.normalize(user.phoneNumber) ?: ""
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,4 +95,35 @@ class ResolvedContactsRepository(
|
|||||||
.thenBy { it.displayName.lowercase() }
|
.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>
|
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
|
@Upsert
|
||||||
suspend fun upsert(chats: List<CachedChatEntity>)
|
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)
|
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)
|
@Serializable(with = ModerationLogTargetTypeSerializer::class)
|
||||||
enum class ModerationLogTargetType(val wireValue: Int) {
|
enum class ModerationLogTargetType(val wireValue: Int) {
|
||||||
Chat(0),
|
Chat(0),
|
||||||
@@ -202,6 +218,14 @@ data class MessageReactionDto(
|
|||||||
val isMine: Boolean
|
val isMine: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StoryReplyDto(
|
||||||
|
val storyId: String,
|
||||||
|
val authorUserId: String,
|
||||||
|
val previewText: String,
|
||||||
|
val attachmentKind: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MessageDto(
|
data class MessageDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -225,7 +249,10 @@ data class MessageDto(
|
|||||||
val ttlSeconds: Int? = null,
|
val ttlSeconds: Int? = null,
|
||||||
val postedAsChannel: Boolean = false,
|
val postedAsChannel: Boolean = false,
|
||||||
val displayAuthorName: String? = null,
|
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
|
@Serializable
|
||||||
@@ -272,7 +299,8 @@ data class ChatSummaryDto(
|
|||||||
val publicUsername: String? = null,
|
val publicUsername: String? = null,
|
||||||
val linkedDiscussionChatId: String? = null,
|
val linkedDiscussionChatId: String? = null,
|
||||||
val channelSignaturesEnabled: Boolean = false,
|
val channelSignaturesEnabled: Boolean = false,
|
||||||
val description: String? = null
|
val description: String? = null,
|
||||||
|
val canDeleteForEveryone: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -299,7 +327,8 @@ data class ChatDetailsDto(
|
|||||||
val publicUsername: String? = null,
|
val publicUsername: String? = null,
|
||||||
val linkedDiscussionChatId: String? = null,
|
val linkedDiscussionChatId: String? = null,
|
||||||
val channelSignaturesEnabled: Boolean = false,
|
val channelSignaturesEnabled: Boolean = false,
|
||||||
val description: String? = null
|
val description: String? = null,
|
||||||
|
val canDeleteForEveryone: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -320,6 +349,7 @@ data class ChatInfoDto(
|
|||||||
val channelSignaturesEnabled: Boolean = false,
|
val channelSignaturesEnabled: Boolean = false,
|
||||||
val canManageChannel: Boolean = false,
|
val canManageChannel: Boolean = false,
|
||||||
val canPostAsChannel: Boolean = false,
|
val canPostAsChannel: Boolean = false,
|
||||||
|
val canInviteUsers: Boolean = false,
|
||||||
val description: String? = null
|
val description: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -355,7 +385,13 @@ data class ChatStateRequest(
|
|||||||
val hasProtectedContent: Boolean? = null,
|
val hasProtectedContent: Boolean? = null,
|
||||||
val defaultMessageTtlSeconds: Int? = null,
|
val defaultMessageTtlSeconds: Int? = null,
|
||||||
val channelSignaturesEnabled: Boolean? = 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
|
@Serializable
|
||||||
@@ -368,13 +404,28 @@ data class ChatInviteLinkDto(
|
|||||||
val createsJoinRequest: Boolean = false,
|
val createsJoinRequest: Boolean = false,
|
||||||
val isRevoked: Boolean = false,
|
val isRevoked: Boolean = false,
|
||||||
val createdAt: String,
|
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
|
@Serializable
|
||||||
data class CreateChatInviteLinkRequest(
|
data class CreateChatInviteLinkRequest(
|
||||||
val title: String? = null,
|
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
|
@Serializable
|
||||||
@@ -507,6 +558,19 @@ data class PhoneCodeChallengeDto(
|
|||||||
val isRegistered: Boolean
|
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
|
@Serializable
|
||||||
data class VerifyPhoneCodeRequest(
|
data class VerifyPhoneCodeRequest(
|
||||||
val challengeId: String,
|
val challengeId: String,
|
||||||
@@ -515,6 +579,15 @@ data class VerifyPhoneCodeRequest(
|
|||||||
val displayName: String? = null
|
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
|
@Serializable
|
||||||
data class RefreshSessionRequest(
|
data class RefreshSessionRequest(
|
||||||
val refreshToken: String
|
val refreshToken: String
|
||||||
@@ -534,7 +607,8 @@ data class SendMessageRequest(
|
|||||||
val replyToMessageId: String? = null,
|
val replyToMessageId: String? = null,
|
||||||
val topicId: String? = null,
|
val topicId: String? = null,
|
||||||
val hasProtectedContent: Boolean? = null,
|
val hasProtectedContent: Boolean? = null,
|
||||||
val ttlSeconds: Int? = null
|
val ttlSeconds: Int? = null,
|
||||||
|
val storyReplyStoryId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -566,6 +640,16 @@ data class RegisterPushDeviceRequest(
|
|||||||
val notificationsEnabled: Boolean
|
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
|
@Serializable
|
||||||
data class AndroidAppReleaseDto(
|
data class AndroidAppReleaseDto(
|
||||||
val versionCode: Int,
|
val versionCode: Int,
|
||||||
@@ -586,10 +670,12 @@ data class ArgusManifestDto(
|
|||||||
data class ArgusReleaseDto(
|
data class ArgusReleaseDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
val version: String,
|
val version: String,
|
||||||
|
val androidVersionCode: Int? = null,
|
||||||
val downloadPath: String,
|
val downloadPath: String,
|
||||||
val originalFileName: String? = null,
|
val originalFileName: String? = null,
|
||||||
val packageSizeBytes: Long? = null,
|
val packageSizeBytes: Long? = null,
|
||||||
val publishedAt: String,
|
val publishedAt: String,
|
||||||
|
val notes: String? = null,
|
||||||
val releaseNotes: String? = null
|
val releaseNotes: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -648,6 +734,9 @@ data class PrivacySettingsDto(
|
|||||||
val blockedUserIds: List<String> = emptyList(),
|
val blockedUserIds: List<String> = emptyList(),
|
||||||
val alwaysAllowUserIds: List<String> = emptyList(),
|
val alwaysAllowUserIds: List<String> = emptyList(),
|
||||||
val neverAllowUserIds: 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 passcodeLockEnabled: Boolean = false,
|
||||||
val showMessagePreview: Boolean = true,
|
val showMessagePreview: Boolean = true,
|
||||||
val updatedAt: String
|
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.ChatTopicDto
|
||||||
import com.seven.ikar.kotlin.core.model.CreateChatInviteLinkRequest
|
import com.seven.ikar.kotlin.core.model.CreateChatInviteLinkRequest
|
||||||
import com.seven.ikar.kotlin.core.model.ContactMatchDto
|
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.CreateChannelRequest
|
||||||
import com.seven.ikar.kotlin.core.model.CreateChatTopicRequest
|
import com.seven.ikar.kotlin.core.model.CreateChatTopicRequest
|
||||||
import com.seven.ikar.kotlin.core.model.CreateStoryRequest
|
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.CreateDirectChatRequest
|
||||||
import com.seven.ikar.kotlin.core.model.CreateGroupChatRequest
|
import com.seven.ikar.kotlin.core.model.CreateGroupChatRequest
|
||||||
import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile
|
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.ForwardMessageRequest
|
||||||
|
import com.seven.ikar.kotlin.core.model.JoinChatResultDto
|
||||||
import com.seven.ikar.kotlin.core.model.MessageDto
|
import com.seven.ikar.kotlin.core.model.MessageDto
|
||||||
import com.seven.ikar.kotlin.core.model.MessageSearchPageDto
|
import com.seven.ikar.kotlin.core.model.MessageSearchPageDto
|
||||||
import com.seven.ikar.kotlin.core.model.ModerationLogDto
|
import com.seven.ikar.kotlin.core.model.ModerationLogDto
|
||||||
import com.seven.ikar.kotlin.core.model.PhoneCodeChallengeDto
|
import com.seven.ikar.kotlin.core.model.PhoneCodeChallengeDto
|
||||||
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
|
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.RegenerateBotTokenResponseDto
|
||||||
|
import com.seven.ikar.kotlin.core.model.RequestEmailCodeRequest
|
||||||
import com.seven.ikar.kotlin.core.model.RequestPhoneCodeRequest
|
import com.seven.ikar.kotlin.core.model.RequestPhoneCodeRequest
|
||||||
import com.seven.ikar.kotlin.core.model.RegisterPushDeviceRequest
|
import com.seven.ikar.kotlin.core.model.RegisterPushDeviceRequest
|
||||||
import com.seven.ikar.kotlin.core.model.RefreshSessionRequest
|
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.UpdatePrivacySettingsRequest
|
||||||
import com.seven.ikar.kotlin.core.model.UserSessionDto
|
import com.seven.ikar.kotlin.core.model.UserSessionDto
|
||||||
import com.seven.ikar.kotlin.core.model.UserSummaryDto
|
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 com.seven.ikar.kotlin.core.model.VerifyPhoneCodeRequest
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -138,6 +141,21 @@ class ApiClient(
|
|||||||
PhoneCodeChallengeDto.serializer()
|
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(
|
suspend fun verifyPhoneCode(
|
||||||
challengeId: String,
|
challengeId: String,
|
||||||
phoneNumber: String,
|
phoneNumber: String,
|
||||||
@@ -163,6 +181,33 @@ class ApiClient(
|
|||||||
AuthSessionDto.serializer()
|
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 =
|
suspend fun refreshSession(refreshToken: String): AuthSessionDto =
|
||||||
executeJsonRequest(
|
executeJsonRequest(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
@@ -200,6 +245,32 @@ class ApiClient(
|
|||||||
kotlinx.serialization.builtins.ListSerializer(ChatFolderDto.serializer())
|
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> =
|
suspend fun searchChats(accessToken: String, query: String, filter: String? = null, limit: Int = 50): List<ChatSearchResultDto> =
|
||||||
searchChatsPage(
|
searchChatsPage(
|
||||||
accessToken = accessToken,
|
accessToken = accessToken,
|
||||||
@@ -280,7 +351,10 @@ class ApiClient(
|
|||||||
accessToken: String,
|
accessToken: String,
|
||||||
chatId: String,
|
chatId: String,
|
||||||
title: String? = null,
|
title: String? = null,
|
||||||
createsJoinRequest: Boolean = false
|
createsJoinRequest: Boolean = false,
|
||||||
|
expiresAt: String? = null,
|
||||||
|
maxUses: Int? = null,
|
||||||
|
memberLimit: Int? = null
|
||||||
): ChatInviteLinkDto =
|
): ChatInviteLinkDto =
|
||||||
executeJsonRequest(
|
executeJsonRequest(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
@@ -288,7 +362,13 @@ class ApiClient(
|
|||||||
.post(
|
.post(
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
CreateChatInviteLinkRequest.serializer(),
|
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)
|
).toRequestBody(JSON)
|
||||||
)
|
)
|
||||||
.withBearer(accessToken)
|
.withBearer(accessToken)
|
||||||
@@ -307,7 +387,7 @@ class ApiClient(
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun joinChatByInviteLink(accessToken: String, token: String): ChatDetailsDto =
|
suspend fun joinChatByInviteLink(accessToken: String, token: String): JoinChatResultDto =
|
||||||
executeJsonRequest(
|
executeJsonRequest(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
.url(ServerConfig.absoluteMessengerUrl("/api/chats/join/${encodeQuery(token)}"))
|
.url(ServerConfig.absoluteMessengerUrl("/api/chats/join/${encodeQuery(token)}"))
|
||||||
@@ -315,7 +395,7 @@ class ApiClient(
|
|||||||
.withBearer(accessToken)
|
.withBearer(accessToken)
|
||||||
.withClientVersionHeaders()
|
.withClientVersionHeaders()
|
||||||
.build(),
|
.build(),
|
||||||
ChatDetailsDto.serializer()
|
JoinChatResultDto.serializer()
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun getChatJoinRequests(accessToken: String, chatId: String): List<ChatJoinRequestDto> =
|
suspend fun getChatJoinRequests(accessToken: String, chatId: String): List<ChatJoinRequestDto> =
|
||||||
@@ -393,6 +473,22 @@ class ApiClient(
|
|||||||
ChatMessagesPageDto.serializer()
|
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(
|
suspend fun searchChatMessages(
|
||||||
accessToken: String,
|
accessToken: String,
|
||||||
chatId: String,
|
chatId: String,
|
||||||
@@ -501,6 +597,28 @@ class ApiClient(
|
|||||||
ChatDetailsDto.serializer()
|
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> =
|
suspend fun getChatModerationLog(accessToken: String, chatId: String, limit: Int = 100): List<ModerationLogDto> =
|
||||||
executeJsonRequest(
|
executeJsonRequest(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
@@ -529,7 +647,8 @@ class ApiClient(
|
|||||||
replyToMessageId: String? = null,
|
replyToMessageId: String? = null,
|
||||||
topicId: String? = null,
|
topicId: String? = null,
|
||||||
hasProtectedContent: Boolean? = null,
|
hasProtectedContent: Boolean? = null,
|
||||||
ttlSeconds: Int? = null
|
ttlSeconds: Int? = null,
|
||||||
|
storyReplyStoryId: String? = null
|
||||||
): MessageDto =
|
): MessageDto =
|
||||||
executeJsonRequest(
|
executeJsonRequest(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
@@ -542,7 +661,8 @@ class ApiClient(
|
|||||||
replyToMessageId = replyToMessageId,
|
replyToMessageId = replyToMessageId,
|
||||||
topicId = topicId,
|
topicId = topicId,
|
||||||
hasProtectedContent = hasProtectedContent,
|
hasProtectedContent = hasProtectedContent,
|
||||||
ttlSeconds = ttlSeconds
|
ttlSeconds = ttlSeconds,
|
||||||
|
storyReplyStoryId = storyReplyStoryId
|
||||||
)
|
)
|
||||||
).toRequestBody(JSON)
|
).toRequestBody(JSON)
|
||||||
)
|
)
|
||||||
@@ -627,13 +747,17 @@ class ApiClient(
|
|||||||
chatId: String,
|
chatId: String,
|
||||||
text: String?,
|
text: String?,
|
||||||
replyToMessageId: String?,
|
replyToMessageId: String?,
|
||||||
file: UploadFileSource
|
file: UploadFileSource,
|
||||||
|
isVoiceNote: Boolean = false
|
||||||
): MessageDto = withContext(Dispatchers.IO) {
|
): MessageDto = withContext(Dispatchers.IO) {
|
||||||
val multipart = MultipartBody.Builder()
|
val multipart = MultipartBody.Builder()
|
||||||
.setType(MultipartBody.FORM)
|
.setType(MultipartBody.FORM)
|
||||||
.apply {
|
.apply {
|
||||||
text?.takeIf { it.isNotBlank() }?.let { addFormDataPart("text", it) }
|
text?.takeIf { it.isNotBlank() }?.let { addFormDataPart("text", it) }
|
||||||
replyToMessageId?.takeIf { it.isNotBlank() }?.let { addFormDataPart("replyToMessageId", it) }
|
replyToMessageId?.takeIf { it.isNotBlank() }?.let { addFormDataPart("replyToMessageId", it) }
|
||||||
|
if (isVoiceNote) {
|
||||||
|
addFormDataPart("isVoiceNote", "true")
|
||||||
|
}
|
||||||
addFormDataPart("file", file.fileName, file.asRequestBody())
|
addFormDataPart("file", file.fileName, file.asRequestBody())
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
@@ -710,6 +834,16 @@ class ApiClient(
|
|||||||
.build()
|
.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 =
|
suspend fun setMessageReaction(accessToken: String, chatId: String, messageId: String, emoji: String): MessageDto =
|
||||||
executeJsonRequest(
|
executeJsonRequest(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
@@ -1154,22 +1288,6 @@ class ApiClient(
|
|||||||
BotSummaryDto.serializer()
|
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 =
|
suspend fun updateBot(accessToken: String, botId: String, displayName: String, about: String?, isEnabled: Boolean): BotSummaryDto =
|
||||||
executeJsonRequest(
|
executeJsonRequest(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
@@ -1233,6 +1351,17 @@ class ApiClient(
|
|||||||
.build()
|
.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) =
|
suspend fun unregisterPushDevice(accessToken: String, installationId: String) =
|
||||||
executeUnitRequest(
|
executeUnitRequest(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
@@ -1256,19 +1385,39 @@ class ApiClient(
|
|||||||
val version = manifest.release.version
|
val version = manifest.release.version
|
||||||
val plusIndex = version.lastIndexOf('+')
|
val plusIndex = version.lastIndexOf('+')
|
||||||
val versionName = if (plusIndex > 0) version.substring(0, plusIndex) else version
|
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(
|
return AndroidAppReleaseDto(
|
||||||
versionCode = versionCode,
|
versionCode = versionCode,
|
||||||
versionName = versionName,
|
versionName = versionName,
|
||||||
downloadPath = ServerConfig.absoluteArgusUrl(manifest.release.downloadPath),
|
downloadPath = ServerConfig.absoluteArgusUrl(manifest.release.downloadPath),
|
||||||
publishedAt = manifest.release.publishedAt,
|
publishedAt = manifest.release.publishedAt,
|
||||||
notes = manifest.release.releaseNotes,
|
notes = manifest.release.notes ?: manifest.release.releaseNotes,
|
||||||
isRequired = true,
|
isRequired = true,
|
||||||
minimumSupportedVersionCode = versionCode
|
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 =
|
private suspend fun <T> executeJsonRequest(request: Request, serializer: kotlinx.serialization.KSerializer<T>): T =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -1380,6 +1529,7 @@ class ApiClient(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val JSON = "application/json; charset=utf-8".toMediaType()
|
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.content.Context
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class AndroidPushSettings(context: Context) {
|
class AndroidPushSettings(context: Context) {
|
||||||
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val _snapshot = MutableStateFlow(loadSnapshot())
|
||||||
|
val snapshot: StateFlow<NotificationSettingsSnapshot> = _snapshot.asStateFlow()
|
||||||
|
|
||||||
fun getInstallationId(): String {
|
fun getInstallationId(): String {
|
||||||
val existing = preferences.getString(KEY_INSTALLATION_ID, null)
|
val existing = preferences.getString(KEY_INSTALLATION_ID, null)
|
||||||
@@ -53,7 +58,7 @@ class AndroidPushSettings(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getChannelsEnabled(): Boolean =
|
fun getChannelsEnabled(): Boolean =
|
||||||
preferences.getBoolean(KEY_CHANNELS_ENABLED, false)
|
preferences.getBoolean(KEY_CHANNELS_ENABLED, true)
|
||||||
|
|
||||||
fun setChannelsEnabled(enabled: Boolean) {
|
fun setChannelsEnabled(enabled: Boolean) {
|
||||||
preferences.edit().putBoolean(KEY_CHANNELS_ENABLED, enabled).apply()
|
preferences.edit().putBoolean(KEY_CHANNELS_ENABLED, enabled).apply()
|
||||||
@@ -137,7 +142,7 @@ class AndroidPushSettings(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getPopupWindowsEnabled(): Boolean =
|
fun getPopupWindowsEnabled(): Boolean =
|
||||||
preferences.getBoolean(KEY_POPUP_WINDOWS_ENABLED, false)
|
preferences.getBoolean(KEY_POPUP_WINDOWS_ENABLED, true)
|
||||||
|
|
||||||
fun setPopupWindowsEnabled(enabled: Boolean) {
|
fun setPopupWindowsEnabled(enabled: Boolean) {
|
||||||
preferences.edit().putBoolean(KEY_POPUP_WINDOWS_ENABLED, enabled).apply()
|
preferences.edit().putBoolean(KEY_POPUP_WINDOWS_ENABLED, enabled).apply()
|
||||||
@@ -230,6 +235,7 @@ class AndroidPushSettings(context: Context) {
|
|||||||
setRestartOnCloseEnabled(snapshot.restartOnCloseEnabled)
|
setRestartOnCloseEnabled(snapshot.restartOnCloseEnabled)
|
||||||
setBackgroundConnectionEnabled(snapshot.backgroundConnectionEnabled)
|
setBackgroundConnectionEnabled(snapshot.backgroundConnectionEnabled)
|
||||||
setRepeatInterval(snapshot.repeatInterval)
|
setRepeatInterval(snapshot.repeatInterval)
|
||||||
|
_snapshot.value = loadSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadChatNotificationSnapshot(chatId: String): ChatNotificationSettingsSnapshot =
|
fun loadChatNotificationSnapshot(chatId: String): ChatNotificationSettingsSnapshot =
|
||||||
@@ -278,7 +284,7 @@ class AndroidPushSettings(context: Context) {
|
|||||||
messageSound = NotificationSoundOption.Default,
|
messageSound = NotificationSoundOption.Default,
|
||||||
privateChatsEnabled = true,
|
privateChatsEnabled = true,
|
||||||
groupsEnabled = true,
|
groupsEnabled = true,
|
||||||
channelsEnabled = false,
|
channelsEnabled = true,
|
||||||
storiesEnabled = false,
|
storiesEnabled = false,
|
||||||
reactionsEnabled = true,
|
reactionsEnabled = true,
|
||||||
callVibrationEnabled = true,
|
callVibrationEnabled = true,
|
||||||
@@ -290,7 +296,7 @@ class AndroidPushSettings(context: Context) {
|
|||||||
inAppVibrationEnabled = true,
|
inAppVibrationEnabled = true,
|
||||||
inAppShowTextEnabled = true,
|
inAppShowTextEnabled = true,
|
||||||
soundInActiveChatEnabled = true,
|
soundInActiveChatEnabled = true,
|
||||||
popupWindowsEnabled = false,
|
popupWindowsEnabled = true,
|
||||||
contactJoinedEnabled = false,
|
contactJoinedEnabled = false,
|
||||||
pinnedMessagesEnabled = true,
|
pinnedMessagesEnabled = true,
|
||||||
restartOnCloseEnabled = false,
|
restartOnCloseEnabled = false,
|
||||||
@@ -326,6 +332,7 @@ class AndroidPushSettings(context: Context) {
|
|||||||
chatKeys.forEach(::remove)
|
chatKeys.forEach(::remove)
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
|
_snapshot.value = loadSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T : Enum<T>> getEnum(key: String, defaultValue: T): T {
|
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.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import com.seven.ikar.kotlin.app.IkarNativeApp
|
import com.seven.ikar.kotlin.app.IkarNativeApp
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -39,19 +38,23 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val app = context.applicationContext as? IkarNativeApp ?: return
|
val app = context.applicationContext as? IkarNativeApp ?: return
|
||||||
runCatching {
|
val ownerUserId = runCatching {
|
||||||
app.container.executeAuthorized { session ->
|
app.container.executeAuthorized { session ->
|
||||||
app.container.apiClient.acknowledgeRead(
|
app.container.apiClient.acknowledgeRead(
|
||||||
accessToken = session.accessToken,
|
accessToken = session.accessToken,
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
messageId = messageId
|
messageId = messageId
|
||||||
)
|
)
|
||||||
|
session.user.id
|
||||||
}
|
}
|
||||||
}.onSuccess {
|
}.getOrNull() ?: return
|
||||||
if (notificationId != Int.MIN_VALUE) {
|
|
||||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
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) {
|
private fun handleAcceptIncomingCall(context: Context, intent: Intent) {
|
||||||
|
|||||||
+125
-9
@@ -6,9 +6,13 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
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.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.seven.ikar.kotlin.BuildConfig
|
||||||
import com.seven.ikar.kotlin.MainActivity
|
import com.seven.ikar.kotlin.MainActivity
|
||||||
import com.seven.ikar.kotlin.app.IkarNativeApp
|
import com.seven.ikar.kotlin.app.IkarNativeApp
|
||||||
import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer
|
import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer
|
||||||
@@ -26,6 +30,22 @@ object PushNotificationDisplayService {
|
|||||||
AndroidPushBootstrapper.tryInitialize(context)
|
AndroidPushBootstrapper.tryInitialize(context)
|
||||||
|
|
||||||
val kind = data[PushDataKeys.Kind].orEmpty()
|
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
|
val app = context.applicationContext as? IkarNativeApp
|
||||||
when (kind) {
|
when (kind) {
|
||||||
PushDataKinds.AudioCallInvite,
|
PushDataKinds.AudioCallInvite,
|
||||||
@@ -65,6 +85,7 @@ object PushNotificationDisplayService {
|
|||||||
val isMessageReaction = kind == PushDataKinds.MessageReaction
|
val isMessageReaction = kind == PushDataKinds.MessageReaction
|
||||||
val isChatNotification = isChatMessage || isMessageReaction
|
val isChatNotification = isChatMessage || isMessageReaction
|
||||||
val chatId = data[PushDataKeys.ChatId].orEmpty()
|
val chatId = data[PushDataKeys.ChatId].orEmpty()
|
||||||
|
val chatType = data[PushDataKeys.ChatType].orEmpty()
|
||||||
val chatNotificationSettings = if (isChatNotification && chatId.isNotBlank()) {
|
val chatNotificationSettings = if (isChatNotification && chatId.isNotBlank()) {
|
||||||
settings.loadChatNotificationSnapshot(chatId)
|
settings.loadChatNotificationSnapshot(chatId)
|
||||||
} else {
|
} else {
|
||||||
@@ -73,6 +94,9 @@ object PushNotificationDisplayService {
|
|||||||
if (isChatNotification && chatNotificationSettings?.notificationsEnabled == false) {
|
if (isChatNotification && chatNotificationSettings?.notificationsEnabled == false) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (isChatNotification && !isChatCategoryEnabled(settings, chatType, isMessageReaction)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
val hideMessagePreview = isChatNotification &&
|
val hideMessagePreview = isChatNotification &&
|
||||||
(app?.container?.privacyRuntimeSettings?.loadSnapshot()?.showMessagePreview == false ||
|
(app?.container?.privacyRuntimeSettings?.loadSnapshot()?.showMessagePreview == false ||
|
||||||
chatNotificationSettings?.showMessagePreview == false)
|
chatNotificationSettings?.showMessagePreview == false)
|
||||||
@@ -101,13 +125,13 @@ object PushNotificationDisplayService {
|
|||||||
).joinToString(separator = "|").takeIf { it.isNotBlank() }
|
).joinToString(separator = "|").takeIf { it.isNotBlank() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (settings.getInAppVibrationEnabled()) {
|
||||||
|
vibrateForInAppNotification(context)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val popupWindowsEnabled = chatNotificationSettings?.popupWindowsEnabled ?: settings.getPopupWindowsEnabled()
|
val popupWindowsEnabled = chatNotificationSettings?.popupWindowsEnabled ?: settings.getPopupWindowsEnabled()
|
||||||
if (isChatNotification && isAppVisible && !popupWindowsEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val senderContactDisplayName = if (isChatNotification) {
|
val senderContactDisplayName = if (isChatNotification) {
|
||||||
resolveContactDisplayName(context, data[PushDataKeys.SenderPhoneNumber])
|
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)) {
|
val title = if (isChatNotification && chatType.equals(ChatTypeDirect, ignoreCase = true)) {
|
||||||
senderContactDisplayName ?: fallbackTitle
|
senderContactDisplayName ?: fallbackTitle
|
||||||
} else {
|
} else {
|
||||||
@@ -179,10 +202,14 @@ object PushNotificationDisplayService {
|
|||||||
data.forEach { (key, value) -> putExtra(key, value) }
|
data.forEach { (key, value) -> putExtra(key, value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val notificationId = ((if (isMessageReaction) "reaction:${data[PushDataKeys.MessageId].orEmpty()}" else data[PushDataKeys.MessageId])
|
val notificationId = if (isAppUpdateNotification) {
|
||||||
?: data[PushDataKeys.CallId]
|
AppUpdateNotificationId
|
||||||
?: data[PushDataKeys.VersionCode]
|
} else {
|
||||||
?: System.currentTimeMillis().toString()).hashCode()
|
((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) {
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
@@ -212,12 +239,17 @@ object PushNotificationDisplayService {
|
|||||||
PushDataKinds.AudioCallInvite,
|
PushDataKinds.AudioCallInvite,
|
||||||
PushDataKinds.VideoCallInvite,
|
PushDataKinds.VideoCallInvite,
|
||||||
PushDataKinds.AppUpdateAvailable -> NotificationCompat.PRIORITY_HIGH
|
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) {
|
val category = when (kind) {
|
||||||
PushDataKinds.AudioCallInvite,
|
PushDataKinds.AudioCallInvite,
|
||||||
PushDataKinds.VideoCallInvite -> NotificationCompat.CATEGORY_CALL
|
PushDataKinds.VideoCallInvite -> NotificationCompat.CATEGORY_CALL
|
||||||
|
PushDataKinds.AppUpdateAvailable -> NotificationCompat.CATEGORY_STATUS
|
||||||
else -> NotificationCompat.CATEGORY_MESSAGE
|
else -> NotificationCompat.CATEGORY_MESSAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,19 +297,103 @@ object PushNotificationDisplayService {
|
|||||||
.addAction(android.R.drawable.ic_menu_view, "Отметить как прочитанное", markReadPendingIntent)
|
.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())
|
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 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 GenericNewReactionText = "\u041d\u043e\u0432\u0430\u044f \u0440\u0435\u0430\u043a\u0446\u0438\u044f"
|
||||||
private const val ChatTypeDirect = "Direct"
|
private const val ChatTypeDirect = "Direct"
|
||||||
private const val ChatTypeGroup = "Group"
|
private const val ChatTypeGroup = "Group"
|
||||||
|
private const val ChatTypeChannel = "Channel"
|
||||||
private const val ContactNameCacheTtlMillis = 5 * 60 * 1000L
|
private const val ContactNameCacheTtlMillis = 5 * 60 * 1000L
|
||||||
private val contactNameCacheLock = Any()
|
private val contactNameCacheLock = Any()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var contactNameCache: ContactNameCache? = null
|
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 {
|
private fun PendingIncomingCallInvite.withResolvedContactDisplayName(context: Context): PendingIncomingCallInvite {
|
||||||
val contactName = resolveContactDisplayName(context, callerPhoneNumber) ?: return this
|
val contactName = resolveContactDisplayName(context, callerPhoneNumber) ?: return this
|
||||||
return copy(callerDisplayName = contactName)
|
return copy(callerDisplayName = contactName)
|
||||||
|
|||||||
+6
-2
@@ -64,7 +64,9 @@ data class RealtimeMessageDto(
|
|||||||
val ttlSeconds: Int? = null,
|
val ttlSeconds: Int? = null,
|
||||||
val postedAsChannel: Boolean = false,
|
val postedAsChannel: Boolean = false,
|
||||||
val displayAuthorName: String? = null,
|
val displayAuthorName: String? = null,
|
||||||
val viewCount: Int? = null
|
val viewCount: Int? = null,
|
||||||
|
val discussionMessageId: String? = null,
|
||||||
|
val commentCount: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RealtimeMessageReplyDto(
|
data class RealtimeMessageReplyDto(
|
||||||
@@ -207,7 +209,9 @@ fun RealtimeMessageDto.toCore(): MessageDto = MessageDto(
|
|||||||
ttlSeconds = ttlSeconds,
|
ttlSeconds = ttlSeconds,
|
||||||
postedAsChannel = postedAsChannel,
|
postedAsChannel = postedAsChannel,
|
||||||
displayAuthorName = displayAuthorName,
|
displayAuthorName = displayAuthorName,
|
||||||
viewCount = viewCount
|
viewCount = viewCount,
|
||||||
|
discussionMessageId = discussionMessageId,
|
||||||
|
commentCount = commentCount
|
||||||
)
|
)
|
||||||
|
|
||||||
fun RealtimeMessageReplyDto.toCore(): MessageReplyDto = MessageReplyDto(
|
fun RealtimeMessageReplyDto.toCore(): MessageReplyDto = MessageReplyDto(
|
||||||
|
|||||||
+39
-1
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
@@ -40,7 +41,8 @@ enum class RealtimeConnectionStage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RealtimeService(
|
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 scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
@@ -66,6 +68,9 @@ class RealtimeService(
|
|||||||
private val _chatsChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
|
private val _chatsChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
|
||||||
val chatsChanged: SharedFlow<Unit> = _chatsChanged
|
val chatsChanged: SharedFlow<Unit> = _chatsChanged
|
||||||
|
|
||||||
|
private val _storiesChanged = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
|
||||||
|
val storiesChanged: SharedFlow<Unit> = _storiesChanged
|
||||||
|
|
||||||
private val _appUpdateAvailable = MutableSharedFlow<AndroidAppReleaseDto>(extraBufferCapacity = 8)
|
private val _appUpdateAvailable = MutableSharedFlow<AndroidAppReleaseDto>(extraBufferCapacity = 8)
|
||||||
val appUpdateAvailable: SharedFlow<AndroidAppReleaseDto> = _appUpdateAvailable
|
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 {
|
fun startCall(chatId: String, mediaType: CallMediaType): OutgoingCallDto {
|
||||||
val activeConnection = requireConnectedConnection()
|
val activeConnection = requireConnectedConnection()
|
||||||
return if (mediaType == CallMediaType.Audio) {
|
return if (mediaType == CallMediaType.Audio) {
|
||||||
@@ -190,6 +223,7 @@ class RealtimeService(
|
|||||||
private fun buildConnection(accessToken: String): HubConnection {
|
private fun buildConnection(accessToken: String): HubConnection {
|
||||||
val hubUrl = buildHubUrl()
|
val hubUrl = buildHubUrl()
|
||||||
val hub = HubConnectionBuilder.create(hubUrl)
|
val hub = HubConnectionBuilder.create(hubUrl)
|
||||||
|
.setHttpClientBuilderCallback { builder -> configureHttpClientBuilder(builder) }
|
||||||
.withAccessTokenProvider(Single.defer { Single.just(accessToken) })
|
.withAccessTokenProvider(Single.defer { Single.just(accessToken) })
|
||||||
.withHeader("X-Ikar-Client-Platform", clientVersionProvider.platform)
|
.withHeader("X-Ikar-Client-Platform", clientVersionProvider.platform)
|
||||||
.withHeader("X-Ikar-Client-Version-Code", clientVersionProvider.versionCode)
|
.withHeader("X-Ikar-Client-Version-Code", clientVersionProvider.versionCode)
|
||||||
@@ -216,6 +250,10 @@ class RealtimeService(
|
|||||||
scope.launch { _chatsChanged.emit(Unit) }
|
scope.launch { _chatsChanged.emit(Unit) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
hub.on("StoriesChanged", {
|
||||||
|
scope.launch { _storiesChanged.emit(Unit) }
|
||||||
|
})
|
||||||
|
|
||||||
hub.on("AppUpdateAvailable", { payload: AndroidAppReleaseDto ->
|
hub.on("AppUpdateAvailable", { payload: AndroidAppReleaseDto ->
|
||||||
scope.launch { _appUpdateAvailable.emit(payload) }
|
scope.launch { _appUpdateAvailable.emit(payload) }
|
||||||
}, AndroidAppReleaseDto::class.java)
|
}, AndroidAppReleaseDto::class.java)
|
||||||
|
|||||||
+8
-3
@@ -65,7 +65,7 @@ class AndroidAppSettings(context: Context) {
|
|||||||
largeEmoji = preferences.getBoolean(KEY_LARGE_EMOJI, true),
|
largeEmoji = preferences.getBoolean(KEY_LARGE_EMOJI, true),
|
||||||
languageMode = getEnum(KEY_LANGUAGE_MODE, AppLanguageMode.System),
|
languageMode = getEnum(KEY_LANGUAGE_MODE, AppLanguageMode.System),
|
||||||
proxyEnabled = preferences.getBoolean(KEY_PROXY_ENABLED, false),
|
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(),
|
proxyHost = preferences.getString(KEY_PROXY_HOST, "").orEmpty(),
|
||||||
proxyPort = preferences.getInt(KEY_PROXY_PORT, 1080).coerceIn(1, 65_535),
|
proxyPort = preferences.getInt(KEY_PROXY_PORT, 1080).coerceIn(1, 65_535),
|
||||||
proxySecret = preferences.getString(KEY_PROXY_SECRET, "").orEmpty()
|
proxySecret = preferences.getString(KEY_PROXY_SECRET, "").orEmpty()
|
||||||
@@ -79,6 +79,7 @@ class AndroidAppSettings(context: Context) {
|
|||||||
val sanitized = snapshot.copy(
|
val sanitized = snapshot.copy(
|
||||||
keepMediaDays = snapshot.keepMediaDays.coerceIn(1, 365),
|
keepMediaDays = snapshot.keepMediaDays.coerceIn(1, 365),
|
||||||
maxCacheMb = snapshot.maxCacheMb.coerceIn(64, 8192),
|
maxCacheMb = snapshot.maxCacheMb.coerceIn(64, 8192),
|
||||||
|
proxyType = snapshot.proxyType.supportedProxyType(),
|
||||||
proxyPort = snapshot.proxyPort.coerceIn(1, 65_535)
|
proxyPort = snapshot.proxyPort.coerceIn(1, 65_535)
|
||||||
)
|
)
|
||||||
preferences.edit().apply {
|
preferences.edit().apply {
|
||||||
@@ -111,12 +112,16 @@ class AndroidAppSettings(context: Context) {
|
|||||||
|
|
||||||
private fun <T : Enum<T>> getEnum(key: String, defaultValue: T): T {
|
private fun <T : Enum<T>> getEnum(key: String, defaultValue: T): T {
|
||||||
val rawValue = preferences.getString(key, defaultValue.name).orEmpty()
|
val rawValue = preferences.getString(key, defaultValue.name).orEmpty()
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
return defaultValue.declaringJavaClass.enumConstants
|
return defaultValue.declaringJavaClass.enumConstants
|
||||||
?.firstOrNull { it.name == rawValue } as? T
|
?.firstOrNull { it.name == rawValue }
|
||||||
?: defaultValue
|
?: defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun AppProxyType.supportedProxyType(): AppProxyType = when (this) {
|
||||||
|
AppProxyType.MtProto -> AppProxyType.Socks5
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREFERENCES_NAME = "ikar_kotlin_app_settings"
|
private const val PREFERENCES_NAME = "ikar_kotlin_app_settings"
|
||||||
private const val KEY_AUTO_DOWNLOAD_PHOTOS = "auto_download_photos"
|
private const val KEY_AUTO_DOWNLOAD_PHOTOS = "auto_download_photos"
|
||||||
|
|||||||
+52
@@ -1,10 +1,15 @@
|
|||||||
package com.seven.ikar.kotlin.core.settings
|
package com.seven.ikar.kotlin.core.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Base64
|
||||||
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
|
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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(
|
data class PrivacyRuntimeSnapshot(
|
||||||
val passcodeLockEnabled: Boolean = false,
|
val passcodeLockEnabled: Boolean = false,
|
||||||
@@ -23,6 +28,9 @@ class AndroidPrivacyRuntimeSettings(context: Context) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun saveFrom(settings: PrivacySettingsDto) {
|
fun saveFrom(settings: PrivacySettingsDto) {
|
||||||
|
if (!settings.passcodeLockEnabled) {
|
||||||
|
clearPasscode()
|
||||||
|
}
|
||||||
saveSnapshot(
|
saveSnapshot(
|
||||||
PrivacyRuntimeSnapshot(
|
PrivacyRuntimeSnapshot(
|
||||||
passcodeLockEnabled = settings.passcodeLockEnabled,
|
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) {
|
private fun saveSnapshot(snapshot: PrivacyRuntimeSnapshot) {
|
||||||
preferences.edit()
|
preferences.edit()
|
||||||
.putBoolean(KEY_PASSCODE_LOCK_ENABLED, snapshot.passcodeLockEnabled)
|
.putBoolean(KEY_PASSCODE_LOCK_ENABLED, snapshot.passcodeLockEnabled)
|
||||||
@@ -39,10 +79,22 @@ class AndroidPrivacyRuntimeSettings(context: Context) {
|
|||||||
_snapshot.value = snapshot
|
_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 {
|
companion object {
|
||||||
private const val PREFERENCES_NAME = "ikar_kotlin_privacy_runtime"
|
private const val PREFERENCES_NAME = "ikar_kotlin_privacy_runtime"
|
||||||
private const val KEY_PASSCODE_LOCK_ENABLED = "passcode_lock_enabled"
|
private const val KEY_PASSCODE_LOCK_ENABLED = "passcode_lock_enabled"
|
||||||
private const val KEY_SHOW_MESSAGE_PREVIEW = "show_message_preview"
|
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 org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.Proxy
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
class ArgusAppUpdateClient {
|
class ArgusAppUpdateClient(
|
||||||
|
private val proxyProvider: () -> Proxy? = { null }
|
||||||
|
) {
|
||||||
fun fetchManifest(config: ArgusAppUpdateConfig): ArgusAppManifest? {
|
fun fetchManifest(config: ArgusAppUpdateConfig): ArgusAppManifest? {
|
||||||
val url = buildManifestUrl(config)
|
val url = buildManifestUrl(config)
|
||||||
val connection = openConnection(url)
|
val connection = openConnection(url)
|
||||||
@@ -62,12 +65,15 @@ class ArgusAppUpdateClient {
|
|||||||
return "$baseUrl/api/apps/$slug/manifest?platform=${config.platform}&channel=${config.channel}"
|
return "$baseUrl/api/apps/$slug/manifest?platform=${config.platform}&channel=${config.channel}"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openConnection(url: String): HttpURLConnection =
|
private fun openConnection(url: String): HttpURLConnection {
|
||||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
val targetUrl = URL(url)
|
||||||
|
val connection = proxyProvider()?.let(targetUrl::openConnection) ?: targetUrl.openConnection()
|
||||||
|
return (connection as HttpURLConnection).apply {
|
||||||
connectTimeout = 15_000
|
connectTimeout = 15_000
|
||||||
readTimeout = 60_000
|
readTimeout = 60_000
|
||||||
setRequestProperty("Accept", "application/json")
|
setRequestProperty("Accept", "application/json")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseManifest(body: String): ArgusAppManifest {
|
private fun parseManifest(body: String): ArgusAppManifest {
|
||||||
val root = JSONObject(body)
|
val root = JSONObject(body)
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ class ArgusAppUpdateInstaller(private val context: Context) {
|
|||||||
update: AvailableIkarUpdate
|
update: AvailableIkarUpdate
|
||||||
): AppUpdateSubmissionResult {
|
): AppUpdateSubmissionResult {
|
||||||
val expectedPackageName = update.installedApp.packageName
|
val expectedPackageName = update.installedApp.packageName
|
||||||
val expectedVersionCode = update.manifest.release.androidVersionCode
|
val expectedVersionCode = update.manifest.release.resolvedVersionCode
|
||||||
val archiveInfo = readArchiveInfo(packageFile)
|
val archiveInfo = readArchiveInfo(packageFile)
|
||||||
?: return reject(
|
?: return reject(
|
||||||
expectedPackageName,
|
expectedPackageName,
|
||||||
|
|||||||
+27
-17
@@ -45,18 +45,16 @@ class ArgusAppUpdateManager(
|
|||||||
"На сервере Argus нет совместимого релиза для ${config.slug}."
|
"На сервере Argus нет совместимого релиза для ${config.slug}."
|
||||||
)
|
)
|
||||||
|
|
||||||
val manifestPackageName = manifest.androidPackageName?.trim()
|
manifest.androidPackageName
|
||||||
if (manifestPackageName.isNullOrBlank()) {
|
?.trim()
|
||||||
return AppUpdateCheckResult.Incompatible(
|
?.takeIf { it.isNotBlank() }
|
||||||
"В карточке Argus не указан androidPackageName."
|
?.let { manifestPackageName ->
|
||||||
)
|
if (!manifestPackageName.equals(installedApp.packageName, ignoreCase = false)) {
|
||||||
}
|
return AppUpdateCheckResult.Incompatible(
|
||||||
|
"Argus отдаёт пакет $manifestPackageName, а текущее приложение имеет пакет ${installedApp.packageName}."
|
||||||
if (!manifestPackageName.equals(installedApp.packageName, ignoreCase = false)) {
|
)
|
||||||
return AppUpdateCheckResult.Incompatible(
|
}
|
||||||
"Argus отдаёт пакет $manifestPackageName, а текущее приложение имеет пакет ${installedApp.packageName}."
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!manifest.release.packageKind.equals("apk", ignoreCase = true)) {
|
if (!manifest.release.packageKind.equals("apk", ignoreCase = true)) {
|
||||||
return AppUpdateCheckResult.Incompatible(
|
return AppUpdateCheckResult.Incompatible(
|
||||||
@@ -64,12 +62,24 @@ class ArgusAppUpdateManager(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val remoteVersionCode = manifest.release.androidVersionCode
|
val remoteVersionCode = manifest.release.resolvedVersionCode
|
||||||
?: return AppUpdateCheckResult.Incompatible(
|
val installedVersionName = installedApp.versionName?.trim().orEmpty()
|
||||||
"Argus не вернул androidVersionCode для релиза."
|
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)
|
return AppUpdateCheckResult.UpToDate(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+137
-2
@@ -27,7 +27,38 @@ data class ArgusPublishedRelease(
|
|||||||
val sha256: String,
|
val sha256: String,
|
||||||
val publishedAt: String,
|
val publishedAt: String,
|
||||||
val notes: 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(
|
data class ArgusAppManifest(
|
||||||
val slug: String,
|
val slug: String,
|
||||||
@@ -45,8 +76,112 @@ data class AvailableIkarUpdate(
|
|||||||
val installedApp: InstalledIkarApp,
|
val installedApp: InstalledIkarApp,
|
||||||
val manifest: ArgusAppManifest
|
val manifest: ArgusAppManifest
|
||||||
) {
|
) {
|
||||||
|
val versionCode: Long
|
||||||
|
get() = manifest.release.resolvedVersionCode ?: 0L
|
||||||
|
|
||||||
val downloadUrl: String
|
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 {
|
sealed interface AppUpdateCheckResult {
|
||||||
|
|||||||
+22
-9
@@ -1,5 +1,6 @@
|
|||||||
package com.seven.ikar.kotlin.feature.bots
|
package com.seven.ikar.kotlin.feature.bots
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.Delete
|
||||||
import androidx.compose.material.icons.outlined.SmartToy
|
import androidx.compose.material.icons.outlined.SmartToy
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -31,12 +31,14 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -48,12 +50,12 @@ fun BotEditorScreen(
|
|||||||
onAboutChanged: (String) -> Unit,
|
onAboutChanged: (String) -> Unit,
|
||||||
onCommandsChanged: (String) -> Unit,
|
onCommandsChanged: (String) -> Unit,
|
||||||
onEnabledChanged: (Boolean) -> Unit,
|
onEnabledChanged: (Boolean) -> Unit,
|
||||||
onSaveClick: () -> Unit,
|
|
||||||
onRegenerateTokenClick: () -> Unit,
|
onRegenerateTokenClick: () -> Unit,
|
||||||
onDeleteBotClick: () -> Unit,
|
onDeleteBotClick: () -> Unit,
|
||||||
onOpenChatClick: () -> Unit
|
onOpenChatClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboard = LocalClipboard.current
|
||||||
|
val clipboardScope = rememberCoroutineScope()
|
||||||
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
||||||
if (showDeleteConfirmation) {
|
if (showDeleteConfirmation) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -81,7 +83,7 @@ fun BotEditorScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(if (state.isExistingBot) "Редактор бота" else "Новый бот") },
|
title = { Text(if (state.isExistingBot) "Редактор бота" else "Создатель ботов") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBackClick) {
|
IconButton(onClick = onBackClick) {
|
||||||
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад")
|
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад")
|
||||||
@@ -147,8 +149,13 @@ fun BotEditorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(onClick = onSaveClick, enabled = !state.isSaving && !state.isDeleting, modifier = Modifier.fillMaxWidth()) {
|
if (!state.isExistingBot) {
|
||||||
Text(if (state.isSaving) "Сохранение..." else "Сохранить")
|
Text(
|
||||||
|
"Новых ботов создавайте в чате с @botfatherbot командой /создать_бота.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else if (state.isSaving) {
|
||||||
|
Text("Сохраняется...", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.isExistingBot) {
|
if (state.isExistingBot) {
|
||||||
@@ -183,7 +190,13 @@ fun BotEditorScreen(
|
|||||||
Text("Текущий токен", fontWeight = FontWeight.SemiBold)
|
Text("Текущий токен", fontWeight = FontWeight.SemiBold)
|
||||||
Text(state.latestToken, style = MaterialTheme.typography.bodySmall)
|
Text(state.latestToken, style = MaterialTheme.typography.bodySmall)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { clipboardManager.setText(AnnotatedString(state.latestToken)) },
|
onClick = {
|
||||||
|
clipboardScope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText("Ikar bot token", state.latestToken))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Outlined.ContentCopy, contentDescription = null)
|
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.AuthSessionDto
|
||||||
import com.seven.ikar.kotlin.core.model.BotCommandDto
|
import com.seven.ikar.kotlin.core.model.BotCommandDto
|
||||||
import com.seven.ikar.kotlin.core.model.BotSummaryDto
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -38,29 +40,34 @@ class BotEditorViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(BotEditorUiState(session = container.sessionStore.read(), botId = botId))
|
private val _uiState = MutableStateFlow(BotEditorUiState(session = container.sessionStore.read(), botId = botId))
|
||||||
val uiState: StateFlow<BotEditorUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<BotEditorUiState> = _uiState.asStateFlow()
|
||||||
|
private var autosaveJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateDisplayName(value: String) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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() {
|
fun load() {
|
||||||
@@ -85,8 +92,10 @@ class BotEditorViewModel(
|
|||||||
_uiState.value = state.copy(errorMessage = "Укажите отображаемое имя бота.")
|
_uiState.value = state.copy(errorMessage = "Укажите отображаемое имя бота.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!state.isExistingBot && state.username.isBlank()) {
|
if (!state.isExistingBot) {
|
||||||
_uiState.value = state.copy(errorMessage = "Укажите имя пользователя бота.")
|
_uiState.value = state.copy(
|
||||||
|
errorMessage = "Создавайте ботов через чат с @botfatherbot командой /создать_бота."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,34 +104,19 @@ class BotEditorViewModel(
|
|||||||
runCatching {
|
runCatching {
|
||||||
container.executeAuthorized { session ->
|
container.executeAuthorized { session ->
|
||||||
val parsedCommands = parseCommands(state.commandsText)
|
val parsedCommands = parseCommands(state.commandsText)
|
||||||
if (!state.isExistingBot) {
|
val updated = container.apiClient.updateBot(
|
||||||
val created = container.apiClient.createBot(
|
accessToken = session.accessToken,
|
||||||
accessToken = session.accessToken,
|
botId = state.botId.orEmpty(),
|
||||||
username = state.username.trim(),
|
displayName = state.displayName.trim(),
|
||||||
displayName = state.displayName.trim(),
|
about = state.about.trim().ifBlank { null },
|
||||||
about = state.about.trim().ifBlank { null }
|
isEnabled = state.isEnabled
|
||||||
)
|
)
|
||||||
val updatedBot = if (parsedCommands.isNotEmpty()) {
|
val withCommands = container.apiClient.setBotCommands(
|
||||||
container.apiClient.setBotCommands(session.accessToken, created.bot.id, parsedCommands)
|
accessToken = session.accessToken,
|
||||||
} else {
|
botId = updated.id,
|
||||||
created.bot
|
commands = parsedCommands
|
||||||
}
|
)
|
||||||
Triple(session, updatedBot, Pair(created.accessToken, "Бот создан."))
|
Triple(session, withCommands, Pair(state.latestToken, "Бот обновлён."))
|
||||||
} 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, "Бот сохранён."))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.onSuccess { result ->
|
}.onSuccess { result ->
|
||||||
val (session, bot, extra) = result
|
val (session, bot, extra) = result
|
||||||
@@ -233,4 +227,20 @@ class BotEditorViewModel(
|
|||||||
|
|
||||||
private fun formatCommands(commands: List<BotCommandDto>): String =
|
private fun formatCommands(commands: List<BotCommandDto>): String =
|
||||||
commands.joinToString(separator = "\n") { "/${it.command} - ${it.description}" }
|
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.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
import androidx.compose.material.icons.outlined.Add
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -41,7 +40,7 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
|
|||||||
fun BotsScreen(
|
fun BotsScreen(
|
||||||
state: BotsUiState,
|
state: BotsUiState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onCreateBotClick: () -> Unit,
|
onOpenCreatorBotClick: () -> Unit,
|
||||||
onBotClick: (BotSummaryDto) -> Unit
|
onBotClick: (BotSummaryDto) -> Unit
|
||||||
) {
|
) {
|
||||||
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -72,8 +71,8 @@ fun BotsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Button(onClick = onCreateBotClick) {
|
Button(onClick = onOpenCreatorBotClick) {
|
||||||
Text("Создать бота")
|
Text("Открыть Создателя ботов")
|
||||||
}
|
}
|
||||||
|
|
||||||
state.errorMessage?.let {
|
state.errorMessage?.let {
|
||||||
@@ -98,7 +97,7 @@ fun BotsScreen(
|
|||||||
) {
|
) {
|
||||||
Text("Ботов пока нет", style = MaterialTheme.typography.headlineMedium)
|
Text("Ботов пока нет", style = MaterialTheme.typography.headlineMedium)
|
||||||
Text(
|
Text(
|
||||||
"Создайте первого бота и подключите его через Bot API.",
|
"Создайте первого бота через чат с Создателем ботов.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = TelegramInkMuted
|
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.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
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.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import android.util.Base64
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile
|
import com.seven.ikar.kotlin.core.model.DownloadedAttachmentFile
|
||||||
import com.seven.ikar.kotlin.core.network.UploadFileSource
|
import com.seven.ikar.kotlin.core.network.UploadFileSource
|
||||||
@@ -41,6 +48,7 @@ internal fun LocalAttachmentFile.toUploadFileSource(): UploadFileSource =
|
|||||||
class AndroidAttachmentFileService(
|
class AndroidAttachmentFileService(
|
||||||
private val context: Context
|
private val context: Context
|
||||||
) {
|
) {
|
||||||
|
private val maxCacheFileNameLength = 120
|
||||||
private val maxAttachmentSizeBytes = 25L * 1024L * 1024L
|
private val maxAttachmentSizeBytes = 25L * 1024L * 1024L
|
||||||
private val attachmentsDirectory: File by lazy {
|
private val attachmentsDirectory: File by lazy {
|
||||||
File(context.cacheDir, "attachments").apply { mkdirs() }
|
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 =
|
fun createAttachmentCacheFile(attachmentId: String, fileName: String): File =
|
||||||
File(attachmentsDirectory, "${attachmentId}_${sanitizeFileName(fileName)}")
|
File(attachmentsDirectory, "${sanitizeFileName(attachmentId)}_${sanitizeCacheFileName(fileName)}")
|
||||||
|
|
||||||
suspend fun cacheDownloadedAttachment(file: DownloadedAttachmentFile): LocalAttachmentFile =
|
suspend fun cacheDownloadedAttachment(file: DownloadedAttachmentFile): LocalAttachmentFile =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val target = File(file.localPath)
|
val target = File(file.localPath)
|
||||||
target.setLastModified(System.currentTimeMillis())
|
target.setLastModified(System.currentTimeMillis())
|
||||||
LocalAttachmentFile(
|
LocalAttachmentFile(
|
||||||
fileName = target.name.substringAfter('_', file.fileName),
|
fileName = file.fileName,
|
||||||
contentType = file.contentType,
|
contentType = file.contentType,
|
||||||
fileSizeBytes = file.fileSizeBytes.takeIf { it >= 0L } ?: target.length(),
|
fileSizeBytes = file.fileSizeBytes.takeIf { it >= 0L } ?: target.length(),
|
||||||
localPath = target.absolutePath
|
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 {
|
private fun requireExistingFile(localPath: String): File {
|
||||||
val file = File(localPath)
|
val file = File(localPath)
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
@@ -244,6 +341,29 @@ class AndroidAttachmentFileService(
|
|||||||
return file
|
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 =
|
private fun contentUriFor(file: File): Uri =
|
||||||
FileProvider.getUriForFile(
|
FileProvider.getUriForFile(
|
||||||
context,
|
context,
|
||||||
@@ -298,4 +418,29 @@ class AndroidAttachmentFileService(
|
|||||||
|
|
||||||
private fun sanitizeFileName(value: String): String =
|
private fun sanitizeFileName(value: String): String =
|
||||||
value.trim().ifBlank { "attachment" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
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
|
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.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -11,27 +18,51 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import java.util.concurrent.Semaphore
|
import java.util.concurrent.Semaphore
|
||||||
|
|
||||||
data class VoiceNotePlaybackEvent(
|
data class VoiceNotePlaybackEvent(
|
||||||
val attachmentId: String,
|
val attachmentId: String,
|
||||||
val isPlaying: Boolean,
|
val isPlaying: Boolean,
|
||||||
val positionMs: Long,
|
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 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)
|
private val _events = MutableSharedFlow<VoiceNotePlaybackEvent>(extraBufferCapacity = 32)
|
||||||
val events: SharedFlow<VoiceNotePlaybackEvent> = _events.asSharedFlow()
|
val events: SharedFlow<VoiceNotePlaybackEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
private var player: MediaPlayer? = null
|
private var player: ExoPlayer? = null
|
||||||
private var currentAttachmentId: String? = null
|
private var currentAttachmentId: String? = null
|
||||||
private var durationMs: Long = 0L
|
private var durationMs: Long = 0L
|
||||||
private var tickerJob: Job? = null
|
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()
|
sync.acquire()
|
||||||
try {
|
try {
|
||||||
if (player != null && currentAttachmentId == attachmentId) {
|
if (player != null && currentAttachmentId == attachmentId) {
|
||||||
@@ -41,19 +72,46 @@ class AndroidVoiceNotePlayerService {
|
|||||||
|
|
||||||
stopCurrent()
|
stopCurrent()
|
||||||
|
|
||||||
val nextPlayer = MediaPlayer().apply {
|
val nextPlayer = ExoPlayer.Builder(context).build().apply {
|
||||||
setDataSource(localFilePath)
|
setAudioAttributes(VoiceNoteAudioAttributes, pauseExternalAudio)
|
||||||
prepare()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
player = nextPlayer
|
player = nextPlayer
|
||||||
currentAttachmentId = attachmentId
|
currentAttachmentId = attachmentId
|
||||||
durationMs = nextPlayer.duration.toLong().coerceAtLeast(0L)
|
durationMs = 0L
|
||||||
|
|
||||||
nextPlayer.setOnCompletionListener {
|
nextPlayer.addListener(object : Player.Listener {
|
||||||
scope.launch { stopCurrent(playbackCompleted = true) }
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
}
|
val activeAttachmentId = currentAttachmentId ?: return
|
||||||
nextPlayer.start()
|
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()
|
startTicker()
|
||||||
emitEvent(attachmentId, true, 0L, durationMs)
|
emitEvent(attachmentId, true, 0L, durationMs)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -81,16 +139,8 @@ class AndroidVoiceNotePlayerService {
|
|||||||
durationMs = 0L
|
durationMs = 0L
|
||||||
stopTicker()
|
stopTicker()
|
||||||
|
|
||||||
existingPlayer?.let { mediaPlayer ->
|
existingPlayer?.let { exoPlayer ->
|
||||||
try {
|
exoPlayer.release()
|
||||||
if (safeIsPlaying(mediaPlayer)) {
|
|
||||||
mediaPlayer.stop()
|
|
||||||
}
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaPlayer.reset()
|
|
||||||
mediaPlayer.release()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentId != null) {
|
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 {
|
try {
|
||||||
mediaPlayer?.isPlaying == true
|
exoPlayer?.isPlaying == true
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun safePosition(mediaPlayer: MediaPlayer?): Long =
|
private fun safePosition(exoPlayer: ExoPlayer?): Long =
|
||||||
try {
|
try {
|
||||||
mediaPlayer?.currentPosition?.toLong()?.coerceAtLeast(0L) ?: 0L
|
exoPlayer?.currentPosition?.coerceAtLeast(0L) ?: 0L
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
0L
|
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
|
package com.seven.ikar.kotlin.feature.chat
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -28,11 +30,15 @@ class AndroidVoiceNoteRecorderService(
|
|||||||
private var currentFile: File? = null
|
private var currentFile: File? = null
|
||||||
private var startedAtMs: Long = 0L
|
private var startedAtMs: Long = 0L
|
||||||
private var tickerJob: Job? = null
|
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
|
val isRecording: Boolean
|
||||||
get() = recorder != null
|
get() = recorder != null
|
||||||
|
|
||||||
suspend fun start() {
|
suspend fun start(pauseExternalAudio: Boolean = false) {
|
||||||
sync.acquire()
|
sync.acquire()
|
||||||
try {
|
try {
|
||||||
if (recorder != null) {
|
if (recorder != null) {
|
||||||
@@ -48,6 +54,7 @@ class AndroidVoiceNoteRecorderService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
requestAudioFocusIfNeeded(pauseExternalAudio)
|
||||||
nextRecorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
nextRecorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
nextRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
nextRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
nextRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
nextRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
@@ -60,6 +67,7 @@ class AndroidVoiceNoteRecorderService(
|
|||||||
nextRecorder.reset()
|
nextRecorder.reset()
|
||||||
nextRecorder.release()
|
nextRecorder.release()
|
||||||
file.delete()
|
file.delete()
|
||||||
|
abandonAudioFocus()
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +95,7 @@ class AndroidVoiceNoteRecorderService(
|
|||||||
|
|
||||||
if (currentRecorder == null) {
|
if (currentRecorder == null) {
|
||||||
_elapsedMs.value = 0L
|
_elapsedMs.value = 0L
|
||||||
|
abandonAudioFocus()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +107,7 @@ class AndroidVoiceNoteRecorderService(
|
|||||||
} finally {
|
} finally {
|
||||||
currentRecorder.reset()
|
currentRecorder.reset()
|
||||||
currentRecorder.release()
|
currentRecorder.release()
|
||||||
|
abandonAudioFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
_elapsedMs.value = 0L
|
_elapsedMs.value = 0L
|
||||||
@@ -145,4 +155,40 @@ class AndroidVoiceNoteRecorderService(
|
|||||||
tickerJob?.cancel()
|
tickerJob?.cancel()
|
||||||
tickerJob = null
|
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.app.AppContainer
|
||||||
import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer
|
import com.seven.ikar.kotlin.core.contacts.PhoneNumberNormalizer
|
||||||
import com.seven.ikar.kotlin.core.model.AttachmentDto
|
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.AuthSessionDto
|
||||||
import com.seven.ikar.kotlin.core.model.BotCommandDto
|
import com.seven.ikar.kotlin.core.model.BotCommandDto
|
||||||
import com.seven.ikar.kotlin.core.model.CallMediaType
|
import com.seven.ikar.kotlin.core.model.CallMediaType
|
||||||
@@ -37,6 +38,7 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -111,6 +113,7 @@ data class ChatUiState(
|
|||||||
val canSubscribe: Boolean = false,
|
val canSubscribe: Boolean = false,
|
||||||
val canManageChannel: Boolean = false,
|
val canManageChannel: Boolean = false,
|
||||||
val canPostAsChannel: Boolean = false,
|
val canPostAsChannel: Boolean = false,
|
||||||
|
val canDeleteForEveryone: Boolean = false,
|
||||||
val publicUsername: String? = null,
|
val publicUsername: String? = null,
|
||||||
val linkedDiscussionChatId: String? = null,
|
val linkedDiscussionChatId: String? = null,
|
||||||
val channelDescription: String? = null,
|
val channelDescription: String? = null,
|
||||||
@@ -128,6 +131,7 @@ data class ChatUiState(
|
|||||||
val attachmentPlayback: Map<String, VoiceAttachmentPlaybackUiState> = emptyMap(),
|
val attachmentPlayback: Map<String, VoiceAttachmentPlaybackUiState> = emptyMap(),
|
||||||
val botCommands: List<BotCommandDto> = emptyList(),
|
val botCommands: List<BotCommandDto> = emptyList(),
|
||||||
val previewImageAttachmentId: String? = null,
|
val previewImageAttachmentId: String? = null,
|
||||||
|
val previewVideoAttachmentId: String? = null,
|
||||||
val firstUnreadMessageId: String? = null,
|
val firstUnreadMessageId: String? = null,
|
||||||
val selectedMessageIds: Set<String> = emptySet(),
|
val selectedMessageIds: Set<String> = emptySet(),
|
||||||
val isDeleteSelectionDialogVisible: Boolean = false,
|
val isDeleteSelectionDialogVisible: Boolean = false,
|
||||||
@@ -139,7 +143,8 @@ data class ChatUiState(
|
|||||||
val scrollRequest: ChatScrollRequest? = null,
|
val scrollRequest: ChatScrollRequest? = null,
|
||||||
val isSendingMessage: Boolean = false,
|
val isSendingMessage: Boolean = false,
|
||||||
val isDeletingChat: Boolean = false,
|
val isDeletingChat: Boolean = false,
|
||||||
val isChatDeleted: Boolean = false
|
val isChatDeleted: Boolean = false,
|
||||||
|
val attachmentCacheVersion: Long = 0L
|
||||||
) {
|
) {
|
||||||
val isMessageSelectionMode: Boolean
|
val isMessageSelectionMode: Boolean
|
||||||
get() = selectedMessageIds.isNotEmpty()
|
get() = selectedMessageIds.isNotEmpty()
|
||||||
@@ -165,7 +170,13 @@ class ChatViewModel(
|
|||||||
private val container: AppContainer,
|
private val container: AppContainer,
|
||||||
private val chatId: String
|
private val chatId: String
|
||||||
) : ViewModel() {
|
) : 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()
|
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private val cachedAttachmentFiles = mutableMapOf<String, LocalAttachmentFile>()
|
private val cachedAttachmentFiles = mutableMapOf<String, LocalAttachmentFile>()
|
||||||
@@ -179,6 +190,8 @@ class ChatViewModel(
|
|||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
private var typingStateSent = false
|
private var typingStateSent = false
|
||||||
private var lastTypingSentAtMillis = 0L
|
private var lastTypingSentAtMillis = 0L
|
||||||
|
private var lastLocallyReadMessageId: String? = null
|
||||||
|
private var lastDeliveredMessageId: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
attachRealtime()
|
attachRealtime()
|
||||||
@@ -192,6 +205,9 @@ class ChatViewModel(
|
|||||||
|
|
||||||
fun updateDraft(value: String) {
|
fun updateDraft(value: String) {
|
||||||
_uiState.value = _uiState.value.copy(draftText = value)
|
_uiState.value = _uiState.value.copy(draftText = value)
|
||||||
|
if (editingMessage == null) {
|
||||||
|
container.chatDraftStore.save(chatId, value)
|
||||||
|
}
|
||||||
notifyTyping(value.isNotBlank())
|
notifyTyping(value.isNotBlank())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +323,56 @@ class ChatViewModel(
|
|||||||
_uiState.value = _uiState.value.copy(draftText = _uiState.value.draftText + emoji)
|
_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() {
|
fun clearErrorMessage() {
|
||||||
_uiState.value = _uiState.value.copy(errorMessage = null)
|
_uiState.value = _uiState.value.copy(errorMessage = null)
|
||||||
}
|
}
|
||||||
@@ -350,6 +416,12 @@ class ChatViewModel(
|
|||||||
if (messageId.isBlank()) {
|
if (messageId.isBlank()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.messages.none { it.id == messageId }) {
|
||||||
|
requestScrollToMessageInternal(messageId)
|
||||||
|
loadMessagesAround(messageId)
|
||||||
|
return
|
||||||
|
}
|
||||||
requestScrollToMessageInternal(messageId)
|
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() {
|
fun sendMessage() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (!state.canSendMessages || state.isSendingMessage) {
|
if (!state.canSendMessages || state.isSendingMessage) {
|
||||||
@@ -531,6 +654,7 @@ class ChatViewModel(
|
|||||||
)
|
)
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
clearPendingAttachment()
|
clearPendingAttachment()
|
||||||
|
clearStoredDraft()
|
||||||
_uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false)
|
_uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(isSendingMessage = false, errorMessage = error.message)
|
_uiState.value = _uiState.value.copy(isSendingMessage = false, errorMessage = error.message)
|
||||||
@@ -546,6 +670,7 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (optimisticMessage != null) {
|
if (optimisticMessage != null) {
|
||||||
|
clearStoredDraft()
|
||||||
val optimisticMessages = (state.messages + optimisticMessage)
|
val optimisticMessages = (state.messages + optimisticMessage)
|
||||||
.distinctBy { it.id }
|
.distinctBy { it.id }
|
||||||
.sortedBy { it.sentAt }
|
.sortedBy { it.sentAt }
|
||||||
@@ -607,6 +732,7 @@ class ChatViewModel(
|
|||||||
if (localMessageId != null) {
|
if (localMessageId != null) {
|
||||||
replaceLocalMessage(localMessageId, message)
|
replaceLocalMessage(localMessageId, message)
|
||||||
} else {
|
} else {
|
||||||
|
clearStoredDraft()
|
||||||
_uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false)
|
_uiState.value = _uiState.value.copy(draftText = "", replyTo = null, isSendingMessage = false)
|
||||||
replaceOrInsertMessage(message)
|
replaceOrInsertMessage(message)
|
||||||
}
|
}
|
||||||
@@ -666,6 +792,7 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}.onSuccess { message ->
|
}.onSuccess { message ->
|
||||||
if (commandText.startsWith("/turn ", ignoreCase = true)) {
|
if (commandText.startsWith("/turn ", ignoreCase = true)) {
|
||||||
|
clearStoredDraft()
|
||||||
_uiState.value = _uiState.value.copy(draftText = "")
|
_uiState.value = _uiState.value.copy(draftText = "")
|
||||||
}
|
}
|
||||||
replaceOrInsertMessage(message)
|
replaceOrInsertMessage(message)
|
||||||
@@ -684,6 +811,7 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}.onSuccess { ownerUserId ->
|
}.onSuccess { ownerUserId ->
|
||||||
clearPendingAttachment()
|
clearPendingAttachment()
|
||||||
|
clearStoredDraft()
|
||||||
cancelEditMessageInternal(clearDraft = true)
|
cancelEditMessageInternal(clearDraft = true)
|
||||||
container.chatScrollStateStore.clear(chatId)
|
container.chatScrollStateStore.clear(chatId)
|
||||||
clearCachedChat(ownerUserId)
|
clearCachedChat(ownerUserId)
|
||||||
@@ -706,7 +834,7 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteChat() {
|
fun deleteChat(forEveryone: Boolean = false) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.isDeletingChat || state.isChatDeleted) {
|
if (state.isDeletingChat || state.isChatDeleted) {
|
||||||
return
|
return
|
||||||
@@ -719,12 +847,13 @@ class ChatViewModel(
|
|||||||
container.apiClient.deleteChat(
|
container.apiClient.deleteChat(
|
||||||
accessToken = session.accessToken,
|
accessToken = session.accessToken,
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
forEveryone = false
|
forEveryone = forEveryone
|
||||||
)
|
)
|
||||||
session.user.id
|
session.user.id
|
||||||
}
|
}
|
||||||
}.onSuccess { ownerUserId ->
|
}.onSuccess { ownerUserId ->
|
||||||
clearPendingAttachment()
|
clearPendingAttachment()
|
||||||
|
clearStoredDraft()
|
||||||
cancelEditMessageInternal(clearDraft = true)
|
cancelEditMessageInternal(clearDraft = true)
|
||||||
container.chatScrollStateStore.clear(chatId)
|
container.chatScrollStateStore.clear(chatId)
|
||||||
container.pushSettings.resetChatNotificationPreferences(chatId)
|
container.pushSettings.resetChatNotificationPreferences(chatId)
|
||||||
@@ -779,6 +908,7 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}.onSuccess { ownerUserId ->
|
}.onSuccess { ownerUserId ->
|
||||||
clearPendingAttachment()
|
clearPendingAttachment()
|
||||||
|
clearStoredDraft()
|
||||||
cancelEditMessageInternal(clearDraft = true)
|
cancelEditMessageInternal(clearDraft = true)
|
||||||
container.chatScrollStateStore.clear(chatId)
|
container.chatScrollStateStore.clear(chatId)
|
||||||
container.pushSettings.resetChatNotificationPreferences(chatId)
|
container.pushSettings.resetChatNotificationPreferences(chatId)
|
||||||
@@ -960,11 +1090,16 @@ class ChatViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachment.kind == com.seven.ikar.kotlin.core.model.AttachmentKind.VoiceNote) {
|
if (attachment.isVoiceNoteAttachment()) {
|
||||||
toggleVoiceAttachmentPlayback(attachment)
|
toggleVoiceAttachmentPlayback(attachment)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attachment.isVideoAttachment()) {
|
||||||
|
showVideoPreviewAttachment(attachment.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
val local = ensureAttachmentDownloaded(attachment)
|
val local = ensureAttachmentDownloaded(attachment)
|
||||||
@@ -987,6 +1122,14 @@ class ChatViewModel(
|
|||||||
_uiState.value = _uiState.value.copy(previewImageAttachmentId = attachmentId)
|
_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) {
|
fun shareAttachmentExternally(attachment: AttachmentDto) {
|
||||||
if (isAttachmentInProtectedMessage(attachment)) {
|
if (isAttachmentInProtectedMessage(attachment)) {
|
||||||
_uiState.value = _uiState.value.copy(errorMessage = "Защищённый контент нельзя пересылать.")
|
_uiState.value = _uiState.value.copy(errorMessage = "Защищённый контент нельзя пересылать.")
|
||||||
@@ -1034,7 +1177,9 @@ class ChatViewModel(
|
|||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
container.voiceNoteRecorderService.start()
|
container.voiceNoteRecorderService.start(
|
||||||
|
pauseExternalAudio = container.chatSettings.snapshot.value.pauseMusicOnRecordEnabled
|
||||||
|
)
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isRecordingVoiceNote = true,
|
isRecordingVoiceNote = true,
|
||||||
@@ -1079,7 +1224,8 @@ class ChatViewModel(
|
|||||||
fileName = file.name,
|
fileName = file.name,
|
||||||
contentType = "audio/mp4",
|
contentType = "audio/mp4",
|
||||||
contentLength = file.length()
|
contentLength = file.length()
|
||||||
) { file.inputStream() }
|
) { file.inputStream() },
|
||||||
|
isVoiceNote = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1165,9 +1311,31 @@ class ChatViewModel(
|
|||||||
container.callManager.endCurrentCall()
|
container.callManager.endCurrentCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun attachmentPreviewUrl(attachment: AttachmentDto): String = when {
|
fun attachmentPreviewUrl(attachment: AttachmentDto): String {
|
||||||
attachment.downloadPath.startsWith("http://") || attachment.downloadPath.startsWith("https://") -> attachment.downloadPath
|
val cachedFile = cachedAttachmentFiles[attachment.id]
|
||||||
else -> ServerConfig.absoluteMessengerUrl(attachment.downloadPath)
|
?.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 =
|
fun showCallHeaderButton(state: ChatUiState = uiState.value): Boolean =
|
||||||
@@ -1305,6 +1473,7 @@ class ChatViewModel(
|
|||||||
canSubscribe = chat.canSubscribe,
|
canSubscribe = chat.canSubscribe,
|
||||||
canManageChannel = chat.canManageChannel,
|
canManageChannel = chat.canManageChannel,
|
||||||
canPostAsChannel = chat.canPostAsChannel,
|
canPostAsChannel = chat.canPostAsChannel,
|
||||||
|
canDeleteForEveryone = chat.canDeleteForEveryone,
|
||||||
publicUsername = chat.publicUsername,
|
publicUsername = chat.publicUsername,
|
||||||
linkedDiscussionChatId = chat.linkedDiscussionChatId,
|
linkedDiscussionChatId = chat.linkedDiscussionChatId,
|
||||||
channelDescription = chat.description,
|
channelDescription = chat.description,
|
||||||
@@ -1326,6 +1495,11 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
cacheMessages(session.user.id, chat.messages)
|
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)
|
scheduleAutoDownloads(orderedMessages)
|
||||||
refreshCallBanner()
|
refreshCallBanner()
|
||||||
}
|
}
|
||||||
@@ -1335,9 +1509,8 @@ class ChatViewModel(
|
|||||||
container.realtimeService.messagesCreated.collect { message ->
|
container.realtimeService.messagesCreated.collect { message ->
|
||||||
if (message.chatId == chatId) {
|
if (message.chatId == chatId) {
|
||||||
replaceOrInsertMessage(message)
|
replaceOrInsertMessage(message)
|
||||||
requestScrollToMessageInternal(message.id)
|
|
||||||
if (message.sender.id != _uiState.value.session?.user?.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,
|
positionMs = event.positionMs,
|
||||||
durationMs = event.durationMs
|
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
|
localPath = target.absolutePath
|
||||||
)
|
)
|
||||||
cachedAttachmentFiles[attachment.id] = cached
|
cachedAttachmentFiles[attachment.id] = cached
|
||||||
|
notifyAttachmentCacheChanged()
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1517,10 +1694,27 @@ class ChatViewModel(
|
|||||||
val downloaded = container.apiClient.downloadAttachmentTo(session.accessToken, attachment, target)
|
val downloaded = container.apiClient.downloadAttachmentTo(session.accessToken, attachment, target)
|
||||||
val cached = container.attachmentFileService.cacheDownloadedAttachment(downloaded)
|
val cached = container.attachmentFileService.cacheDownloadedAttachment(downloaded)
|
||||||
cachedAttachmentFiles[attachment.id] = cached
|
cachedAttachmentFiles[attachment.id] = cached
|
||||||
|
saveImageToGalleryIfEnabled(attachment, cached)
|
||||||
|
notifyAttachmentCacheChanged()
|
||||||
cached
|
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>) {
|
private fun scheduleAutoDownloads(messages: List<MessageDto>) {
|
||||||
val settings = container.appSettings.loadSnapshot()
|
val settings = container.appSettings.loadSnapshot()
|
||||||
if (!settings.autoDownloadPhotos && !settings.autoDownloadVideosOnWifi && !settings.autoDownloadFilesOnWifi) {
|
if (!settings.autoDownloadPhotos && !settings.autoDownloadVideosOnWifi && !settings.autoDownloadFilesOnWifi) {
|
||||||
@@ -1549,7 +1743,7 @@ class ChatViewModel(
|
|||||||
isUnmetered: Boolean
|
isUnmetered: Boolean
|
||||||
): Boolean = when {
|
): Boolean = when {
|
||||||
attachment.isImageAttachment() -> settings.autoDownloadPhotos
|
attachment.isImageAttachment() -> settings.autoDownloadPhotos
|
||||||
attachment.contentType.startsWith("video/", ignoreCase = true) -> settings.autoDownloadVideosOnWifi && isUnmetered
|
attachment.isVideoAttachment() -> settings.autoDownloadVideosOnWifi && isUnmetered
|
||||||
else -> settings.autoDownloadFilesOnWifi && isUnmetered
|
else -> settings.autoDownloadFilesOnWifi && isUnmetered
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1572,7 +1766,22 @@ class ChatViewModel(
|
|||||||
val local = ensureAttachmentDownloaded(attachment)
|
val local = ensureAttachmentDownloaded(attachment)
|
||||||
container.voiceNotePlayerService.toggle(
|
container.voiceNotePlayerService.toggle(
|
||||||
attachmentId = attachment.id,
|
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 ->
|
}.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(errorMessage = error.message)
|
_uiState.value = _uiState.value.copy(errorMessage = error.message)
|
||||||
@@ -1585,12 +1794,16 @@ class ChatViewModel(
|
|||||||
_uiState.value = _uiState.value.copy(pendingAttachment = null)
|
_uiState.value = _uiState.value.copy(pendingAttachment = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearStoredDraft() {
|
||||||
|
container.chatDraftStore.clear(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun cancelEditMessageInternal(clearDraft: Boolean) {
|
private fun cancelEditMessageInternal(clearDraft: Boolean) {
|
||||||
editingMessage = null
|
editingMessage = null
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
editingMessageId = null,
|
editingMessageId = null,
|
||||||
editingMessageBanner = "",
|
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) {
|
private fun clearCachedChat(ownerUserId: String) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
container.chatMessageCache.clearChat(ownerUserId, chatId)
|
container.chatMessageCache.clearChat(ownerUserId, chatId)
|
||||||
@@ -1723,11 +1941,57 @@ class ChatViewModel(
|
|||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
messageId = messageId
|
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? {
|
private fun resolvePeer(chat: ChatDetailsDto, session: AuthSessionDto): UserSummaryDto? {
|
||||||
if (chat.type != ChatType.Direct) {
|
if (chat.type != ChatType.Direct) {
|
||||||
return null
|
return null
|
||||||
@@ -1825,8 +2089,10 @@ class ChatViewModel(
|
|||||||
state.activeCall == null
|
state.activeCall == null
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun MessageDto.canManage(currentUserId: String?): Boolean =
|
internal fun MessageDto.canManage(currentUserId: String?, canPostAsChannel: Boolean = false): Boolean =
|
||||||
currentUserId != null && sender.id == currentUserId && deletedAt == null
|
currentUserId != null &&
|
||||||
|
deletedAt == null &&
|
||||||
|
(sender.id == currentUserId || (chatType == ChatType.Channel && canPostAsChannel))
|
||||||
|
|
||||||
internal fun MessageDto.canDelete(currentUserId: String?): Boolean =
|
internal fun MessageDto.canDelete(currentUserId: String?): Boolean =
|
||||||
currentUserId != null && deletedAt == null
|
currentUserId != null && deletedAt == null
|
||||||
@@ -1945,6 +2211,104 @@ internal fun AttachmentDto.isImageAttachment(): Boolean =
|
|||||||
fileName.endsWith(".webp", ignoreCase = true) ||
|
fileName.endsWith(".webp", ignoreCase = true) ||
|
||||||
fileName.endsWith(".gif", 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> {
|
internal fun buildBotQuickActions(message: MessageDto): List<BotQuickAction> {
|
||||||
if (!message.sender.isBot || message.text.isBlank()) {
|
if (!message.sender.isBot || message.text.isBlank()) {
|
||||||
return emptyList()
|
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
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
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.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ContentCopy
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.ChatInviteLinkDto
|
||||||
|
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
|
||||||
import com.seven.ikar.kotlin.core.model.ChatTopicDto
|
import com.seven.ikar.kotlin.core.model.ChatTopicDto
|
||||||
import com.seven.ikar.kotlin.core.model.ChatType
|
import com.seven.ikar.kotlin.core.model.ChatType
|
||||||
import com.seven.ikar.kotlin.ui.IkarRowDivider
|
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.TelegramBlue
|
||||||
import com.seven.ikar.kotlin.ui.theme.TelegramDanger
|
import com.seven.ikar.kotlin.ui.theme.TelegramDanger
|
||||||
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
|
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
|
@Composable
|
||||||
fun ChatInfoScreen(
|
fun ChatInfoScreen(
|
||||||
@@ -44,14 +67,26 @@ fun ChatInfoScreen(
|
|||||||
onRefreshClick: () -> Unit,
|
onRefreshClick: () -> Unit,
|
||||||
onMembersClick: () -> Unit,
|
onMembersClick: () -> Unit,
|
||||||
onSearchClick: () -> Unit,
|
onSearchClick: () -> Unit,
|
||||||
|
onProfileTitleChanged: (String) -> Unit,
|
||||||
|
onProfileDescriptionChanged: (String) -> Unit,
|
||||||
|
onProfilePublicUsernameChanged: (String) -> Unit,
|
||||||
|
onMaterialsClick: (ChatMaterialFilter) -> Unit,
|
||||||
onInviteTitleChanged: (String) -> Unit,
|
onInviteTitleChanged: (String) -> Unit,
|
||||||
onInviteJoinRequestChanged: (Boolean) -> Unit,
|
onInviteJoinRequestChanged: (Boolean) -> Unit,
|
||||||
|
onInviteExpiresInHoursChanged: (String) -> Unit,
|
||||||
|
onInviteMaxUsesChanged: (String) -> Unit,
|
||||||
|
onInviteMemberLimitChanged: (String) -> Unit,
|
||||||
onCreateInviteClick: () -> Unit,
|
onCreateInviteClick: () -> Unit,
|
||||||
onRevokeInviteClick: (ChatInviteLinkDto) -> Unit,
|
onRevokeInviteClick: (ChatInviteLinkDto) -> Unit,
|
||||||
onTopicTitleChanged: (String) -> Unit,
|
onTopicTitleChanged: (String) -> Unit,
|
||||||
onCreateTopicClick: () -> Unit
|
onCreateTopicClick: () -> Unit,
|
||||||
|
onLinkDiscussionClick: (ChatSummaryDto) -> Unit,
|
||||||
|
onClearDiscussionClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val info = state.info
|
val info = state.info
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val clipboardScope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -75,42 +110,145 @@ fun ChatInfoScreen(
|
|||||||
|
|
||||||
if (info != null) {
|
if (info != null) {
|
||||||
IkarSectionCard {
|
IkarSectionCard {
|
||||||
IkarSectionTitle("Обзор", modifier = Modifier.padding(bottom = 8.dp))
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(info.chat.type.label(), style = MaterialTheme.typography.bodyMedium, color = TelegramInkMuted)
|
IkarSectionTitle("Обзор")
|
||||||
Text(info.channelAudienceText(), style = MaterialTheme.typography.titleMedium)
|
Text(info.chat.type.label(), style = MaterialTheme.typography.bodyMedium, color = TelegramInkMuted)
|
||||||
if (info.chat.type == ChatType.Channel && info.adminCount > 0) {
|
Text(info.channelAudienceText(), style = MaterialTheme.typography.titleMedium)
|
||||||
Text("${info.adminCount} ${russianPlural(info.adminCount, "администратор", "администратора", "администраторов")}", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted)
|
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.canEditProfile()) {
|
||||||
}
|
IkarSectionCard {
|
||||||
if (!info.publicUsername.isNullOrBlank()) {
|
IkarSectionTitle("Профиль", modifier = Modifier.padding(bottom = 8.dp))
|
||||||
Text("@${info.publicUsername}", color = TelegramBlue)
|
IkarSearchCard(
|
||||||
}
|
value = state.profileTitle,
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
onValueChange = onProfileTitleChanged,
|
||||||
TextButton(onClick = onMembersClick) { Text("Участники") }
|
placeholder = "Название"
|
||||||
TextButton(onClick = onSearchClick) { Text("Поиск") }
|
)
|
||||||
TextButton(onClick = onRefreshClick) { Text("Обновить") }
|
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 {
|
IkarSectionCard {
|
||||||
IkarSectionTitle("Общие материалы", modifier = Modifier.padding(bottom = 8.dp))
|
IkarSectionTitle("Общие материалы", modifier = Modifier.padding(bottom = 8.dp))
|
||||||
InfoStatRow("Медиа", info.mediaCount)
|
InfoStatRow("Медиа", info.mediaCount, onClick = { onMaterialsClick(ChatMaterialFilter.Media) })
|
||||||
InfoStatRow("Файлы", info.fileCount)
|
InfoStatRow("Файлы", info.fileCount, onClick = { onMaterialsClick(ChatMaterialFilter.Files) })
|
||||||
InfoStatRow("Ссылки", info.linkCount)
|
InfoStatRow("Ссылки", info.linkCount, onClick = { onMaterialsClick(ChatMaterialFilter.Links) })
|
||||||
InfoStatRow("Голосовые", info.voiceCount)
|
InfoStatRow("Голосовые", info.voiceCount, onClick = { onMaterialsClick(ChatMaterialFilter.Voice) })
|
||||||
InfoStatRow("Закреплённые", info.pinnedCount)
|
InfoStatRow("Закреплённые", info.pinnedCount, onClick = { onMaterialsClick(ChatMaterialFilter.Pinned) })
|
||||||
}
|
}
|
||||||
|
|
||||||
IkarSectionCard {
|
if (info.chat.type == ChatType.Channel && info.canManageChannel) {
|
||||||
IkarSectionTitle("Пригласительные ссылки", modifier = Modifier.padding(bottom = 8.dp))
|
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(
|
IkarSearchCard(
|
||||||
value = state.inviteTitle,
|
value = state.inviteTitle,
|
||||||
onValueChange = onInviteTitleChanged,
|
onValueChange = onInviteTitleChanged,
|
||||||
placeholder = "Название ссылки"
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -142,6 +280,7 @@ fun ChatInfoScreen(
|
|||||||
IkarRowDivider()
|
IkarRowDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IkarSectionCard {
|
IkarSectionCard {
|
||||||
@@ -167,9 +306,101 @@ fun ChatInfoScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InfoStatRow(label: String, value: Int) {
|
private fun DiscussionGroupRow(
|
||||||
|
group: ChatSummaryDto,
|
||||||
|
isLinked: Boolean,
|
||||||
|
isSaving: Boolean,
|
||||||
|
onLinkClick: () -> Unit
|
||||||
|
) {
|
||||||
Row(
|
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(),
|
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,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -204,6 +435,13 @@ private fun InviteLinkRow(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = if (link.isRevoked) TelegramDanger else TelegramInkMuted
|
color = if (link.isRevoked) TelegramDanger else TelegramInkMuted
|
||||||
)
|
)
|
||||||
|
inviteLinkLimitsText(link)?.let { limits ->
|
||||||
|
Text(
|
||||||
|
text = limits,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = TelegramInkMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!link.isRevoked) {
|
if (!link.isRevoked) {
|
||||||
TextButton(onClick = onRevokeClick) { Text("Отозвать") }
|
TextButton(onClick = onRevokeClick) { Text("Отозвать") }
|
||||||
}
|
}
|
||||||
@@ -233,7 +471,32 @@ private fun ChatType.label(): String = when (this) {
|
|||||||
ChatType.Channel -> "Канал"
|
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) {
|
if (chat.type != ChatType.Channel) {
|
||||||
return "${chat.participants.size} участников"
|
return "${chat.participants.size} участников"
|
||||||
}
|
}
|
||||||
|
|||||||
+205
-6
@@ -3,17 +3,32 @@ package com.seven.ikar.kotlin.feature.chatinfo
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.seven.ikar.kotlin.app.AppContainer
|
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.ChatInfoDto
|
||||||
import com.seven.ikar.kotlin.core.model.ChatInviteLinkDto
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
data class ChatInfoUiState(
|
data class ChatInfoUiState(
|
||||||
val info: ChatInfoDto? = null,
|
val info: ChatInfoDto? = null,
|
||||||
|
val discussionGroups: List<ChatSummaryDto> = emptyList(),
|
||||||
val inviteTitle: String = "",
|
val inviteTitle: String = "",
|
||||||
val inviteCreatesJoinRequest: Boolean = false,
|
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 topicTitle: String = "",
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
@@ -26,6 +41,7 @@ class ChatInfoViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(ChatInfoUiState())
|
private val _uiState = MutableStateFlow(ChatInfoUiState())
|
||||||
val uiState: StateFlow<ChatInfoUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ChatInfoUiState> = _uiState.asStateFlow()
|
||||||
|
private var profileAutosaveJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
@@ -36,10 +52,25 @@ class ChatInfoViewModel(
|
|||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
runCatching {
|
runCatching {
|
||||||
container.executeAuthorized { session ->
|
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 ->
|
}.onSuccess { (info, discussionGroups) ->
|
||||||
_uiState.value = _uiState.value.copy(info = info, isLoading = false)
|
_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 ->
|
}.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
|
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
|
||||||
}
|
}
|
||||||
@@ -54,13 +85,111 @@ class ChatInfoViewModel(
|
|||||||
_uiState.value = _uiState.value.copy(inviteCreatesJoinRequest = value)
|
_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) {
|
fun updateTopicTitle(value: String) {
|
||||||
_uiState.value = _uiState.value.copy(topicTitle = value.take(80))
|
_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() {
|
fun createInviteLink() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.isSaving) return
|
if (state.isSaving || state.info?.canInviteUsers != true) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -69,13 +198,23 @@ class ChatInfoViewModel(
|
|||||||
accessToken = session.accessToken,
|
accessToken = session.accessToken,
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
title = state.inviteTitle,
|
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 {
|
}.onSuccess {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
inviteTitle = "",
|
inviteTitle = "",
|
||||||
inviteCreatesJoinRequest = false,
|
inviteCreatesJoinRequest = false,
|
||||||
|
inviteExpiresInHours = "",
|
||||||
|
inviteMaxUses = "",
|
||||||
|
inviteMemberLimit = "",
|
||||||
isSaving = false
|
isSaving = false
|
||||||
)
|
)
|
||||||
refresh()
|
refresh()
|
||||||
@@ -86,7 +225,7 @@ class ChatInfoViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun revokeInviteLink(link: ChatInviteLinkDto) {
|
fun revokeInviteLink(link: ChatInviteLinkDto) {
|
||||||
if (_uiState.value.isSaving) return
|
if (_uiState.value.isSaving || _uiState.value.info?.canInviteUsers != true) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null)
|
||||||
runCatching {
|
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.material.icons.outlined.PushPin
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.network.ServerConfig
|
||||||
import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption
|
import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption
|
||||||
import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot
|
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.feature.share.PendingExternalShare
|
||||||
import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage
|
import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage
|
||||||
import com.seven.ikar.kotlin.ui.AvatarView
|
import com.seven.ikar.kotlin.ui.AvatarView
|
||||||
@@ -99,6 +104,11 @@ fun ChatsScreen(
|
|||||||
onPinSelectedChatsClick: () -> Unit,
|
onPinSelectedChatsClick: () -> Unit,
|
||||||
onArchiveSelectedChatsClick: () -> Unit,
|
onArchiveSelectedChatsClick: () -> Unit,
|
||||||
onMuteSelectedChatsClick: () -> Unit,
|
onMuteSelectedChatsClick: () -> Unit,
|
||||||
|
onTogglePinChat: (ChatSummaryDto) -> Unit,
|
||||||
|
onToggleArchiveChat: (ChatSummaryDto) -> Unit,
|
||||||
|
onToggleMuteChat: (ChatSummaryDto) -> Unit,
|
||||||
|
onMarkChatRead: (ChatSummaryDto) -> Unit,
|
||||||
|
onSwipeDeleteChat: (ChatSummaryDto) -> Unit,
|
||||||
onDismissDeleteDialog: () -> Unit,
|
onDismissDeleteDialog: () -> Unit,
|
||||||
onDeleteForEveryoneChanged: (Boolean) -> Unit,
|
onDeleteForEveryoneChanged: (Boolean) -> Unit,
|
||||||
onConfirmDeleteSelected: () -> Unit,
|
onConfirmDeleteSelected: () -> Unit,
|
||||||
@@ -112,6 +122,7 @@ fun ChatsScreen(
|
|||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
onNotificationsClick: () -> Unit,
|
onNotificationsClick: () -> Unit,
|
||||||
onGlobalSearchClick: () -> Unit,
|
onGlobalSearchClick: () -> Unit,
|
||||||
|
onFilterChanged: (ChatListFilter) -> Unit,
|
||||||
onQueryChanged: (String) -> Unit
|
onQueryChanged: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val isRealtimeConnected = !state.isLoading || state.chats.isNotEmpty()
|
val isRealtimeConnected = !state.isLoading || state.chats.isNotEmpty()
|
||||||
@@ -229,6 +240,13 @@ fun ChatsScreen(
|
|||||||
placeholder = "Поиск чатов"
|
placeholder = "Поиск чатов"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!state.isSelectionMode && !state.isShareMode) {
|
||||||
|
ChatFilterTabs(
|
||||||
|
selectedFilter = state.filter,
|
||||||
|
onFilterChanged = onFilterChanged
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
TextButton(onClick = onGlobalSearchClick) {
|
TextButton(onClick = onGlobalSearchClick) {
|
||||||
Text("Глобальный поиск: сообщения, медиа, файлы и ссылки")
|
Text("Глобальный поиск: сообщения, медиа, файлы и ссылки")
|
||||||
}
|
}
|
||||||
@@ -275,20 +293,38 @@ fun ChatsScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(state.chats, key = { it.chat.id }) { chatItem ->
|
items(state.chats, key = { it.chat.id }) { chatItem ->
|
||||||
ChatListRow(
|
val rowClick = {
|
||||||
chatItem = chatItem,
|
if (state.isSelectionMode) {
|
||||||
currentUserId = state.session?.user?.id,
|
onChatSelectionClick(chatItem.chat)
|
||||||
accessToken = state.session?.accessToken,
|
} else {
|
||||||
previewMaxLines = previewMaxLines,
|
onChatClick(chatItem.chat)
|
||||||
onClick = {
|
}
|
||||||
if (state.isSelectionMode) {
|
}
|
||||||
onChatSelectionClick(chatItem.chat)
|
if (state.isSelectionMode || state.isShareMode) {
|
||||||
} else {
|
ChatListRow(
|
||||||
onChatClick(chatItem.chat)
|
chatItem = chatItem,
|
||||||
}
|
currentUserId = state.session?.user?.id,
|
||||||
},
|
accessToken = state.session?.accessToken,
|
||||||
onLongClick = { onChatLongPress(chatItem.chat) }
|
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)) }
|
item { Box(modifier = Modifier.padding(bottom = 132.dp)) }
|
||||||
@@ -323,6 +359,7 @@ fun ChatsScreen(
|
|||||||
selectedChatsCount = state.selectedChatsCount,
|
selectedChatsCount = state.selectedChatsCount,
|
||||||
showDeleteForEveryoneOption = state.showDeleteForEveryoneOption,
|
showDeleteForEveryoneOption = state.showDeleteForEveryoneOption,
|
||||||
deleteForEveryone = state.deleteForEveryone,
|
deleteForEveryone = state.deleteForEveryone,
|
||||||
|
deleteForEveryoneLabel = state.deleteForEveryoneLabel,
|
||||||
isDeletingSelected = state.isDeletingSelected,
|
isDeletingSelected = state.isDeletingSelected,
|
||||||
onDismiss = onDismissDeleteDialog,
|
onDismiss = onDismissDeleteDialog,
|
||||||
onDeleteForEveryoneChanged = onDeleteForEveryoneChanged,
|
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
|
@Composable
|
||||||
private fun ChatsBrandHeader(
|
private fun ChatsBrandHeader(
|
||||||
isRealtimeConnected: Boolean,
|
isRealtimeConnected: Boolean,
|
||||||
@@ -370,7 +441,7 @@ private fun StoriesClusterButton(
|
|||||||
currentUserId: String?,
|
currentUserId: String?,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val previewStories = stories.take(3)
|
val previewStories = remember(stories) { stories.toAuthorGroups().take(3) }
|
||||||
val placeholderColors = listOf(
|
val placeholderColors = listOf(
|
||||||
TelegramBlue,
|
TelegramBlue,
|
||||||
Color(0xFF7C4DFF),
|
Color(0xFF7C4DFF),
|
||||||
@@ -385,7 +456,8 @@ private fun StoriesClusterButton(
|
|||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
repeat(3) { index ->
|
repeat(3) { index ->
|
||||||
val story = previewStories.getOrNull(index)
|
val group = previewStories.getOrNull(index)
|
||||||
|
val story = group?.latestStory
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = (index * 13).dp)
|
.padding(start = (index * 13).dp)
|
||||||
@@ -432,6 +504,7 @@ private fun StoriesTray(
|
|||||||
onComposeStoryClick: () -> Unit,
|
onComposeStoryClick: () -> Unit,
|
||||||
onStoryClick: (StoryDto) -> Unit
|
onStoryClick: (StoryDto) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val storyGroups = remember(stories) { stories.toAuthorGroups() }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.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(
|
StoryBubble(
|
||||||
title = if (story.author.id == currentUserId) "Моя" else story.author.displayName,
|
title = if (story.author.id == currentUserId) "Моя" else story.author.displayName,
|
||||||
avatarUrl = ServerConfig.normalizeMediaUrl(story.author.avatarPath),
|
avatarUrl = ServerConfig.normalizeMediaUrl(story.author.avatarPath),
|
||||||
@@ -462,8 +536,9 @@ private fun StoriesTray(
|
|||||||
story.attachments.firstOrNull { it.contentType.startsWith("image/") }?.downloadPath
|
story.attachments.firstOrNull { it.contentType.startsWith("image/") }?.downloadPath
|
||||||
),
|
),
|
||||||
accessToken = accessToken,
|
accessToken = accessToken,
|
||||||
viewed = story.viewedByMe || story.author.id == currentUserId,
|
viewed = group.isViewedByMe || story.author.id == currentUserId,
|
||||||
onClick = { onStoryClick(story) }
|
badgeText = group.stories.size.takeIf { it > 1 }?.toString(),
|
||||||
|
onClick = { onStoryClick(group.openStory) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +556,7 @@ private fun StoryBubble(
|
|||||||
previewUrl: String?,
|
previewUrl: String?,
|
||||||
accessToken: String?,
|
accessToken: String?,
|
||||||
viewed: Boolean,
|
viewed: Boolean,
|
||||||
|
badgeText: String? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -520,6 +596,24 @@ private fun StoryBubble(
|
|||||||
size = 56.dp
|
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(
|
Text(
|
||||||
title,
|
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
|
@Composable
|
||||||
private fun ShareSelectionHeader(
|
private fun ShareSelectionHeader(
|
||||||
selectedChatsCount: Int,
|
selectedChatsCount: Int,
|
||||||
@@ -718,6 +836,7 @@ private fun DeleteChatsDialog(
|
|||||||
selectedChatsCount: Int,
|
selectedChatsCount: Int,
|
||||||
showDeleteForEveryoneOption: Boolean,
|
showDeleteForEveryoneOption: Boolean,
|
||||||
deleteForEveryone: Boolean,
|
deleteForEveryone: Boolean,
|
||||||
|
deleteForEveryoneLabel: String,
|
||||||
isDeletingSelected: Boolean,
|
isDeletingSelected: Boolean,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onDeleteForEveryoneChanged: (Boolean) -> Unit,
|
onDeleteForEveryoneChanged: (Boolean) -> Unit,
|
||||||
@@ -749,7 +868,7 @@ private fun DeleteChatsDialog(
|
|||||||
onCheckedChange = onDeleteForEveryoneChanged
|
onCheckedChange = onDeleteForEveryoneChanged
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Также удалить чат для собеседника",
|
text = deleteForEveryoneLabel,
|
||||||
style = MaterialTheme.typography.bodyMedium
|
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
|
@Composable
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
private fun ChatListRow(
|
private fun ChatListRow(
|
||||||
@@ -795,7 +1002,7 @@ private fun ChatListRow(
|
|||||||
val rowBackground = if (chatItem.isSelected) {
|
val rowBackground = if (chatItem.isSelected) {
|
||||||
TelegramBlue.copy(alpha = 0.12f)
|
TelegramBlue.copy(alpha = 0.12f)
|
||||||
} else {
|
} else {
|
||||||
Color.Transparent
|
MaterialTheme.colorScheme.background
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@@ -848,6 +1055,7 @@ private fun ChatListRow(
|
|||||||
) {
|
) {
|
||||||
ChatLastMessagePreview(
|
ChatLastMessagePreview(
|
||||||
chat = chat,
|
chat = chat,
|
||||||
|
draftText = chatItem.draftText,
|
||||||
accessToken = accessToken,
|
accessToken = accessToken,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
maxLines = previewMaxLines
|
maxLines = previewMaxLines
|
||||||
@@ -887,12 +1095,37 @@ private fun ChatListRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ChatLastMessagePreview(
|
private fun ChatLastMessagePreview(
|
||||||
chat: ChatSummaryDto,
|
chat: ChatSummaryDto,
|
||||||
|
draftText: String,
|
||||||
accessToken: String?,
|
accessToken: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
maxLines: Int
|
maxLines: Int
|
||||||
) {
|
) {
|
||||||
val attachment = chat.lastMessageAttachment
|
val attachment = chat.lastMessageAttachment
|
||||||
when {
|
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 -> {
|
attachment?.isChatPreviewImage() == true -> {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -980,8 +1213,7 @@ private fun AttachmentDto.isChatPreviewImage(): Boolean =
|
|||||||
fileName.endsWith(".gif", ignoreCase = true)
|
fileName.endsWith(".gif", ignoreCase = true)
|
||||||
|
|
||||||
private fun AttachmentDto.isChatPreviewVoiceNote(): Boolean =
|
private fun AttachmentDto.isChatPreviewVoiceNote(): Boolean =
|
||||||
kind == AttachmentKind.VoiceNote ||
|
kind == AttachmentKind.VoiceNote
|
||||||
contentType.startsWith("audio/", ignoreCase = true)
|
|
||||||
|
|
||||||
private fun selectionCountLabel(selectedChatsCount: Int): String = when {
|
private fun selectionCountLabel(selectedChatsCount: Int): String = when {
|
||||||
selectedChatsCount % 10 == 1 && selectedChatsCount % 100 != 11 -> "Выбран $selectedChatsCount чат"
|
selectedChatsCount % 10 == 1 && selectedChatsCount % 100 != 11 -> "Выбран $selectedChatsCount чат"
|
||||||
|
|||||||
+138
-8
@@ -31,6 +31,7 @@ data class ChatListItemUiState(
|
|||||||
val chat: ChatSummaryDto,
|
val chat: ChatSummaryDto,
|
||||||
val resolvedTitle: String,
|
val resolvedTitle: String,
|
||||||
val lastActivityLabel: String,
|
val lastActivityLabel: String,
|
||||||
|
val draftText: String,
|
||||||
val isSelected: Boolean
|
val isSelected: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@ enum class ChatListFilter {
|
|||||||
Archive
|
Archive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val DefaultDeleteForEveryoneLabel = "Также удалить чат для собеседника"
|
||||||
|
|
||||||
data class ChatsUiState(
|
data class ChatsUiState(
|
||||||
val session: AuthSessionDto? = null,
|
val session: AuthSessionDto? = null,
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
@@ -64,7 +67,9 @@ data class ChatsUiState(
|
|||||||
val isDeleteDialogVisible: Boolean = false,
|
val isDeleteDialogVisible: Boolean = false,
|
||||||
val deleteForEveryone: Boolean = false,
|
val deleteForEveryone: Boolean = false,
|
||||||
val showDeleteForEveryoneOption: 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
|
val isShareMode: Boolean
|
||||||
get() = pendingShare != null
|
get() = pendingShare != null
|
||||||
@@ -81,11 +86,13 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
private val _allChats = mutableListOf<ChatSummaryDto>()
|
private val _allChats = mutableListOf<ChatSummaryDto>()
|
||||||
private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap()
|
private var contactNamesByNormalizedPhone: Map<String, String> = emptyMap()
|
||||||
private var refreshJob: Job? = null
|
private var refreshJob: Job? = null
|
||||||
|
private var hadActiveStoryTransfers = false
|
||||||
val uiState: StateFlow<ChatsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ChatsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
attachIncomingShare()
|
attachIncomingShare()
|
||||||
attachRealtime()
|
attachRealtime()
|
||||||
|
attachStoryTransferCompletion()
|
||||||
loadCachedChats()
|
loadCachedChats()
|
||||||
refresh()
|
refresh()
|
||||||
refreshLatestRelease()
|
refreshLatestRelease()
|
||||||
@@ -128,7 +135,9 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
isDeleteDialogVisible = false,
|
isDeleteDialogVisible = false,
|
||||||
deleteForEveryone = false,
|
deleteForEveryone = false,
|
||||||
showDeleteForEveryoneOption = false,
|
showDeleteForEveryoneOption = false,
|
||||||
isDeletingSelected = false
|
deleteForEveryoneLabel = DefaultDeleteForEveryoneLabel,
|
||||||
|
isDeletingSelected = false,
|
||||||
|
selectionStartedFromSwipeDelete = false
|
||||||
)
|
)
|
||||||
applyFilter()
|
applyFilter()
|
||||||
}
|
}
|
||||||
@@ -143,15 +152,23 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
_uiState.value = state.copy(
|
_uiState.value = state.copy(
|
||||||
isDeleteDialogVisible = true,
|
isDeleteDialogVisible = true,
|
||||||
deleteForEveryone = state.deleteForEveryone && showDeleteForEveryoneOption,
|
deleteForEveryone = state.deleteForEveryone && showDeleteForEveryoneOption,
|
||||||
showDeleteForEveryoneOption = showDeleteForEveryoneOption
|
showDeleteForEveryoneOption = showDeleteForEveryoneOption,
|
||||||
|
deleteForEveryoneLabel = deleteForEveryoneLabel(state.selectedChatIds),
|
||||||
|
selectionStartedFromSwipeDelete = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissDeleteDialog() {
|
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,
|
isDeleteDialogVisible = false,
|
||||||
deleteForEveryone = false
|
deleteForEveryone = false,
|
||||||
|
showDeleteForEveryoneOption = false,
|
||||||
|
deleteForEveryoneLabel = DefaultDeleteForEveryoneLabel,
|
||||||
|
selectionStartedFromSwipeDelete = false
|
||||||
)
|
)
|
||||||
|
applyFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateDeleteForEveryone(value: Boolean) {
|
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) {
|
private fun updateSelectedChatState(request: ChatStateRequest) {
|
||||||
val selectedChatIds = _uiState.value.selectedChatIds.toList()
|
val selectedChatIds = _uiState.value.selectedChatIds.toList()
|
||||||
if (selectedChatIds.isEmpty()) {
|
if (selectedChatIds.isEmpty()) {
|
||||||
@@ -403,15 +496,21 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
} else {
|
} else {
|
||||||
chatItems.filter { item ->
|
chatItems.filter { item ->
|
||||||
item.resolvedTitle.contains(query, ignoreCase = true) ||
|
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.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(
|
_uiState.value = currentState.copy(
|
||||||
chats = filtered,
|
chats = filtered,
|
||||||
selectedChatIds = selectedChatIds,
|
selectedChatIds = selectedChatIds,
|
||||||
showDeleteForEveryoneOption = showDeleteForEveryoneOption,
|
showDeleteForEveryoneOption = showDeleteForEveryoneOption,
|
||||||
|
deleteForEveryoneLabel = if (showDeleteForEveryoneOption) {
|
||||||
|
deleteForEveryoneLabel(selectedChatIds)
|
||||||
|
} else {
|
||||||
|
DefaultDeleteForEveryoneLabel
|
||||||
|
},
|
||||||
deleteForEveryone = deleteForEveryone,
|
deleteForEveryone = deleteForEveryone,
|
||||||
isDeleteDialogVisible = !currentState.isShareMode &&
|
isDeleteDialogVisible = !currentState.isShareMode &&
|
||||||
currentState.isDeleteDialogVisible &&
|
currentState.isDeleteDialogVisible &&
|
||||||
@@ -428,6 +527,7 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
chat = chat,
|
chat = chat,
|
||||||
resolvedTitle = resolveChatTitle(chat, currentUserId),
|
resolvedTitle = resolveChatTitle(chat, currentUserId),
|
||||||
lastActivityLabel = formatLastActivity(chat.lastActivityAt),
|
lastActivityLabel = formatLastActivity(chat.lastActivityAt),
|
||||||
|
draftText = container.chatDraftStore.read(chat.id).trim(),
|
||||||
isSelected = selectedChatIds.contains(chat.id)
|
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
|
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() {
|
private fun attachRealtime() {
|
||||||
@@ -485,11 +594,30 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
container.realtimeService.messagesUpdated.collect { scheduleRefresh() }
|
container.realtimeService.messagesUpdated.collect { scheduleRefresh() }
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
container.realtimeService.storiesChanged.collect { scheduleRefresh() }
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
container.realtimeService.appUpdateAvailable.collect { release ->
|
container.realtimeService.appUpdateAvailable.collect { release ->
|
||||||
_uiState.value = _uiState.value.copy(latestRelease = 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() {
|
private fun scheduleRefresh() {
|
||||||
@@ -510,7 +638,9 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
isDeleteDialogVisible = if (pendingShare == null) currentState.isDeleteDialogVisible else false,
|
isDeleteDialogVisible = if (pendingShare == null) currentState.isDeleteDialogVisible else false,
|
||||||
deleteForEveryone = if (pendingShare == null) currentState.deleteForEveryone else false,
|
deleteForEveryone = if (pendingShare == null) currentState.deleteForEveryone else false,
|
||||||
showDeleteForEveryoneOption = if (pendingShare == null) currentState.showDeleteForEveryoneOption 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,
|
isDeletingSelected = if (pendingShare == null) currentState.isDeletingSelected else false,
|
||||||
|
selectionStartedFromSwipeDelete = if (pendingShare == null) currentState.selectionStartedFromSwipeDelete else false,
|
||||||
isSendingShare = currentState.isSendingShare && pendingShare != null
|
isSendingShare = currentState.isSendingShare && pendingShare != null
|
||||||
)
|
)
|
||||||
applyFilter()
|
applyFilter()
|
||||||
|
|||||||
+44
-17
@@ -22,7 +22,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
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.IkarRowDivider
|
||||||
import com.seven.ikar.kotlin.ui.IkarScreenSurface
|
import com.seven.ikar.kotlin.ui.IkarScreenSurface
|
||||||
import com.seven.ikar.kotlin.ui.IkarSectionCard
|
import com.seven.ikar.kotlin.ui.IkarSectionCard
|
||||||
@@ -36,8 +37,7 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
|
|||||||
fun DevicesScreen(
|
fun DevicesScreen(
|
||||||
state: DevicesUiState,
|
state: DevicesUiState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onRefreshClick: () -> Unit,
|
onRefreshClick: () -> Unit
|
||||||
onRevokeClick: (UserSessionDto) -> Unit
|
|
||||||
) {
|
) {
|
||||||
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -52,14 +52,25 @@ fun DevicesScreen(
|
|||||||
state.errorMessage?.let {
|
state.errorMessage?.let {
|
||||||
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall)
|
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
if (state.isLoading && state.sessions.isEmpty()) {
|
if (state.isLoading && state.devices.isEmpty()) {
|
||||||
CircularProgressIndicator(color = TelegramBlue)
|
CircularProgressIndicator(color = TelegramBlue)
|
||||||
}
|
}
|
||||||
IkarSectionCard {
|
IkarSectionCard {
|
||||||
IkarSectionTitle("Активные сеансы", modifier = Modifier.padding(bottom = 8.dp))
|
IkarSectionTitle("Устройства с Икаром", modifier = Modifier.padding(bottom = 8.dp))
|
||||||
state.sessions.forEachIndexed { index, session ->
|
if (state.devices.isEmpty() && !state.isLoading) {
|
||||||
DeviceRow(session = session, enabled = !state.isSaving, onRevokeClick = { onRevokeClick(session) })
|
Text(
|
||||||
if (index != state.sessions.lastIndex) {
|
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()
|
IkarRowDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,9 +82,8 @@ fun DevicesScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceRow(
|
private fun DeviceRow(
|
||||||
session: UserSessionDto,
|
device: PushDeviceDto,
|
||||||
enabled: Boolean,
|
isCurrent: Boolean
|
||||||
onRevokeClick: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -83,17 +93,34 @@ private fun DeviceRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(session.deviceName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = TelegramInkMuted
|
color = TelegramInkMuted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (session.isCurrent) {
|
if (isCurrent) {
|
||||||
Text("Текущий", color = TelegramBlue)
|
Text("Это устройство", color = TelegramBlue)
|
||||||
} else {
|
|
||||||
TextButton(enabled = enabled, onClick = onRevokeClick) { Text("Завершить") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.seven.ikar.kotlin.app.AppContainer
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class DevicesUiState(
|
data class DevicesUiState(
|
||||||
val sessions: List<UserSessionDto> = emptyList(),
|
val devices: List<PushDeviceDto> = emptyList(),
|
||||||
|
val currentInstallationId: String = "",
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
|
||||||
val errorMessage: String? = null
|
val errorMessage: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,30 +29,17 @@ class DevicesViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
runCatching {
|
runCatching {
|
||||||
container.executeAuthorized { session ->
|
container.executeAuthorized { session ->
|
||||||
container.apiClient.getMySessions(session.accessToken)
|
container.apiClient.getPushDevices(session.accessToken)
|
||||||
}
|
}
|
||||||
}.onSuccess { sessions ->
|
}.onSuccess { devices ->
|
||||||
_uiState.value = _uiState.value.copy(sessions = sessions, isLoading = false)
|
_uiState.value = _uiState.value.copy(
|
||||||
|
devices = devices,
|
||||||
|
currentInstallationId = container.pushSettings.getInstallationId(),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
|
_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.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
import androidx.compose.material.icons.outlined.Group
|
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.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem
|
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.AvatarView
|
||||||
import com.seven.ikar.kotlin.ui.IkarCenteredHeader
|
import com.seven.ikar.kotlin.ui.IkarCenteredHeader
|
||||||
import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble
|
import com.seven.ikar.kotlin.ui.IkarHeaderIconBubble
|
||||||
@@ -65,9 +74,13 @@ fun DiscoverScreen(
|
|||||||
onQueryChanged: (String) -> Unit,
|
onQueryChanged: (String) -> Unit,
|
||||||
onRefreshClick: () -> Unit,
|
onRefreshClick: () -> Unit,
|
||||||
onUserClick: (DiscoverUserItem) -> Unit,
|
onUserClick: (DiscoverUserItem) -> Unit,
|
||||||
|
onChannelClick: (ChatSummaryDto) -> Unit,
|
||||||
onBeginGroupSelection: () -> Unit,
|
onBeginGroupSelection: () -> Unit,
|
||||||
onBeginChannelSelection: () -> Unit,
|
onBeginChannelSelection: () -> Unit,
|
||||||
onCancelSelection: () -> Unit,
|
onCancelSelection: () -> Unit,
|
||||||
|
onGroupBackClick: () -> Unit,
|
||||||
|
onGroupTitleChanged: (String) -> Unit,
|
||||||
|
onContinueGroupWizard: () -> Unit,
|
||||||
onChannelBackClick: () -> Unit,
|
onChannelBackClick: () -> Unit,
|
||||||
onChannelTitleChanged: (String) -> Unit,
|
onChannelTitleChanged: (String) -> Unit,
|
||||||
onChannelDescriptionChanged: (String) -> Unit,
|
onChannelDescriptionChanged: (String) -> Unit,
|
||||||
@@ -85,8 +98,11 @@ fun DiscoverScreen(
|
|||||||
}
|
}
|
||||||
val hasContactsPermission =
|
val hasContactsPermission =
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
val isGroupWizard = state.groupWizardStep != null
|
||||||
val isChannelWizard = state.channelWizardStep != 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) {
|
LaunchedEffect(hasContactsPermission) {
|
||||||
if (hasContactsPermission) {
|
if (hasContactsPermission) {
|
||||||
@@ -95,20 +111,40 @@ fun DiscoverScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
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(
|
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)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
IkarCenteredHeader(
|
IkarCenteredHeader(
|
||||||
title = when {
|
title = when {
|
||||||
|
isGroupWizard -> "Новая группа"
|
||||||
isChannelWizard -> "Новый канал"
|
isChannelWizard -> "Новый канал"
|
||||||
state.selectionMode == DiscoverSelectionMode.Group -> "Новая группа"
|
state.selectionMode == DiscoverSelectionMode.Group -> "Новая группа"
|
||||||
else -> "Новое сообщение"
|
else -> "Новое сообщение"
|
||||||
},
|
},
|
||||||
left = {
|
left = {
|
||||||
IkarHeaderIconBubble(
|
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)
|
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Назад", tint = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
@@ -134,8 +170,25 @@ fun DiscoverScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (state.channelWizardStep) {
|
when {
|
||||||
ChannelWizardStep.Details -> ChannelDetailsStep(
|
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,
|
state = state,
|
||||||
onTitleChanged = onChannelTitleChanged,
|
onTitleChanged = onChannelTitleChanged,
|
||||||
onDescriptionChanged = onChannelDescriptionChanged,
|
onDescriptionChanged = onChannelDescriptionChanged,
|
||||||
@@ -143,7 +196,7 @@ fun DiscoverScreen(
|
|||||||
onNextClick = onContinueChannelWizard
|
onNextClick = onContinueChannelWizard
|
||||||
)
|
)
|
||||||
|
|
||||||
ChannelWizardStep.Type -> ChannelTypeStep(
|
state.channelWizardStep == ChannelWizardStep.Type -> ChannelTypeStep(
|
||||||
state = state,
|
state = state,
|
||||||
onVisibilityChanged = onChannelVisibilityChanged,
|
onVisibilityChanged = onChannelVisibilityChanged,
|
||||||
onPublicUsernameChanged = onChannelPublicUsernameChanged,
|
onPublicUsernameChanged = onChannelPublicUsernameChanged,
|
||||||
@@ -152,7 +205,7 @@ fun DiscoverScreen(
|
|||||||
onNextClick = onContinueChannelWizard
|
onNextClick = onContinueChannelWizard
|
||||||
)
|
)
|
||||||
|
|
||||||
ChannelWizardStep.Subscribers -> SelectionBanner(
|
state.channelWizardStep == ChannelWizardStep.Subscribers -> SelectionBanner(
|
||||||
title = "Добавить подписчиков",
|
title = "Добавить подписчиков",
|
||||||
subtitle = "Можно создать канал сейчас и пригласить людей позже.",
|
subtitle = "Можно создать канал сейчас и пригласить людей позже.",
|
||||||
selectedCount = state.selectedUserIds.size,
|
selectedCount = state.selectedUserIds.size,
|
||||||
@@ -162,7 +215,7 @@ fun DiscoverScreen(
|
|||||||
enabled = !state.isLoading
|
enabled = !state.isLoading
|
||||||
)
|
)
|
||||||
|
|
||||||
null -> {
|
else -> {
|
||||||
if (state.selectionMode == null) {
|
if (state.selectionMode == null) {
|
||||||
IkarSectionCard(padding = PaddingValues(0.dp)) {
|
IkarSectionCard(padding = PaddingValues(0.dp)) {
|
||||||
DiscoverActionRow(
|
DiscoverActionRow(
|
||||||
@@ -193,6 +246,15 @@ fun DiscoverScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showPublicChannels) {
|
||||||
|
PublicChannelsSection(
|
||||||
|
channels = state.publicChannels,
|
||||||
|
query = state.query,
|
||||||
|
isLoading = state.isLoading,
|
||||||
|
onChannelClick = onChannelClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
state.errorMessage?.let {
|
state.errorMessage?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
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
|
@Composable
|
||||||
private fun ChannelDetailsStep(
|
private fun ChannelDetailsStep(
|
||||||
state: DiscoverUiState,
|
state: DiscoverUiState,
|
||||||
@@ -401,13 +603,13 @@ private fun StepActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WizardStepIndicator(currentStep: Int) {
|
private fun WizardStepIndicator(currentStep: Int, totalSteps: Int = 3) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
(1..3).forEach { step ->
|
(1..totalSteps).forEach { step ->
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(4.dp)
|
.height(4.dp)
|
||||||
@@ -417,7 +619,7 @@ private fun WizardStepIndicator(currentStep: Int) {
|
|||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"$currentStep/3",
|
"$currentStep/$totalSteps",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = TelegramInkMuted,
|
color = TelegramInkMuted,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
|
|||||||
+88
-9
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.seven.ikar.kotlin.app.AppContainer
|
import com.seven.ikar.kotlin.app.AppContainer
|
||||||
import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem
|
import com.seven.ikar.kotlin.core.contacts.DiscoverUserItem
|
||||||
|
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -23,6 +24,11 @@ enum class ChannelWizardStep {
|
|||||||
Subscribers
|
Subscribers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class GroupWizardStep {
|
||||||
|
Details,
|
||||||
|
Members
|
||||||
|
}
|
||||||
|
|
||||||
enum class ChannelVisibility {
|
enum class ChannelVisibility {
|
||||||
Public,
|
Public,
|
||||||
Private
|
Private
|
||||||
@@ -31,11 +37,14 @@ enum class ChannelVisibility {
|
|||||||
data class DiscoverUiState(
|
data class DiscoverUiState(
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val items: List<DiscoverUserItem> = emptyList(),
|
val items: List<DiscoverUserItem> = emptyList(),
|
||||||
|
val publicChannels: List<ChatSummaryDto> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val pendingChatId: String? = null,
|
val pendingChatId: String? = null,
|
||||||
val selectionMode: DiscoverSelectionMode? = null,
|
val selectionMode: DiscoverSelectionMode? = null,
|
||||||
val selectedUserIds: Set<String> = emptySet(),
|
val selectedUserIds: Set<String> = emptySet(),
|
||||||
|
val groupWizardStep: GroupWizardStep? = null,
|
||||||
|
val groupTitle: String = "",
|
||||||
val channelWizardStep: ChannelWizardStep? = null,
|
val channelWizardStep: ChannelWizardStep? = null,
|
||||||
val channelTitle: String = "",
|
val channelTitle: String = "",
|
||||||
val channelDescription: String = "",
|
val channelDescription: String = "",
|
||||||
@@ -65,22 +74,44 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
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)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
runCatching { container.resolvedContactsRepository.loadMergedCandidates(query) }
|
val contactsResult = runCatching { container.resolvedContactsRepository.loadMergedCandidates(query) }
|
||||||
.onSuccess { items ->
|
val channelsResult = if (shouldLoadPublicChannels) {
|
||||||
_uiState.value = _uiState.value.copy(items = items, isLoading = false, errorMessage = null)
|
runCatching {
|
||||||
}
|
container.executeAuthorized { session ->
|
||||||
.onFailure { error ->
|
container.apiClient.getPublicChannels(session.accessToken, query.trim(), limit = 50)
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
|
}
|
||||||
}
|
}
|
||||||
|
} 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() {
|
fun beginGroupSelection() {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
selectionMode = DiscoverSelectionMode.Group,
|
selectionMode = null,
|
||||||
selectedUserIds = emptySet(),
|
selectedUserIds = emptySet(),
|
||||||
|
groupWizardStep = GroupWizardStep.Details,
|
||||||
|
groupTitle = "",
|
||||||
channelWizardStep = null,
|
channelWizardStep = null,
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
)
|
)
|
||||||
@@ -90,6 +121,8 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
selectionMode = null,
|
selectionMode = null,
|
||||||
selectedUserIds = emptySet(),
|
selectedUserIds = emptySet(),
|
||||||
|
groupWizardStep = null,
|
||||||
|
groupTitle = "",
|
||||||
channelWizardStep = ChannelWizardStep.Details,
|
channelWizardStep = ChannelWizardStep.Details,
|
||||||
channelTitle = "",
|
channelTitle = "",
|
||||||
channelDescription = "",
|
channelDescription = "",
|
||||||
@@ -104,11 +137,26 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
selectionMode = null,
|
selectionMode = null,
|
||||||
selectedUserIds = emptySet(),
|
selectedUserIds = emptySet(),
|
||||||
|
groupWizardStep = null,
|
||||||
|
groupTitle = "",
|
||||||
channelWizardStep = null,
|
channelWizardStep = null,
|
||||||
errorMessage = 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() {
|
fun previousChannelWizardStep() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
when (state.channelWizardStep) {
|
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) {
|
fun updateChannelTitle(value: String) {
|
||||||
_uiState.value = _uiState.value.copy(channelTitle = value.take(80), errorMessage = null)
|
_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) {
|
fun handleUserTap(user: DiscoverUserItem) {
|
||||||
val selectionMode = _uiState.value.selectionMode
|
val selectionMode = _uiState.value.selectionMode
|
||||||
if (selectionMode == null) {
|
if (selectionMode == null) {
|
||||||
@@ -203,7 +276,7 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
when (mode) {
|
when (mode) {
|
||||||
DiscoverSelectionMode.Group -> container.apiClient.createGroupChat(
|
DiscoverSelectionMode.Group -> container.apiClient.createGroupChat(
|
||||||
accessToken = session.accessToken,
|
accessToken = session.accessToken,
|
||||||
title = "Новая группа",
|
title = state.groupTitle.trim(),
|
||||||
memberIds = selectedIds
|
memberIds = selectedIds
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -224,6 +297,8 @@ class DiscoverViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
selectionMode = null,
|
selectionMode = null,
|
||||||
selectedUserIds = emptySet(),
|
selectedUserIds = emptySet(),
|
||||||
|
groupWizardStep = null,
|
||||||
|
groupTitle = "",
|
||||||
channelWizardStep = null,
|
channelWizardStep = null,
|
||||||
errorMessage = 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() {
|
fun consumePendingChatNavigation() {
|
||||||
_uiState.value = _uiState.value.copy(pendingChatId = null)
|
_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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -37,7 +42,11 @@ fun ForwardScreen(
|
|||||||
onUserClick: (DiscoverUserItem) -> Unit
|
onUserClick: (DiscoverUserItem) -> Unit
|
||||||
) {
|
) {
|
||||||
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp),
|
modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(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
|
package com.seven.ikar.kotlin.feature.login
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
state: LoginUiState,
|
state: LoginUiState,
|
||||||
|
onEmailChanged: (String) -> Unit,
|
||||||
onPhoneNumberChanged: (String) -> Unit,
|
onPhoneNumberChanged: (String) -> Unit,
|
||||||
onDisplayNameChanged: (String) -> Unit,
|
onDisplayNameChanged: (String) -> Unit,
|
||||||
onVerificationCodeChanged: (String) -> Unit,
|
onVerificationCodeChanged: (String) -> Unit,
|
||||||
onRequestCodeClick: () -> Unit,
|
onRequestCodeClick: () -> Unit,
|
||||||
onVerifyCodeClick: () -> Unit,
|
onVerifyCodeClick: () -> Unit,
|
||||||
onChangePhoneNumberClick: () -> Unit
|
onChangeEmailClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -116,7 +117,16 @@ fun LoginScreen(
|
|||||||
fontWeight = FontWeight.SemiBold
|
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(
|
OutlinedTextField(
|
||||||
value = state.phoneNumber,
|
value = state.phoneNumber,
|
||||||
onValueChange = onPhoneNumberChanged,
|
onValueChange = onPhoneNumberChanged,
|
||||||
@@ -128,7 +138,7 @@ fun LoginScreen(
|
|||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onRequestCodeClick,
|
onClick = onRequestCodeClick,
|
||||||
enabled = !state.isLoading,
|
enabled = !state.isLoading && state.email.isNotBlank() && state.phoneNumber.isNotBlank(),
|
||||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
|
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
|
||||||
) {
|
) {
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
@@ -137,7 +147,7 @@ fun LoginScreen(
|
|||||||
modifier = Modifier.padding(horizontal = 8.dp)
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Получить тестовый код")
|
Text("Получить код")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,25 +164,16 @@ fun LoginScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Подтверждение номера",
|
text = "Подтверждение email",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Для тестового режима код показывается прямо в приложении. После подключения SMS-провайдера это сообщение исчезнет.",
|
text = "Код отправлен на указанную электронную почту. Номер телефона будет привязан к аккаунту.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color(0xFF617A8F)
|
color = Color(0xFF617A8F)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state.hasTestCodeHint) {
|
|
||||||
Text(
|
|
||||||
text = "Тестовый код: ${state.testCodeHint}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = Color(0xFF229AF0),
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.verificationCode,
|
value = state.verificationCode,
|
||||||
onValueChange = onVerificationCodeChanged,
|
onValueChange = onVerificationCodeChanged,
|
||||||
@@ -194,7 +195,9 @@ fun LoginScreen(
|
|||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onVerifyCodeClick,
|
onClick = onVerifyCodeClick,
|
||||||
enabled = !state.isLoading,
|
enabled = !state.isLoading &&
|
||||||
|
state.verificationCode.isNotBlank() &&
|
||||||
|
(!state.isNewAccount || state.displayName.isNotBlank()),
|
||||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
|
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
|
||||||
) {
|
) {
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
@@ -208,7 +211,7 @@ fun LoginScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onChangePhoneNumberClick,
|
onClick = onChangeEmailClick,
|
||||||
enabled = !state.isLoading,
|
enabled = !state.isLoading,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
@@ -216,7 +219,7 @@ fun LoginScreen(
|
|||||||
),
|
),
|
||||||
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp)
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class LoginUiState(
|
data class LoginUiState(
|
||||||
|
val email: String = "",
|
||||||
val phoneNumber: String = "",
|
val phoneNumber: String = "",
|
||||||
val displayName: String = "",
|
val displayName: String = "",
|
||||||
val verificationCode: String = "",
|
val verificationCode: String = "",
|
||||||
val challengeId: String = "",
|
val challengeId: String = "",
|
||||||
val testCodeHint: String = "",
|
|
||||||
val isAwaitingCode: Boolean = false,
|
val isAwaitingCode: Boolean = false,
|
||||||
val isNewAccount: Boolean = false,
|
val isNewAccount: Boolean = false,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -22,16 +22,16 @@ data class LoginUiState(
|
|||||||
val isAuthenticated: Boolean = false
|
val isAuthenticated: Boolean = false
|
||||||
) {
|
) {
|
||||||
val pageTitle: String
|
val pageTitle: String
|
||||||
get() = if (isAwaitingCode) "Подтвердите номер" else "Вход в ИКАР"
|
get() = if (isAwaitingCode) "Подтвердите email" else "Вход в ИКАР"
|
||||||
|
|
||||||
val subtitle: String
|
val subtitle: String
|
||||||
get() = if (isAwaitingCode) {
|
get() = if (isAwaitingCode) {
|
||||||
"Введите тестовый код. Когда подключим SMS-провайдера, здесь будет код из сообщения."
|
"Введите код, который отправлен на указанную электронную почту."
|
||||||
} else {
|
} else {
|
||||||
"Введите номер телефона, чтобы получить тестовый код входа."
|
"Введите электронную почту и номер телефона. Код придёт только на email."
|
||||||
}
|
}
|
||||||
|
|
||||||
val showPhoneStep: Boolean
|
val showEmailStep: Boolean
|
||||||
get() = !isAwaitingCode
|
get() = !isAwaitingCode
|
||||||
|
|
||||||
val showCodeStep: Boolean
|
val showCodeStep: Boolean
|
||||||
@@ -39,15 +39,16 @@ data class LoginUiState(
|
|||||||
|
|
||||||
val showDisplayName: Boolean
|
val showDisplayName: Boolean
|
||||||
get() = isAwaitingCode && isNewAccount
|
get() = isAwaitingCode && isNewAccount
|
||||||
|
|
||||||
val hasTestCodeHint: Boolean
|
|
||||||
get() = testCodeHint.isNotBlank()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(LoginUiState())
|
private val _uiState = MutableStateFlow(LoginUiState())
|
||||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun updateEmail(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(email = value, errorMessage = null)
|
||||||
|
}
|
||||||
|
|
||||||
fun updatePhoneNumber(value: String) {
|
fun updatePhoneNumber(value: String) {
|
||||||
_uiState.value = _uiState.value.copy(phoneNumber = value, errorMessage = null)
|
_uiState.value = _uiState.value.copy(phoneNumber = value, errorMessage = null)
|
||||||
}
|
}
|
||||||
@@ -62,19 +63,26 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
|
|
||||||
fun requestCode() {
|
fun requestCode() {
|
||||||
val state = _uiState.value
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
_uiState.value = state.copy(isLoading = true, errorMessage = null)
|
_uiState.value = state.copy(isLoading = true, errorMessage = null)
|
||||||
runCatching {
|
runCatching {
|
||||||
container.apiClient.requestPhoneCode(state.phoneNumber.trim())
|
container.apiClient.requestEmailCode(
|
||||||
|
email = state.email.trim(),
|
||||||
|
phoneNumber = state.phoneNumber.trim()
|
||||||
|
)
|
||||||
}.onSuccess { challenge ->
|
}.onSuccess { challenge ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
challengeId = challenge.challengeId,
|
challengeId = challenge.challengeId,
|
||||||
testCodeHint = challenge.testCode,
|
|
||||||
isAwaitingCode = true,
|
isAwaitingCode = true,
|
||||||
isNewAccount = !challenge.isRegistered,
|
isNewAccount = !challenge.isRegistered,
|
||||||
verificationCode = "",
|
verificationCode = "",
|
||||||
@@ -83,7 +91,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = error.message ?: "Не удалось запросить код."
|
errorMessage = error.message ?: "Не удалось отправить код на email."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +101,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.isLoading ||
|
if (state.isLoading ||
|
||||||
state.challengeId.isBlank() ||
|
state.challengeId.isBlank() ||
|
||||||
|
state.email.isBlank() ||
|
||||||
state.phoneNumber.isBlank() ||
|
state.phoneNumber.isBlank() ||
|
||||||
state.verificationCode.isBlank() ||
|
state.verificationCode.isBlank() ||
|
||||||
(state.isNewAccount && state.displayName.isBlank())
|
(state.isNewAccount && state.displayName.isBlank())
|
||||||
@@ -103,8 +112,9 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
_uiState.value = state.copy(isLoading = true, errorMessage = null)
|
_uiState.value = state.copy(isLoading = true, errorMessage = null)
|
||||||
runCatching {
|
runCatching {
|
||||||
container.apiClient.verifyPhoneCode(
|
container.apiClient.verifyEmailCode(
|
||||||
challengeId = state.challengeId,
|
challengeId = state.challengeId,
|
||||||
|
email = state.email.trim(),
|
||||||
phoneNumber = state.phoneNumber.trim(),
|
phoneNumber = state.phoneNumber.trim(),
|
||||||
code = state.verificationCode.trim(),
|
code = state.verificationCode.trim(),
|
||||||
displayName = state.displayName.trim().ifBlank { null }
|
displayName = state.displayName.trim().ifBlank { null }
|
||||||
@@ -116,16 +126,15 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = error.message ?: "Не удалось подтвердить код."
|
errorMessage = error.message ?: "Не удалось подтвердить email-код."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changePhoneNumber() {
|
fun changeEmail() {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
challengeId = "",
|
challengeId = "",
|
||||||
testCodeHint = "",
|
|
||||||
verificationCode = "",
|
verificationCode = "",
|
||||||
isAwaitingCode = false,
|
isAwaitingCode = false,
|
||||||
isNewAccount = false,
|
isNewAccount = false,
|
||||||
|
|||||||
+42
-3
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.seven.ikar.kotlin.app.AppContainer
|
import com.seven.ikar.kotlin.app.AppContainer
|
||||||
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
|
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
|
||||||
|
import com.seven.ikar.kotlin.core.push.NotificationSettingsSnapshot
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -22,8 +23,10 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
|
|
||||||
private var debounceJob: Job? = null
|
private var debounceJob: Job? = null
|
||||||
private var refreshJob: Job? = null
|
private var refreshJob: Job? = null
|
||||||
|
private var latestChats: List<ChatSummaryDto> = emptyList()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
attachNotificationSettings()
|
||||||
attachRealtime()
|
attachRealtime()
|
||||||
loadCachedUnreadChatsCount()
|
loadCachedUnreadChatsCount()
|
||||||
refresh()
|
refresh()
|
||||||
@@ -47,7 +50,8 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
if (container.sessionStore.read()?.user?.id != ownerUserId) {
|
if (container.sessionStore.read()?.user?.id != ownerUserId) {
|
||||||
return@onSuccess
|
return@onSuccess
|
||||||
}
|
}
|
||||||
_uiState.value = MainMenuUiState(unreadChatsCount = countUnreadChats(chats))
|
latestChats = chats
|
||||||
|
updateUnreadChatsCount(chats)
|
||||||
cacheChatList(ownerUserId, chats)
|
cacheChatList(ownerUserId, chats)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
loadCachedUnreadChatsCount()
|
loadCachedUnreadChatsCount()
|
||||||
@@ -58,6 +62,7 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
fun clear() {
|
fun clear() {
|
||||||
debounceJob?.cancel()
|
debounceJob?.cancel()
|
||||||
refreshJob?.cancel()
|
refreshJob?.cancel()
|
||||||
|
latestChats = emptyList()
|
||||||
_uiState.value = MainMenuUiState()
|
_uiState.value = MainMenuUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +74,8 @@ class MainMenuViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
container.chatListCache.loadChats(ownerUserId)
|
container.chatListCache.loadChats(ownerUserId)
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
if (container.sessionStore.read()?.user?.id == ownerUserId) {
|
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() {
|
private fun scheduleRefresh() {
|
||||||
debounceJob?.cancel()
|
debounceJob?.cancel()
|
||||||
debounceJob = viewModelScope.launch {
|
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 =
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
@@ -52,6 +54,8 @@ fun ChatMembersScreen(
|
|||||||
onToggleCanPin: (ChatMemberDto) -> Unit,
|
onToggleCanPin: (ChatMemberDto) -> Unit,
|
||||||
onToggleCanDelete: (ChatMemberDto) -> Unit,
|
onToggleCanDelete: (ChatMemberDto) -> Unit,
|
||||||
onToggleCanInvite: (ChatMemberDto) -> Unit,
|
onToggleCanInvite: (ChatMemberDto) -> Unit,
|
||||||
|
onTransferOwnership: (ChatMemberDto) -> Unit,
|
||||||
|
onRemoveMember: (ChatMemberDto) -> Unit,
|
||||||
onMemberSearchChanged: (String) -> Unit,
|
onMemberSearchChanged: (String) -> Unit,
|
||||||
onAddMember: (UserSummaryDto) -> Unit,
|
onAddMember: (UserSummaryDto) -> Unit,
|
||||||
onAddAdmin: (UserSummaryDto) -> Unit,
|
onAddAdmin: (UserSummaryDto) -> Unit,
|
||||||
@@ -153,12 +157,19 @@ fun ChatMembersScreen(
|
|||||||
MemberPermissionRow(
|
MemberPermissionRow(
|
||||||
member = member,
|
member = member,
|
||||||
canManage = state.canManageMembers && !member.isOwner,
|
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),
|
isUpdating = state.updatingUserIds.contains(member.user.id),
|
||||||
onToggleAdmin = { onToggleAdmin(member) },
|
onToggleAdmin = { onToggleAdmin(member) },
|
||||||
onToggleCanPost = { onToggleCanPost(member) },
|
onToggleCanPost = { onToggleCanPost(member) },
|
||||||
onToggleCanPin = { onToggleCanPin(member) },
|
onToggleCanPin = { onToggleCanPin(member) },
|
||||||
onToggleCanDelete = { onToggleCanDelete(member) },
|
onToggleCanDelete = { onToggleCanDelete(member) },
|
||||||
onToggleCanInvite = { onToggleCanInvite(member) }
|
onToggleCanInvite = { onToggleCanInvite(member) },
|
||||||
|
onTransferOwnership = { onTransferOwnership(member) },
|
||||||
|
onRemoveMember = { onRemoveMember(member) }
|
||||||
)
|
)
|
||||||
if (index != members.lastIndex) {
|
if (index != members.lastIndex) {
|
||||||
IkarRowDivider(startIndent = 58.dp)
|
IkarRowDivider(startIndent = 58.dp)
|
||||||
@@ -298,15 +309,19 @@ private fun JoinRequestRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
private fun MemberPermissionRow(
|
private fun MemberPermissionRow(
|
||||||
member: ChatMemberDto,
|
member: ChatMemberDto,
|
||||||
canManage: Boolean,
|
canManage: Boolean,
|
||||||
|
canTransferOwnership: Boolean,
|
||||||
isUpdating: Boolean,
|
isUpdating: Boolean,
|
||||||
onToggleAdmin: () -> Unit,
|
onToggleAdmin: () -> Unit,
|
||||||
onToggleCanPost: () -> Unit,
|
onToggleCanPost: () -> Unit,
|
||||||
onToggleCanPin: () -> Unit,
|
onToggleCanPin: () -> Unit,
|
||||||
onToggleCanDelete: () -> Unit,
|
onToggleCanDelete: () -> Unit,
|
||||||
onToggleCanInvite: () -> Unit
|
onToggleCanInvite: () -> Unit,
|
||||||
|
onTransferOwnership: () -> Unit,
|
||||||
|
onRemoveMember: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -345,7 +360,18 @@ private fun MemberPermissionRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (canManage) {
|
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.canPostMessages, isUpdating, onToggleCanPost)
|
||||||
PermissionChip("закрепление", member.permissions.canPinMessages, isUpdating, onToggleCanPin)
|
PermissionChip("закрепление", member.permissions.canPinMessages, isUpdating, onToggleCanPin)
|
||||||
PermissionChip("удаление", member.permissions.canDeleteMessages, isUpdating, onToggleCanDelete)
|
PermissionChip("удаление", member.permissions.canDeleteMessages, isUpdating, onToggleCanDelete)
|
||||||
@@ -436,6 +462,7 @@ private fun moderationActionLabel(action: String): String = when (action) {
|
|||||||
"member.added" -> "Участник добавлен"
|
"member.added" -> "Участник добавлен"
|
||||||
"member.admin.added" -> "Администратор добавлен"
|
"member.admin.added" -> "Администратор добавлен"
|
||||||
"member.permissions.updated" -> "Права участника обновлены"
|
"member.permissions.updated" -> "Права участника обновлены"
|
||||||
|
"member.owner.transferred" -> "Владелец передан"
|
||||||
"message.deleted" -> "Сообщение удалено"
|
"message.deleted" -> "Сообщение удалено"
|
||||||
"message.pinned" -> "Сообщение закреплено"
|
"message.pinned" -> "Сообщение закреплено"
|
||||||
"message.unpinned" -> "Сообщение откреплено"
|
"message.unpinned" -> "Сообщение откреплено"
|
||||||
|
|||||||
+96
-1
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
data class ChatMembersUiState(
|
data class ChatMembersUiState(
|
||||||
val chat: ChatDetailsDto? = null,
|
val chat: ChatDetailsDto? = null,
|
||||||
|
val currentUserId: String? = null,
|
||||||
val joinRequests: List<ChatJoinRequestDto> = emptyList(),
|
val joinRequests: List<ChatJoinRequestDto> = emptyList(),
|
||||||
val moderationLog: List<ModerationLogDto> = emptyList(),
|
val moderationLog: List<ModerationLogDto> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -36,8 +37,26 @@ data class ChatMembersUiState(
|
|||||||
val canManageMembers: Boolean
|
val canManageMembers: Boolean
|
||||||
get() = chat?.currentUserPermissions?.canManageMembers == true
|
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
|
val canAddMembers: Boolean
|
||||||
get() = canManageMembers && chat?.type == ChatType.Channel
|
get() = canManageMembers && when (chat?.type) {
|
||||||
|
ChatType.Group,
|
||||||
|
ChatType.Channel -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatMembersViewModel(
|
class ChatMembersViewModel(
|
||||||
@@ -61,6 +80,7 @@ class ChatMembersViewModel(
|
|||||||
val canManageMembers = chat.currentUserPermissions?.canManageMembers == true
|
val canManageMembers = chat.currentUserPermissions?.canManageMembers == true
|
||||||
ChatMembersPayload(
|
ChatMembersPayload(
|
||||||
chat = chat,
|
chat = chat,
|
||||||
|
currentUserId = session.user.id,
|
||||||
joinRequests = if (canManageMembers) {
|
joinRequests = if (canManageMembers) {
|
||||||
container.apiClient.getChatJoinRequests(session.accessToken, chatId)
|
container.apiClient.getChatJoinRequests(session.accessToken, chatId)
|
||||||
} else {
|
} else {
|
||||||
@@ -76,6 +96,7 @@ class ChatMembersViewModel(
|
|||||||
}.onSuccess { payload ->
|
}.onSuccess { payload ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
chat = payload.chat,
|
chat = payload.chat,
|
||||||
|
currentUserId = payload.currentUserId,
|
||||||
joinRequests = payload.joinRequests,
|
joinRequests = payload.joinRequests,
|
||||||
moderationLog = payload.moderationLog,
|
moderationLog = payload.moderationLog,
|
||||||
memberSearchResults = filterMemberSearchResults(_uiState.value.memberSearchResults, payload.chat),
|
memberSearchResults = filterMemberSearchResults(_uiState.value.memberSearchResults, payload.chat),
|
||||||
@@ -182,6 +203,79 @@ class ChatMembersViewModel(
|
|||||||
updateMember(member, canInviteUsers = !member.permissions.canInviteUsers)
|
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(
|
private fun updateMember(
|
||||||
member: ChatMemberDto,
|
member: ChatMemberDto,
|
||||||
role: ChatMemberRole = member.role,
|
role: ChatMemberRole = member.role,
|
||||||
@@ -289,6 +383,7 @@ class ChatMembersViewModel(
|
|||||||
|
|
||||||
private data class ChatMembersPayload(
|
private data class ChatMembersPayload(
|
||||||
val chat: ChatDetailsDto,
|
val chat: ChatDetailsDto,
|
||||||
|
val currentUserId: String,
|
||||||
val joinRequests: List<ChatJoinRequestDto>,
|
val joinRequests: List<ChatJoinRequestDto>,
|
||||||
val moderationLog: List<ModerationLogDto>
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.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 androidx.compose.ui.unit.dp
|
||||||
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
|
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.IkarRowDivider
|
||||||
import com.seven.ikar.kotlin.ui.IkarScreenSurface
|
import com.seven.ikar.kotlin.ui.IkarScreenSurface
|
||||||
import com.seven.ikar.kotlin.ui.IkarSectionCard
|
import com.seven.ikar.kotlin.ui.IkarSectionCard
|
||||||
@@ -33,11 +57,19 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
|
|||||||
fun PrivacySecurityScreen(
|
fun PrivacySecurityScreen(
|
||||||
state: PrivacySecurityUiState,
|
state: PrivacySecurityUiState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onRefreshClick: () -> Unit,
|
onUpdate: ((PrivacySettingsDto) -> PrivacySettingsDto) -> Unit,
|
||||||
onBlockedUsersClick: () -> Unit,
|
onUserSearchQueryChanged: (String) -> Unit,
|
||||||
onExceptionsClick: () -> Unit,
|
onBlockUser: (UserSummaryDto) -> Unit,
|
||||||
onUpdate: ((PrivacySettingsDto) -> PrivacySettingsDto) -> 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()) {
|
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -58,41 +90,351 @@ fun PrivacySecurityScreen(
|
|||||||
if (settings != null) {
|
if (settings != null) {
|
||||||
IkarSectionCard {
|
IkarSectionCard {
|
||||||
IkarSectionTitle("Конфиденциальность", modifier = Modifier.padding(bottom = 8.dp))
|
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)) }
|
onUpdate { it.copy(phoneNumberVisibility = nextVisibility(it.phoneNumberVisibility)) }
|
||||||
}
|
}
|
||||||
IkarRowDivider()
|
IkarRowDivider()
|
||||||
PrivacyRow("Последняя активность и онлайн", privacyValueLabel(settings.lastSeenVisibility)) {
|
PrivacyRow("Последняя активность и онлайн", privacyValueLabel(settings.lastSeenVisibility), Icons.Outlined.Schedule) {
|
||||||
onUpdate { it.copy(lastSeenVisibility = nextVisibility(it.lastSeenVisibility)) }
|
onUpdate { it.copy(lastSeenVisibility = nextVisibility(it.lastSeenVisibility)) }
|
||||||
}
|
}
|
||||||
IkarRowDivider()
|
IkarRowDivider()
|
||||||
PrivacyRow("Фотографии профиля", privacyValueLabel(settings.profilePhotoVisibility)) {
|
PrivacyRow("Фотографии профиля", privacyValueLabel(settings.profilePhotoVisibility), Icons.Outlined.Image) {
|
||||||
onUpdate { it.copy(profilePhotoVisibility = nextVisibility(it.profilePhotoVisibility)) }
|
onUpdate { it.copy(profilePhotoVisibility = nextVisibility(it.profilePhotoVisibility)) }
|
||||||
}
|
}
|
||||||
IkarRowDivider()
|
IkarRowDivider()
|
||||||
PrivacyRow("Заблокированные пользователи", "${settings.blockedUserIds.size}") {
|
PrivacyRow("Заблокированные пользователи", "${settings.blockedUserIds.size}", Icons.Outlined.Block) {
|
||||||
onBlockedUsersClick()
|
listDialogMode = PrivacyListDialogMode.Blocked
|
||||||
}
|
}
|
||||||
IkarRowDivider()
|
IkarRowDivider()
|
||||||
PrivacyRow("Исключения", "${settings.alwaysAllowUserIds.size + settings.neverAllowUserIds.size}") {
|
PrivacyRow("Исключения", "${settings.alwaysAllowUserIds.size + settings.neverAllowUserIds.size}", Icons.Outlined.Security) {
|
||||||
onExceptionsClick()
|
listDialogMode = PrivacyListDialogMode.Exceptions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IkarSectionCard {
|
IkarSectionCard {
|
||||||
IkarSectionTitle("Безопасность", modifier = Modifier.padding(bottom = 8.dp))
|
IkarSectionTitle("Безопасность", modifier = Modifier.padding(bottom = 8.dp))
|
||||||
PrivacyRow("Код-пароль", if (settings.passcodeLockEnabled) "включено" else "выключено") {
|
PrivacyRow("Код-пароль", if (settings.passcodeLockEnabled) "включено" else "выключено", Icons.Outlined.Lock) {
|
||||||
onUpdate { it.copy(passcodeLockEnabled = !it.passcodeLockEnabled) }
|
passcodeDialogMode = if (settings.passcodeLockEnabled) {
|
||||||
|
PasscodeDialogMode.Disable
|
||||||
|
} else {
|
||||||
|
PasscodeDialogMode.Enable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
IkarRowDivider()
|
IkarRowDivider()
|
||||||
PrivacyRow("Предпросмотр сообщений", if (settings.showMessagePreview) "включено" else "выключено") {
|
PrivacyRow("Предпросмотр сообщений", if (settings.showMessagePreview) "включено" else "выключено", Icons.Outlined.Visibility) {
|
||||||
onUpdate { it.copy(showMessagePreview = !it.showMessagePreview) }
|
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(
|
private fun PrivacyRow(
|
||||||
title: String,
|
title: String,
|
||||||
value: String,
|
value: String,
|
||||||
onClick: () -> Unit
|
icon: ImageVector,
|
||||||
|
onClick: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
IkarSettingRow(
|
IkarSettingRow(
|
||||||
title = title,
|
title = title,
|
||||||
subtitle = "Сейчас: $value",
|
subtitle = "Сейчас: $value",
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
leading = {
|
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.app.AppContainer
|
||||||
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
|
import com.seven.ikar.kotlin.core.model.PrivacySettingsDto
|
||||||
import com.seven.ikar.kotlin.core.model.UpdatePrivacySettingsRequest
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -12,6 +15,9 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
data class PrivacySecurityUiState(
|
data class PrivacySecurityUiState(
|
||||||
val settings: PrivacySettingsDto? = null,
|
val settings: PrivacySettingsDto? = null,
|
||||||
|
val userSearchQuery: String = "",
|
||||||
|
val userSearchResults: List<UserSummaryDto> = emptyList(),
|
||||||
|
val isSearchingUsers: Boolean = false,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val errorMessage: String? = null
|
val errorMessage: String? = null
|
||||||
@@ -19,6 +25,7 @@ data class PrivacySecurityUiState(
|
|||||||
|
|
||||||
class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel() {
|
class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(PrivacySecurityUiState())
|
private val _uiState = MutableStateFlow(PrivacySecurityUiState())
|
||||||
|
private var userSearchJob: Job? = null
|
||||||
val uiState: StateFlow<PrivacySecurityUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PrivacySecurityUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -44,6 +51,99 @@ class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel(
|
|||||||
fun update(transform: (PrivacySettingsDto) -> PrivacySettingsDto) {
|
fun update(transform: (PrivacySettingsDto) -> PrivacySettingsDto) {
|
||||||
val current = _uiState.value.settings ?: return
|
val current = _uiState.value.settings ?: return
|
||||||
val next = transform(current)
|
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 {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(settings = next, isSaving = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(settings = next, isSaving = true, errorMessage = null)
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -63,6 +163,7 @@ class PrivacySecurityViewModel(private val container: AppContainer) : ViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess { saved ->
|
}.onSuccess { saved ->
|
||||||
|
afterSuccess()
|
||||||
container.privacyRuntimeSettings.saveFrom(saved)
|
container.privacyRuntimeSettings.saveFrom(saved)
|
||||||
_uiState.value = _uiState.value.copy(settings = saved, isSaving = false)
|
_uiState.value = _uiState.value.copy(settings = saved, isSaving = false)
|
||||||
}.onFailure { error ->
|
}.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) {
|
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.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.seven.ikar.kotlin.ui.AvatarView
|
import com.seven.ikar.kotlin.ui.AvatarView
|
||||||
import com.seven.ikar.kotlin.ui.IkarSectionCard
|
import com.seven.ikar.kotlin.ui.IkarSectionCard
|
||||||
@@ -50,7 +51,6 @@ fun AccountSettingsScreen(
|
|||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onDisplayNameChanged: (String) -> Unit,
|
onDisplayNameChanged: (String) -> Unit,
|
||||||
onAboutChanged: (String) -> Unit,
|
onAboutChanged: (String) -> Unit,
|
||||||
onSaveClick: () -> Unit,
|
|
||||||
onDeleteAvatarClick: () -> Unit,
|
onDeleteAvatarClick: () -> Unit,
|
||||||
onAvatarSelected: (Uri) -> Unit
|
onAvatarSelected: (Uri) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -103,7 +103,9 @@ fun AccountSettingsScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Нажмите на надпись, чтобы удалить текущее фото",
|
text = "Нажмите на надпись, чтобы удалить текущее фото",
|
||||||
color = TelegramInkMuted,
|
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)) {
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text("Добавить личный канал", style = MaterialTheme.typography.titleLarge)
|
|
||||||
Text(
|
Text(
|
||||||
"Этот сценарий будет доведён отдельным экраном. Сейчас основной профиль уже перенесён.",
|
"Добавить личный канал",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Отдельный экран будет добавлен позже.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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 {
|
state.errorMessage?.let {
|
||||||
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium)
|
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
|
if (state.isSaving) {
|
||||||
|
Text("Сохраняется...", color = TelegramInkMuted, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
state.infoMessage?.let {
|
state.infoMessage?.let {
|
||||||
Text(it, color = TelegramBlue, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
Text(it, color = TelegramBlue, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
@@ -220,8 +224,19 @@ private fun IkarInfoLine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(title, style = MaterialTheme.typography.titleLarge)
|
Text(
|
||||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted)
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class ProfileUiState(
|
data class ProfileUiState(
|
||||||
@@ -28,17 +30,20 @@ data class ProfileUiState(
|
|||||||
class ProfileViewModel(private val container: AppContainer) : ViewModel() {
|
class ProfileViewModel(private val container: AppContainer) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(ProfileUiState(session = container.sessionStore.read()))
|
private val _uiState = MutableStateFlow(ProfileUiState(session = container.sessionStore.read()))
|
||||||
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
|
||||||
|
private var autosaveJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateDisplayName(value: String) {
|
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) {
|
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() {
|
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
|
val state = _uiState.value
|
||||||
if (state.displayName.isBlank()) {
|
if (state.displayName.isBlank()) {
|
||||||
_uiState.value = state.copy(errorMessage = "Укажите отображаемое имя.")
|
_uiState.value = state.copy(errorMessage = "Укажите отображаемое имя.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val submittedDisplayName = state.displayName.trim()
|
||||||
|
val submittedAbout = state.about.trim()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = state.copy(isSaving = true, errorMessage = null, infoMessage = null)
|
_uiState.value = state.copy(isSaving = true, errorMessage = null, infoMessage = null)
|
||||||
runCatching {
|
runCatching {
|
||||||
container.executeAuthorized { session ->
|
container.executeAuthorized { session ->
|
||||||
session to container.apiClient.updateMyProfile(
|
session to container.apiClient.updateMyProfile(
|
||||||
accessToken = session.accessToken,
|
accessToken = session.accessToken,
|
||||||
displayName = state.displayName.trim(),
|
displayName = submittedDisplayName,
|
||||||
about = state.about.trim().ifBlank { null }
|
about = submittedAbout.ifBlank { null }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess { (session, user) ->
|
}.onSuccess { (session, user) ->
|
||||||
applyUser(session, user, infoMessage = "Профиль сохранён.")
|
applySavedProfile(session, user, submittedDisplayName, submittedAbout)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
|
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
|
||||||
}
|
}
|
||||||
@@ -143,4 +158,34 @@ class ProfileViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
infoMessage = infoMessage
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -180,6 +181,7 @@ fun GlobalSearchScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
private fun GlobalSearchAdvancedFilters(
|
private fun GlobalSearchAdvancedFilters(
|
||||||
selectedDate: GlobalSearchDateFilter,
|
selectedDate: GlobalSearchDateFilter,
|
||||||
selectedType: GlobalSearchResultType,
|
selectedType: GlobalSearchResultType,
|
||||||
@@ -189,7 +191,10 @@ private fun GlobalSearchAdvancedFilters(
|
|||||||
onAuthorFilterChanged: (String) -> Unit
|
onAuthorFilterChanged: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
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 ->
|
GlobalSearchDateFilter.entries.forEach { filter ->
|
||||||
TextButton(onClick = { onDateFilterChanged(filter) }) {
|
TextButton(onClick = { onDateFilterChanged(filter) }) {
|
||||||
Text(
|
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 ->
|
GlobalSearchResultType.entries.forEach { type ->
|
||||||
TextButton(onClick = { onResultTypeChanged(type) }) {
|
TextButton(onClick = { onResultTypeChanged(type) }) {
|
||||||
Text(
|
Text(
|
||||||
@@ -220,11 +228,15 @@ private fun GlobalSearchAdvancedFilters(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
private fun GlobalSearchFilterRow(
|
private fun GlobalSearchFilterRow(
|
||||||
selected: GlobalSearchFilter,
|
selected: GlobalSearchFilter,
|
||||||
onFilterChanged: (GlobalSearchFilter) -> Unit
|
onFilterChanged: (GlobalSearchFilter) -> Unit
|
||||||
) {
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
GlobalSearchFilter.entries.forEach { filter ->
|
GlobalSearchFilter.entries.forEach { filter ->
|
||||||
TextButton(onClick = { onFilterChanged(filter) }) {
|
TextButton(onClick = { onFilterChanged(filter) }) {
|
||||||
Text(
|
Text(
|
||||||
@@ -261,6 +273,19 @@ private fun GlobalSearchResultRow(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
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(
|
||||||
text = highlightedText(result.matchText.ifBlank { chatPreview(result.chat) }, query),
|
text = highlightedText(result.matchText.ifBlank { chatPreview(result.chat) }, query),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -276,18 +301,6 @@ private fun GlobalSearchResultRow(
|
|||||||
overflow = TextOverflow.Ellipsis
|
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) {
|
when (this) {
|
||||||
GlobalSearchResultType.All -> "Все типы"
|
GlobalSearchResultType.All -> "Все типы"
|
||||||
GlobalSearchResultType.Chats -> "Чаты"
|
GlobalSearchResultType.Chats -> "Чаты"
|
||||||
|
GlobalSearchResultType.Channels -> "Каналы"
|
||||||
|
GlobalSearchResultType.Groups -> "Группы"
|
||||||
GlobalSearchResultType.Messages -> "Сообщения"
|
GlobalSearchResultType.Messages -> "Сообщения"
|
||||||
GlobalSearchResultType.Attachments -> "Вложения"
|
GlobalSearchResultType.Attachments -> "Вложения"
|
||||||
GlobalSearchResultType.Bots -> "Боты"
|
GlobalSearchResultType.Bots -> "Боты"
|
||||||
GlobalSearchResultType.GroupCalls -> "Звонки"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ChatSearchResultDto.botMiniAppDeepLink(): String? {
|
private fun formatChannelSubscribers(count: Int): String =
|
||||||
val bot = chat.participants.firstOrNull { it.isBot } ?: return null
|
when {
|
||||||
return "ikar://bot/${bot.username}?start=miniapp"
|
count == 1 -> "1 подписчик"
|
||||||
}
|
count in 2..4 -> "$count подписчика"
|
||||||
|
else -> "$count подписчиков"
|
||||||
|
}
|
||||||
|
|||||||
+6
-12
@@ -32,19 +32,13 @@ enum class GlobalSearchDateFilter {
|
|||||||
enum class GlobalSearchResultType {
|
enum class GlobalSearchResultType {
|
||||||
All,
|
All,
|
||||||
Chats,
|
Chats,
|
||||||
|
Channels,
|
||||||
|
Groups,
|
||||||
Messages,
|
Messages,
|
||||||
Attachments,
|
Attachments,
|
||||||
Bots,
|
Bots
|
||||||
GroupCalls
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SearchCapabilityHooks(
|
|
||||||
val supportsMediaDateGrouping: Boolean = true,
|
|
||||||
val supportsBotMiniAppPlaceholder: Boolean = true,
|
|
||||||
val supportsScheduledMessageOptions: Boolean = true,
|
|
||||||
val supportsGroupCallPlaceholder: Boolean = true
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GlobalSearchUiState(
|
data class GlobalSearchUiState(
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val filter: GlobalSearchFilter = GlobalSearchFilter.All,
|
val filter: GlobalSearchFilter = GlobalSearchFilter.All,
|
||||||
@@ -56,8 +50,7 @@ data class GlobalSearchUiState(
|
|||||||
val hasMore: Boolean = false,
|
val hasMore: Boolean = false,
|
||||||
val isSearching: Boolean = false,
|
val isSearching: Boolean = false,
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null
|
||||||
val hooks: SearchCapabilityHooks = SearchCapabilityHooks()
|
|
||||||
) {
|
) {
|
||||||
val visibleResults: List<ChatSearchResultDto>
|
val visibleResults: List<ChatSearchResultDto>
|
||||||
get() = results.filter { result ->
|
get() = results.filter { result ->
|
||||||
@@ -214,10 +207,11 @@ private fun ChatSearchResultDto.matchesType(type: GlobalSearchResultType): Boole
|
|||||||
when (type) {
|
when (type) {
|
||||||
GlobalSearchResultType.All -> true
|
GlobalSearchResultType.All -> true
|
||||||
GlobalSearchResultType.Chats -> message == null
|
GlobalSearchResultType.Chats -> message == null
|
||||||
|
GlobalSearchResultType.Channels -> chat.type == ChatType.Channel
|
||||||
|
GlobalSearchResultType.Groups -> chat.type == ChatType.Group
|
||||||
GlobalSearchResultType.Messages -> message != null
|
GlobalSearchResultType.Messages -> message != null
|
||||||
GlobalSearchResultType.Attachments -> message?.attachments?.isNotEmpty() == true
|
GlobalSearchResultType.Attachments -> message?.attachments?.isNotEmpty() == true
|
||||||
GlobalSearchResultType.Bots -> chat.participants.any { it.isBot }
|
GlobalSearchResultType.Bots -> chat.participants.any { it.isBot }
|
||||||
GlobalSearchResultType.GroupCalls -> chat.type == ChatType.Group
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun ChatSearchResultDto.searchDateGroupLabel(): String =
|
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.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
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
|
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PrivacySettingsV2Screen(
|
fun ThemeSettingsScreen(
|
||||||
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(
|
|
||||||
state: AppSettingsUiState,
|
state: AppSettingsUiState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit,
|
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit,
|
||||||
@@ -138,14 +84,21 @@ fun ThemePlaceholderScreen(
|
|||||||
onCheckedChange = { value -> onSnapshotChanged { it.copy(largeEmoji = value) } }
|
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
|
@Composable
|
||||||
fun LanguagePlaceholderScreen(
|
fun LanguageSettingsScreen(
|
||||||
state: AppSettingsUiState,
|
state: AppSettingsUiState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit
|
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit
|
||||||
@@ -167,9 +120,9 @@ fun LanguagePlaceholderScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
IkarSectionCard {
|
IkarSectionCard {
|
||||||
Text("Локализация сохранена", style = MaterialTheme.typography.titleMedium, color = TelegramBlue)
|
Text("Локализация применяется сразу", style = MaterialTheme.typography.titleMedium, color = TelegramBlue)
|
||||||
Text(
|
Text(
|
||||||
"Значение уже хранится локально. Для полного эффекта нужно применить его в базовом контексте приложения или слое локали Compose.",
|
"Выбранный язык сохраняется локально и сразу передается в контекст приложения. Системный режим возвращает язык устройства.",
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = TelegramInkMuted
|
color = TelegramInkMuted
|
||||||
@@ -179,7 +132,7 @@ fun LanguagePlaceholderScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProxyPlaceholderScreen(
|
fun ProxySettingsScreen(
|
||||||
state: AppSettingsUiState,
|
state: AppSettingsUiState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit
|
onSnapshotChanged: ((AppSettingsSnapshot) -> AppSettingsSnapshot) -> Unit
|
||||||
@@ -189,14 +142,14 @@ fun ProxyPlaceholderScreen(
|
|||||||
IkarSectionCard {
|
IkarSectionCard {
|
||||||
IkarToggleRow(
|
IkarToggleRow(
|
||||||
"Использовать прокси",
|
"Использовать прокси",
|
||||||
"Профиль сохраняется локально и готов для подключения к OkHttp.",
|
"Применяется к новым сетевым запросам сразу после изменения.",
|
||||||
snapshot.proxyEnabled,
|
snapshot.proxyEnabled,
|
||||||
onCheckedChange = { value -> onSnapshotChanged { it.copy(proxyEnabled = value) } }
|
onCheckedChange = { value -> onSnapshotChanged { it.copy(proxyEnabled = value) } }
|
||||||
)
|
)
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarValueRow(
|
IkarValueRow(
|
||||||
"Тип",
|
"Тип",
|
||||||
"SOCKS5, MTProto или HTTP.",
|
"SOCKS5 или HTTP.",
|
||||||
snapshot.proxyType.proxyTypeTitle(),
|
snapshot.proxyType.proxyTypeTitle(),
|
||||||
onClick = { onSnapshotChanged { it.copy(proxyType = nextProxyType(it.proxyType)) } }
|
onClick = { onSnapshotChanged { it.copy(proxyType = nextProxyType(it.proxyType)) } }
|
||||||
)
|
)
|
||||||
@@ -224,15 +177,6 @@ fun ProxyPlaceholderScreen(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
label = { Text("Порт") }
|
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) {
|
private fun AppThemeMode.themeTitle(): String = when (this) {
|
||||||
AppThemeMode.System -> "Как в системе"
|
AppThemeMode.System -> "Как в системе"
|
||||||
AppThemeMode.Light -> "Дневная"
|
AppThemeMode.Light -> "Дневная"
|
||||||
@@ -349,12 +274,12 @@ private fun nextAccent(current: String): String = when (current) {
|
|||||||
|
|
||||||
private fun AppProxyType.proxyTypeTitle(): String = when (this) {
|
private fun AppProxyType.proxyTypeTitle(): String = when (this) {
|
||||||
AppProxyType.Socks5 -> "SOCKS5"
|
AppProxyType.Socks5 -> "SOCKS5"
|
||||||
AppProxyType.MtProto -> "MTProto"
|
AppProxyType.MtProto -> "SOCKS5"
|
||||||
AppProxyType.Http -> "HTTP"
|
AppProxyType.Http -> "HTTP"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun nextProxyType(current: AppProxyType): AppProxyType = when (current) {
|
private fun nextProxyType(current: AppProxyType): AppProxyType = when (current) {
|
||||||
AppProxyType.Socks5 -> AppProxyType.MtProto
|
AppProxyType.Socks5 -> AppProxyType.Http
|
||||||
AppProxyType.MtProto -> AppProxyType.Http
|
AppProxyType.MtProto -> AppProxyType.Socks5
|
||||||
AppProxyType.Http -> 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.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -27,12 +26,12 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.seven.ikar.kotlin.ui.ChatWallpaperBackground
|
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.ChatSettingsSnapshot
|
||||||
|
import com.seven.ikar.kotlin.core.settings.ChatSwipeLeftActionOption
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Image
|
import androidx.compose.material.icons.outlined.Image
|
||||||
import androidx.compose.material3.Icon
|
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 {
|
ChatSettingsSection {
|
||||||
IkarSectionTitle("Медиафайлы и звук", modifier = Modifier.padding(bottom = 12.dp))
|
IkarSectionTitle("Медиафайлы", modifier = Modifier.padding(bottom = 12.dp))
|
||||||
IkarToggleRow(
|
IkarToggleRow(
|
||||||
title = "Листать медиафайлы по нажатию",
|
title = "Листать медиафайлы по нажатию",
|
||||||
subtitle = "Листать нажатием у края экрана в режиме просмотра медиафайлов.",
|
subtitle = "Листать нажатием у края экрана в режиме просмотра медиафайлов.",
|
||||||
@@ -120,93 +140,38 @@ fun ChatSettingsScreen(
|
|||||||
)
|
)
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarToggleRow(
|
IkarToggleRow(
|
||||||
title = "Поднести и слушать",
|
title = "Пауза музыки при запуске аудио",
|
||||||
subtitle = "Переключаться на верхний динамик, когда вы подносите телефон к уху.",
|
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 = "Останавливать фоновую музыку при воспроизведении медиафайлов в чате.",
|
|
||||||
checked = snapshot.pauseMusicOnPlaybackEnabled,
|
checked = snapshot.pauseMusicOnPlaybackEnabled,
|
||||||
onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnPlaybackEnabled = it)) }
|
onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnPlaybackEnabled = it)) }
|
||||||
)
|
)
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarValueRow(
|
IkarToggleRow(
|
||||||
title = "Микрофон",
|
title = "Пауза музыки при записи",
|
||||||
subtitle = "Источник записи голосовых сообщений.",
|
subtitle = "Ставить фоновые плееры на паузу при записи голосового сообщения.",
|
||||||
value = "По умолчанию",
|
checked = snapshot.pauseMusicOnRecordEnabled,
|
||||||
enabled = false
|
onCheckedChange = { onSnapshotChanged(snapshot.copy(pauseMusicOnRecordEnabled = it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatSettingsSection {
|
ChatSettingsSection {
|
||||||
IkarSectionTitle("Прочие настройки", modifier = Modifier.padding(bottom = 12.dp))
|
IkarSectionTitle("Ссылки", modifier = Modifier.padding(bottom = 12.dp))
|
||||||
IkarToggleRow(
|
|
||||||
title = "Смена темы ночью",
|
|
||||||
subtitle = "Автоматически включать ночную тему по локальному сценарию.",
|
|
||||||
checked = snapshot.autoNightThemeEnabled,
|
|
||||||
onCheckedChange = { onSnapshotChanged(snapshot.copy(autoNightThemeEnabled = it)) }
|
|
||||||
)
|
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
|
||||||
IkarToggleRow(
|
IkarToggleRow(
|
||||||
title = "Встроенный браузер",
|
title = "Встроенный браузер",
|
||||||
subtitle = "Открывать ссылки внутри приложения, если доступен встроенный просмотр.",
|
subtitle = "Открывать ссылки из сообщений внутри Икара.",
|
||||||
checked = snapshot.inAppBrowserEnabled,
|
checked = snapshot.inAppBrowserEnabled,
|
||||||
onCheckedChange = { onSnapshotChanged(snapshot.copy(inAppBrowserEnabled = it)) }
|
onCheckedChange = { onSnapshotChanged(snapshot.copy(inAppBrowserEnabled = it)) }
|
||||||
)
|
)
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
}
|
||||||
IkarToggleRow(
|
|
||||||
title = "Анимации",
|
ChatSettingsSection {
|
||||||
subtitle = "Использовать анимации интерфейса и медиа-переходов.",
|
IkarSectionTitle("Отправка", modifier = Modifier.padding(bottom = 12.dp))
|
||||||
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)
|
|
||||||
IkarToggleRow(
|
IkarToggleRow(
|
||||||
title = "Отправка по Вводу",
|
title = "Отправка по Вводу",
|
||||||
subtitle = "Использовать действие отправки вместо перевода строки, если клавиатура поддерживает «Отправить».",
|
subtitle = "Использовать действие отправки вместо перевода строки, если клавиатура поддерживает «Отправить».",
|
||||||
checked = snapshot.sendByEnterEnabled,
|
checked = snapshot.sendByEnterEnabled,
|
||||||
onCheckedChange = { onSnapshotChanged(snapshot.copy(sendByEnterEnabled = it)) }
|
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(
|
Text(
|
||||||
@@ -293,6 +258,7 @@ private fun ChatStylePreview(
|
|||||||
) {
|
) {
|
||||||
ChatWallpaperBackground(
|
ChatWallpaperBackground(
|
||||||
snapshot = wallpaperSnapshot,
|
snapshot = wallpaperSnapshot,
|
||||||
|
animated = wallpaperSnapshot.animationsEnabled,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -333,26 +299,28 @@ private fun ChatStylePreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private val ChatListLayoutOption.title: String
|
||||||
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
|
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
ChatDistanceUnitOption.Automatic -> "Автоматически"
|
ChatListLayoutOption.TwoLine -> "2 строки"
|
||||||
ChatDistanceUnitOption.Kilometers -> "Километры"
|
ChatListLayoutOption.ThreeLine -> "3 строки"
|
||||||
ChatDistanceUnitOption.Miles -> "Мили"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ChatDistanceUnitOption.next(): ChatDistanceUnitOption = when (this) {
|
private fun ChatListLayoutOption.next(): ChatListLayoutOption = when (this) {
|
||||||
ChatDistanceUnitOption.Automatic -> ChatDistanceUnitOption.Kilometers
|
ChatListLayoutOption.TwoLine -> ChatListLayoutOption.ThreeLine
|
||||||
ChatDistanceUnitOption.Kilometers -> ChatDistanceUnitOption.Miles
|
ChatListLayoutOption.ThreeLine -> ChatListLayoutOption.TwoLine
|
||||||
ChatDistanceUnitOption.Miles -> ChatDistanceUnitOption.Automatic
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
package com.seven.ikar.kotlin.feature.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
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.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.IkarSimpleHeader
|
||||||
import com.seven.ikar.kotlin.ui.IkarToggleRow
|
import com.seven.ikar.kotlin.ui.IkarToggleRow
|
||||||
import com.seven.ikar.kotlin.ui.IkarValueRow
|
import com.seven.ikar.kotlin.ui.IkarValueRow
|
||||||
|
import com.seven.ikar.kotlin.ui.theme.TelegramBlue
|
||||||
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
|
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -52,19 +53,19 @@ fun DataStorageSettingsScreen(
|
|||||||
IkarSectionTitle("Использование памяти", modifier = Modifier.padding(bottom = 12.dp))
|
IkarSectionTitle("Использование памяти", modifier = Modifier.padding(bottom = 12.dp))
|
||||||
IkarValueRow(
|
IkarValueRow(
|
||||||
"Кэш Ikar",
|
"Кэш Ikar",
|
||||||
"Лимит локальных медиа и превью на устройстве.",
|
"Лимит локальных медиа.",
|
||||||
"${snapshot.maxCacheMb} МБ",
|
"${snapshot.maxCacheMb} МБ",
|
||||||
onClick = { onSnapshotChanged { it.copy(maxCacheMb = nextCacheLimit(it.maxCacheMb)) } }
|
onClick = { onSnapshotChanged { it.copy(maxCacheMb = nextCacheLimit(it.maxCacheMb)) } }
|
||||||
)
|
)
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarValueRow(
|
IkarValueRow(
|
||||||
"Хранить медиа",
|
"Хранить медиа",
|
||||||
"Срок хранения локальных файлов перед LRU-очисткой.",
|
"Срок хранения файлов.",
|
||||||
"${snapshot.keepMediaDays} дн.",
|
"${snapshot.keepMediaDays} дн.",
|
||||||
onClick = { onSnapshotChanged { it.copy(keepMediaDays = nextKeepDays(it.keepMediaDays)) } }
|
onClick = { onSnapshotChanged { it.copy(keepMediaDays = nextKeepDays(it.keepMediaDays)) } }
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Параметры сохраняются локально и готовы для подключения к TransferQueue/LRU-очистке.",
|
"Лимит и срок хранения применяются сразу к локальному кэшу медиафайлов.",
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = TelegramInkMuted
|
color = TelegramInkMuted
|
||||||
@@ -75,28 +76,28 @@ fun DataStorageSettingsScreen(
|
|||||||
IkarSectionTitle("Автозагрузка медиа", modifier = Modifier.padding(bottom = 12.dp))
|
IkarSectionTitle("Автозагрузка медиа", modifier = Modifier.padding(bottom = 12.dp))
|
||||||
IkarToggleRow(
|
IkarToggleRow(
|
||||||
"Фото",
|
"Фото",
|
||||||
"Автоматически загружать фото в чатах.",
|
"Автозагрузка фото.",
|
||||||
snapshot.autoDownloadPhotos,
|
snapshot.autoDownloadPhotos,
|
||||||
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadPhotos = value) } }
|
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadPhotos = value) } }
|
||||||
)
|
)
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarToggleRow(
|
IkarToggleRow(
|
||||||
"Видео",
|
"Видео",
|
||||||
"Автоматически загружать видео только по Wi-Fi.",
|
"Видео только по Wi-Fi.",
|
||||||
snapshot.autoDownloadVideosOnWifi,
|
snapshot.autoDownloadVideosOnWifi,
|
||||||
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadVideosOnWifi = value) } }
|
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadVideosOnWifi = value) } }
|
||||||
)
|
)
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarToggleRow(
|
IkarToggleRow(
|
||||||
"Файлы",
|
"Файлы",
|
||||||
"Автозагрузка документов только по Wi-Fi.",
|
"Документы только по Wi-Fi.",
|
||||||
snapshot.autoDownloadFilesOnWifi,
|
snapshot.autoDownloadFilesOnWifi,
|
||||||
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadFilesOnWifi = value) } }
|
onCheckedChange = { value -> onSnapshotChanged { it.copy(autoDownloadFilesOnWifi = value) } }
|
||||||
)
|
)
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarToggleRow(
|
IkarToggleRow(
|
||||||
"Сохранять в галерею",
|
"Сохранять фото в галерею",
|
||||||
"Добавлять новые медиа в системную галерею.",
|
"Загруженные изображения сразу появляются в альбоме Ikar.",
|
||||||
snapshot.saveMediaToGallery,
|
snapshot.saveMediaToGallery,
|
||||||
onCheckedChange = { value -> onSnapshotChanged { it.copy(saveMediaToGallery = value) } }
|
onCheckedChange = { value -> onSnapshotChanged { it.copy(saveMediaToGallery = value) } }
|
||||||
)
|
)
|
||||||
@@ -104,27 +105,23 @@ fun DataStorageSettingsScreen(
|
|||||||
|
|
||||||
IkarSectionCard {
|
IkarSectionCard {
|
||||||
IkarSectionTitle("Сеть", modifier = Modifier.padding(bottom = 12.dp))
|
IkarSectionTitle("Сеть", modifier = Modifier.padding(bottom = 12.dp))
|
||||||
IkarToggleRow(
|
|
||||||
"Меньше данных для звонков",
|
|
||||||
"Снижать битрейт звонков в мобильной сети.",
|
|
||||||
snapshot.lowDataCalls,
|
|
||||||
onCheckedChange = { value -> onSnapshotChanged { it.copy(lowDataCalls = value) } }
|
|
||||||
)
|
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
|
||||||
IkarValueRow(
|
IkarValueRow(
|
||||||
"Прокси",
|
"Прокси",
|
||||||
"SOCKS/MTProto/HTTP профиль для сетевого слоя.",
|
"SOCKS5/HTTP профиль.",
|
||||||
if (snapshot.proxyEnabled) "Вкл." else "Выкл.",
|
if (snapshot.proxyEnabled) "Вкл." else "Выкл.",
|
||||||
onClick = onProxyClick
|
onClick = onProxyClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(onClick = onResetClick, modifier = Modifier.fillMaxWidth()) {
|
Text(
|
||||||
Text("Сбросить настройки")
|
text = "Сбросить настройки",
|
||||||
}
|
modifier = Modifier
|
||||||
Button(onClick = onBackClick, modifier = Modifier.fillMaxWidth()) {
|
.fillMaxWidth()
|
||||||
Text("Готово")
|
.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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Campaign
|
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.Groups
|
||||||
import androidx.compose.material.icons.outlined.Notifications
|
import androidx.compose.material.icons.outlined.Notifications
|
||||||
import androidx.compose.material.icons.outlined.PersonOutline
|
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.material.icons.outlined.VolumeUp
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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.NotificationSettingsSnapshot
|
||||||
import com.seven.ikar.kotlin.core.push.NotificationSoundChoice
|
import com.seven.ikar.kotlin.core.push.NotificationSoundChoice
|
||||||
import com.seven.ikar.kotlin.core.push.NotificationSoundOption
|
import com.seven.ikar.kotlin.core.push.NotificationSoundOption
|
||||||
@@ -49,14 +43,9 @@ import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
|
|||||||
fun NotificationSettingsScreen(
|
fun NotificationSettingsScreen(
|
||||||
state: NotificationSettingsUiState,
|
state: NotificationSettingsUiState,
|
||||||
messageSoundOptions: List<NotificationSoundChoice>,
|
messageSoundOptions: List<NotificationSoundChoice>,
|
||||||
callRingtoneOptions: List<NotificationSoundChoice>,
|
|
||||||
repeatIntervalOptions: List<NotificationRepeatIntervalChoice>,
|
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onSnapshotChanged: (NotificationSettingsSnapshot) -> Unit,
|
onSnapshotChanged: (NotificationSettingsSnapshot) -> Unit,
|
||||||
onMessageSoundChanged: (NotificationSoundOption) -> Unit,
|
onMessageSoundChanged: (NotificationSoundOption) -> Unit,
|
||||||
onCallRingtoneChanged: (NotificationSoundOption) -> Unit,
|
|
||||||
onRepeatIntervalChanged: (NotificationRepeatIntervalOption) -> Unit,
|
|
||||||
onSaveClick: () -> Unit,
|
|
||||||
onResetClick: () -> Unit
|
onResetClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val snapshot = state.snapshot
|
val snapshot = state.snapshot
|
||||||
@@ -73,86 +62,59 @@ fun NotificationSettingsScreen(
|
|||||||
IkarSimpleHeader(title = "Уведомления и звуки", onBackClick = onBackClick)
|
IkarSimpleHeader(title = "Уведомления и звуки", onBackClick = onBackClick)
|
||||||
|
|
||||||
NotificationSection {
|
NotificationSection {
|
||||||
IkarSectionTitle("Уведомления из чатов", modifier = Modifier.padding(bottom = 12.dp))
|
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) })
|
IkarToggleRow(
|
||||||
IkarRowDivider(startIndent = 32.dp)
|
"Уведомления",
|
||||||
IkarToggleRow("Группы", "Нажмите для изменения", snapshot.groupsEnabled, { onSnapshotChanged(snapshot.copy(groupsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Groups, contentDescription = null, tint = TelegramInkMuted) })
|
"Регистрировать устройство и показывать push-уведомления.",
|
||||||
IkarRowDivider(startIndent = 32.dp)
|
snapshot.notificationsEnabled,
|
||||||
IkarToggleRow("Каналы", "Нажмите для изменения", snapshot.channelsEnabled, { onSnapshotChanged(snapshot.copy(channelsEnabled = it)) }, leading = { androidx.compose.material3.Icon(Icons.Outlined.Campaign, contentDescription = null, tint = TelegramInkMuted) })
|
{ onSnapshotChanged(snapshot.copy(notificationsEnabled = it)) },
|
||||||
IkarRowDivider(startIndent = 32.dp)
|
leading = { androidx.compose.material3.Icon(Icons.Outlined.Notifications, contentDescription = null, tint = TelegramInkMuted) }
|
||||||
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) }
|
|
||||||
)
|
)
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
IkarRowDivider(startIndent = 32.dp)
|
||||||
IkarValueRow(
|
IkarValueRow(
|
||||||
title = "Рингтон",
|
title = "Звук сообщений",
|
||||||
subtitle = "Системный звук входящего звонка в Икар",
|
subtitle = "Канал уведомлений для новых сообщений.",
|
||||||
value = callRingtoneOptions.firstOrNull { it.value == snapshot.callRingtone }?.title ?: "По умолчанию",
|
value = messageSoundOptions.firstOrNull { it.value == snapshot.messageSound }?.title ?: "По умолчанию",
|
||||||
onClick = {
|
onClick = if (messageSoundOptions.isEmpty()) null else ({
|
||||||
val currentIndex = callRingtoneOptions.indexOfFirst { it.value == snapshot.callRingtone }.coerceAtLeast(0)
|
val currentIndex = messageSoundOptions.indexOfFirst { it.value == snapshot.messageSound }.coerceAtLeast(0)
|
||||||
val next = callRingtoneOptions[(currentIndex + 1) % callRingtoneOptions.size]
|
val next = messageSoundOptions[(currentIndex + 1) % messageSoundOptions.size]
|
||||||
onCallRingtoneChanged(next.value)
|
onMessageSoundChanged(next.value)
|
||||||
},
|
}),
|
||||||
leading = { androidx.compose.material3.Icon(Icons.Outlined.VolumeUp, contentDescription = null, tint = TelegramInkMuted) }
|
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 {
|
NotificationSection {
|
||||||
IkarSectionTitle("Счётчик сообщений", modifier = Modifier.padding(bottom = 12.dp))
|
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)
|
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)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarToggleRow("Число сообщений", "Показывать количество непрочитанных сообщений", snapshot.countMessagesInBadge, { onSnapshotChanged(snapshot.copy(countMessagesInBadge = it)) })
|
IkarToggleRow("Число сообщений", "Показывать сумму сообщений вместо числа чатов.", snapshot.countMessagesInBadge, { onSnapshotChanged(snapshot.copy(countMessagesInBadge = it)) })
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationSection {
|
NotificationSection {
|
||||||
IkarSectionTitle("В приложении", modifier = Modifier.padding(bottom = 12.dp))
|
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)
|
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)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarToggleRow("Показывать текст", "Показывать содержимое сообщения во всплывающем окне", snapshot.inAppShowTextEnabled, { onSnapshotChanged(snapshot.copy(inAppShowTextEnabled = it)) })
|
IkarToggleRow("Показывать текст", "Текст во всплывающем окне", snapshot.inAppShowTextEnabled, { onSnapshotChanged(snapshot.copy(inAppShowTextEnabled = it)) })
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarToggleRow("Звук в чате", "Короткий сигнал для уже открытого чата", snapshot.soundInActiveChatEnabled, { onSnapshotChanged(snapshot.copy(soundInActiveChatEnabled = it)) })
|
IkarToggleRow("Звук в чате", "Сигнал в открытом чате", snapshot.soundInActiveChatEnabled, { onSnapshotChanged(snapshot.copy(soundInActiveChatEnabled = it)) })
|
||||||
IkarRowDivider(startIndent = 0.dp)
|
IkarRowDivider(startIndent = 0.dp)
|
||||||
IkarToggleRow("Всплывающие окна", "Показывать всплывающие окна, когда приложение открыто", snapshot.popupWindowsEnabled, { onSnapshotChanged(snapshot.copy(popupWindowsEnabled = it)) })
|
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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationSection {
|
NotificationSection {
|
||||||
@@ -166,16 +128,12 @@ fun NotificationSettingsScreen(
|
|||||||
style = MaterialTheme.typography.titleLarge
|
style = MaterialTheme.typography.titleLarge
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Сбросить все особые настройки уведомлений для отдельных контактов, групп и каналов.",
|
"Сбросить особые настройки контактов, групп и каналов.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = TelegramInkMuted
|
color = TelegramInkMuted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
androidx.compose.material3.Button(onClick = onSaveClick, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(if (state.isSaving) "Сохранение..." else "Сохранить")
|
|
||||||
}
|
|
||||||
|
|
||||||
state.errorMessage?.let {
|
state.errorMessage?.let {
|
||||||
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium)
|
Text(it, color = TelegramDanger, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-14
@@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.seven.ikar.kotlin.app.AppContainer
|
import com.seven.ikar.kotlin.app.AppContainer
|
||||||
import com.seven.ikar.kotlin.core.push.NotificationPreferenceCatalog
|
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.NotificationSettingsSnapshot
|
||||||
import com.seven.ikar.kotlin.core.push.NotificationSoundChoice
|
import com.seven.ikar.kotlin.core.push.NotificationSoundChoice
|
||||||
import com.seven.ikar.kotlin.core.push.NotificationSoundOption
|
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 uiState: StateFlow<NotificationSettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
val messageSoundOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.messageSoundChoices
|
val messageSoundOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.messageSoundChoices
|
||||||
val callRingtoneOptions: List<NotificationSoundChoice> = NotificationPreferenceCatalog.callRingtoneChoices
|
private var saveRevision = 0
|
||||||
val repeatIntervalOptions: List<NotificationRepeatIntervalChoice> = NotificationPreferenceCatalog.repeatChoices
|
|
||||||
|
|
||||||
fun updateSnapshot(transform: (NotificationSettingsSnapshot) -> NotificationSettingsSnapshot) {
|
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 setMessageSound(value: NotificationSoundOption) = updateSnapshot { it.copy(messageSound = value) }
|
||||||
|
|
||||||
fun setCallRingtone(value: NotificationSoundOption) = updateSnapshot { it.copy(callRingtone = value) }
|
private fun persist(snapshot: NotificationSettingsSnapshot) {
|
||||||
|
val revision = ++saveRevision
|
||||||
fun setRepeatInterval(value: NotificationRepeatIntervalOption) = updateSnapshot { it.copy(repeatInterval = value) }
|
|
||||||
|
|
||||||
fun save() {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val snapshot = _uiState.value.snapshot
|
|
||||||
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, infoMessage = null)
|
_uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, infoMessage = null)
|
||||||
runCatching {
|
runCatching {
|
||||||
container.pushSettings.saveSnapshot(snapshot)
|
container.pushSettings.saveSnapshot(snapshot)
|
||||||
container.pushRegistrationManager.registerCurrentDeviceAsync()
|
container.pushRegistrationManager.registerCurrentDeviceAsync()
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_uiState.value = _uiState.value.copy(isSaving = false, infoMessage = "Настройки уведомлений сохранены.")
|
if (revision == saveRevision) {
|
||||||
|
_uiState.value = _uiState.value.copy(isSaving = false, infoMessage = null)
|
||||||
|
}
|
||||||
}.onFailure { error ->
|
}.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() {
|
fun reset() {
|
||||||
container.pushSettings.resetNotificationPreferences()
|
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.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.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.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.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -31,6 +44,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -58,7 +72,6 @@ fun SettingsScreen(
|
|||||||
onChatSettingsClick: () -> Unit,
|
onChatSettingsClick: () -> Unit,
|
||||||
onPrivacyClick: () -> Unit,
|
onPrivacyClick: () -> Unit,
|
||||||
onNotificationsClick: () -> Unit,
|
onNotificationsClick: () -> Unit,
|
||||||
onBotsClick: () -> Unit,
|
|
||||||
onFoldersClick: () -> Unit,
|
onFoldersClick: () -> Unit,
|
||||||
onDevicesClick: () -> Unit,
|
onDevicesClick: () -> Unit,
|
||||||
onUpdatesClick: () -> Unit,
|
onUpdatesClick: () -> Unit,
|
||||||
@@ -76,7 +89,6 @@ fun SettingsScreen(
|
|||||||
onChatSettingsClick,
|
onChatSettingsClick,
|
||||||
onPrivacyClick,
|
onPrivacyClick,
|
||||||
onNotificationsClick,
|
onNotificationsClick,
|
||||||
onBotsClick,
|
|
||||||
onFoldersClick,
|
onFoldersClick,
|
||||||
onDevicesClick,
|
onDevicesClick,
|
||||||
onUpdatesClick,
|
onUpdatesClick,
|
||||||
@@ -92,31 +104,24 @@ fun SettingsScreen(
|
|||||||
subtitle = "Телефон, имя и описание профиля",
|
subtitle = "Телефон, имя и описание профиля",
|
||||||
backgroundColor = Color(0xFFE6F1FD),
|
backgroundColor = Color(0xFFE6F1FD),
|
||||||
accentColor = TelegramBlue,
|
accentColor = TelegramBlue,
|
||||||
letter = "А",
|
icon = Icons.Outlined.AccountCircle,
|
||||||
onClick = onAccountClick,
|
onClick = onAccountClick,
|
||||||
keywords = listOf("телефон", "номер", "имя", "username", "описание", "профиль")
|
keywords = listOf("телефон", "номер", "имя", "username", "описание", "профиль")
|
||||||
),
|
),
|
||||||
SettingsEntryModel(
|
SettingsEntryModel(
|
||||||
title = "Настройки чатов",
|
title = "Настройки чатов",
|
||||||
subtitle = "Обои, список чатов, жесты и медиа",
|
subtitle = "Обои, список чатов, текст, медиа и Enter",
|
||||||
backgroundColor = Color(0xFFFFF8ECD8),
|
backgroundColor = Color(0xFFFFF8EC),
|
||||||
accentColor = Color(0xFFD7812B),
|
accentColor = Color(0xFFD7812B),
|
||||||
letter = "Ч",
|
icon = Icons.Outlined.Tune,
|
||||||
onClick = onChatSettingsClick,
|
onClick = onChatSettingsClick,
|
||||||
keywords = listOf(
|
keywords = listOf(
|
||||||
"обои",
|
"обои",
|
||||||
"список чатов",
|
"список чатов",
|
||||||
"жесты",
|
|
||||||
"медиа",
|
"медиа",
|
||||||
"текст сообщений",
|
"текст сообщений",
|
||||||
"углы",
|
"углы",
|
||||||
"свайп",
|
"отправка по enter"
|
||||||
"встроенный браузер",
|
|
||||||
"анимации",
|
|
||||||
"direct share",
|
|
||||||
"отправка по enter",
|
|
||||||
"микрофон",
|
|
||||||
"18+"
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
SettingsEntryModel(
|
SettingsEntryModel(
|
||||||
@@ -124,7 +129,7 @@ fun SettingsScreen(
|
|||||||
subtitle = "Контакты и отображение имён в чатах",
|
subtitle = "Контакты и отображение имён в чатах",
|
||||||
backgroundColor = Color(0xFFE9F7E5),
|
backgroundColor = Color(0xFFE9F7E5),
|
||||||
accentColor = Color(0xFF44A948),
|
accentColor = Color(0xFF44A948),
|
||||||
letter = "К",
|
icon = Icons.Outlined.Security,
|
||||||
onClick = onPrivacyClick,
|
onClick = onPrivacyClick,
|
||||||
keywords = listOf("контакты", "имена", "приватность", "конфиденциальность")
|
keywords = listOf("контакты", "имена", "приватность", "конфиденциальность")
|
||||||
),
|
),
|
||||||
@@ -133,7 +138,7 @@ fun SettingsScreen(
|
|||||||
subtitle = "Звуки, звонки и счётчик сообщений",
|
subtitle = "Звуки, звонки и счётчик сообщений",
|
||||||
backgroundColor = Color(0xFFFDE8EA),
|
backgroundColor = Color(0xFFFDE8EA),
|
||||||
accentColor = Color(0xFFDC4E5C),
|
accentColor = Color(0xFFDC4E5C),
|
||||||
letter = "У",
|
icon = Icons.Outlined.Notifications,
|
||||||
onClick = onNotificationsClick,
|
onClick = onNotificationsClick,
|
||||||
keywords = listOf("уведомления", "звук", "звонки", "push", "счётчик")
|
keywords = listOf("уведомления", "звук", "звонки", "push", "счётчик")
|
||||||
),
|
),
|
||||||
@@ -142,25 +147,16 @@ fun SettingsScreen(
|
|||||||
subtitle = "Проверка новой версии Икара и установка из Argus",
|
subtitle = "Проверка новой версии Икара и установка из Argus",
|
||||||
backgroundColor = Color(0xFFE8F3FF),
|
backgroundColor = Color(0xFFE8F3FF),
|
||||||
accentColor = TelegramBlue,
|
accentColor = TelegramBlue,
|
||||||
letter = "О",
|
icon = Icons.Outlined.SystemUpdate,
|
||||||
onClick = onUpdatesClick,
|
onClick = onUpdatesClick,
|
||||||
keywords = listOf("обновление", "version", "apk", "argus", "релиз", "установка")
|
keywords = listOf("обновление", "version", "apk", "argus", "релиз", "установка")
|
||||||
),
|
),
|
||||||
SettingsEntryModel(
|
|
||||||
title = "Боты",
|
|
||||||
subtitle = "Создание, токены и команды API ботов",
|
|
||||||
backgroundColor = Color(0xFFEAF4FF),
|
|
||||||
accentColor = TelegramBlue,
|
|
||||||
letter = "Б",
|
|
||||||
onClick = onBotsClick,
|
|
||||||
keywords = listOf("bot api", "токен", "команды", "бот", "боты")
|
|
||||||
),
|
|
||||||
SettingsEntryModel(
|
SettingsEntryModel(
|
||||||
title = "Данные и память",
|
title = "Данные и память",
|
||||||
subtitle = "Кэш и локальные медиафайлы",
|
subtitle = "Кэш и локальные медиафайлы",
|
||||||
backgroundColor = Color(0xFFEAF0FD),
|
backgroundColor = Color(0xFFEAF0FD),
|
||||||
accentColor = Color(0xFF4B76E4),
|
accentColor = Color(0xFF4B76E4),
|
||||||
letter = "Д",
|
icon = Icons.Outlined.Storage,
|
||||||
onClick = onDataStorageClick,
|
onClick = onDataStorageClick,
|
||||||
keywords = listOf("кэш", "память", "данные", "медиафайлы", "хранилище")
|
keywords = listOf("кэш", "память", "данные", "медиафайлы", "хранилище")
|
||||||
),
|
),
|
||||||
@@ -169,34 +165,25 @@ fun SettingsScreen(
|
|||||||
subtitle = "Стартовый фильтр для списка чатов",
|
subtitle = "Стартовый фильтр для списка чатов",
|
||||||
backgroundColor = Color(0xFFEEF5FD),
|
backgroundColor = Color(0xFFEEF5FD),
|
||||||
accentColor = Color(0xFF5293F1),
|
accentColor = Color(0xFF5293F1),
|
||||||
letter = "П",
|
icon = Icons.Outlined.Folder,
|
||||||
onClick = onFoldersClick,
|
onClick = onFoldersClick,
|
||||||
keywords = listOf("папки", "фильтр", "список чатов", "архив")
|
keywords = listOf("папки", "фильтр", "список чатов", "архив")
|
||||||
),
|
),
|
||||||
SettingsEntryModel(
|
SettingsEntryModel(
|
||||||
title = "Устройства",
|
title = "Устройства",
|
||||||
subtitle = "Устройства уведомлений и их состояние",
|
subtitle = "Где используется Икар и состояние уведомлений",
|
||||||
backgroundColor = Color(0xFFEAF7FB),
|
backgroundColor = Color(0xFFEAF7FB),
|
||||||
accentColor = Color(0xFF49A8C2),
|
accentColor = Color(0xFF49A8C2),
|
||||||
letter = "У",
|
icon = Icons.Outlined.Devices,
|
||||||
onClick = onDevicesClick,
|
onClick = onDevicesClick,
|
||||||
keywords = listOf("устройства", "push", "сессии", "авторизация")
|
keywords = listOf("устройства", "push", "телефон", "планшет", "компьютер")
|
||||||
),
|
|
||||||
SettingsEntryModel(
|
|
||||||
title = "Энергосбережение",
|
|
||||||
subtitle = "Фоновое обновление и прогрев превью",
|
|
||||||
backgroundColor = Color(0xFFFFF1E6),
|
|
||||||
accentColor = Color(0xFFD7812B),
|
|
||||||
letter = "Э",
|
|
||||||
onClick = null,
|
|
||||||
keywords = listOf("батарея", "энергосбережение", "фон", "превью")
|
|
||||||
),
|
),
|
||||||
SettingsEntryModel(
|
SettingsEntryModel(
|
||||||
title = "Истории",
|
title = "Истории",
|
||||||
subtitle = "Лента, редактор, срок жизни и защищённые истории",
|
subtitle = "Лента, редактор, срок жизни и защищённые истории",
|
||||||
backgroundColor = Color(0xFFEAF4FF),
|
backgroundColor = Color(0xFFEAF4FF),
|
||||||
accentColor = TelegramBlue,
|
accentColor = TelegramBlue,
|
||||||
letter = "И",
|
icon = Icons.Outlined.AutoStories,
|
||||||
onClick = onStoriesClick,
|
onClick = onStoriesClick,
|
||||||
keywords = listOf("stories", "истории", "сторис", "композер", "story")
|
keywords = listOf("stories", "истории", "сторис", "композер", "story")
|
||||||
),
|
),
|
||||||
@@ -205,27 +192,27 @@ fun SettingsScreen(
|
|||||||
subtitle = "Тема, акцент, эмодзи и анимированные фоны",
|
subtitle = "Тема, акцент, эмодзи и анимированные фоны",
|
||||||
backgroundColor = Color(0xFFE9F7E5),
|
backgroundColor = Color(0xFFE9F7E5),
|
||||||
accentColor = Color(0xFF44A948),
|
accentColor = Color(0xFF44A948),
|
||||||
letter = "Т",
|
icon = Icons.Outlined.Palette,
|
||||||
onClick = onThemeClick,
|
onClick = onThemeClick,
|
||||||
keywords = listOf("theme", "тема", "ночная", "оформление")
|
keywords = listOf("theme", "тема", "ночная", "оформление")
|
||||||
),
|
),
|
||||||
SettingsEntryModel(
|
SettingsEntryModel(
|
||||||
title = "Язык",
|
title = "Язык",
|
||||||
subtitle = "Русский",
|
subtitle = "Русский, английский или язык устройства",
|
||||||
backgroundColor = Color(0xFFF3EAFF),
|
backgroundColor = Color(0xFFFFF8EC),
|
||||||
accentColor = Color(0xFFA25FE0),
|
accentColor = Color(0xFFD7812B),
|
||||||
letter = "Я",
|
icon = Icons.Outlined.Language,
|
||||||
onClick = onLanguageClick,
|
onClick = onLanguageClick,
|
||||||
keywords = listOf("язык", "русский", "локаль")
|
keywords = listOf("language", "язык", "русский", "английский", "локализация")
|
||||||
),
|
),
|
||||||
SettingsEntryModel(
|
SettingsEntryModel(
|
||||||
title = "Прокси",
|
title = "Прокси",
|
||||||
subtitle = "SOCKS/MTProto/HTTP профиль подключения",
|
subtitle = "SOCKS5/HTTP профиль подключения",
|
||||||
backgroundColor = Color(0xFFEAF7FB),
|
backgroundColor = Color(0xFFEAF7FB),
|
||||||
accentColor = Color(0xFF49A8C2),
|
accentColor = Color(0xFF49A8C2),
|
||||||
letter = "П",
|
icon = Icons.Outlined.VpnKey,
|
||||||
onClick = onProxyClick,
|
onClick = onProxyClick,
|
||||||
keywords = listOf("proxy", "прокси", "socks", "mtproto", "сеть")
|
keywords = listOf("proxy", "прокси", "socks", "http", "сеть")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -289,7 +276,7 @@ fun SettingsScreen(
|
|||||||
subtitle = entry.subtitle,
|
subtitle = entry.subtitle,
|
||||||
backgroundColor = entry.backgroundColor,
|
backgroundColor = entry.backgroundColor,
|
||||||
accentColor = entry.accentColor,
|
accentColor = entry.accentColor,
|
||||||
letter = entry.letter,
|
icon = entry.icon,
|
||||||
onClick = entry.onClick
|
onClick = entry.onClick
|
||||||
)
|
)
|
||||||
if (index != visibleEntries.lastIndex) {
|
if (index != visibleEntries.lastIndex) {
|
||||||
@@ -387,7 +374,7 @@ private data class SettingsEntryModel(
|
|||||||
val subtitle: String,
|
val subtitle: String,
|
||||||
val backgroundColor: Color,
|
val backgroundColor: Color,
|
||||||
val accentColor: Color,
|
val accentColor: Color,
|
||||||
val letter: String,
|
val icon: ImageVector,
|
||||||
val onClick: (() -> Unit)?,
|
val onClick: (() -> Unit)?,
|
||||||
val keywords: List<String>
|
val keywords: List<String>
|
||||||
)
|
)
|
||||||
@@ -437,7 +424,7 @@ private fun SettingsEntry(
|
|||||||
subtitle: String,
|
subtitle: String,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
accentColor: Color,
|
accentColor: Color,
|
||||||
letter: String,
|
icon: ImageVector,
|
||||||
onClick: (() -> Unit)?
|
onClick: (() -> Unit)?
|
||||||
) {
|
) {
|
||||||
IkarSettingRow(
|
IkarSettingRow(
|
||||||
@@ -451,11 +438,11 @@ private fun SettingsEntry(
|
|||||||
shape = CircleShape
|
shape = CircleShape
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Icon(
|
||||||
text = letter,
|
imageVector = icon,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
contentDescription = null,
|
||||||
color = accentColor,
|
tint = accentColor,
|
||||||
fontWeight = FontWeight.SemiBold
|
modifier = Modifier.size(22.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-67
@@ -1,10 +1,15 @@
|
|||||||
package com.seven.ikar.kotlin.feature.share
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
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.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.contacts.DiscoverUserItem
|
||||||
import com.seven.ikar.kotlin.ui.AvatarView
|
import com.seven.ikar.kotlin.ui.AvatarView
|
||||||
import com.seven.ikar.kotlin.ui.IkarRowDivider
|
import com.seven.ikar.kotlin.ui.IkarRowDivider
|
||||||
@@ -37,9 +45,18 @@ fun ShareTargetScreen(
|
|||||||
state: ShareTargetUiState,
|
state: ShareTargetUiState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onQueryChanged: (String) -> Unit,
|
onQueryChanged: (String) -> Unit,
|
||||||
onChatClick: (ShareChatTargetItemUiState) -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onUserClick: (DiscoverUserItem) -> 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()) {
|
IkarScreenSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -56,11 +73,26 @@ fun ShareTargetScreen(
|
|||||||
IkarSearchCard(
|
IkarSearchCard(
|
||||||
value = state.query,
|
value = state.query,
|
||||||
onValueChange = onQueryChanged,
|
onValueChange = onQueryChanged,
|
||||||
placeholder = "Поиск чата или контакта"
|
placeholder = "Поиск контакта"
|
||||||
)
|
)
|
||||||
|
|
||||||
SharePreviewCard(state.payload)
|
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 {
|
state.errorMessage?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
@@ -74,7 +106,7 @@ fun ShareTargetScreen(
|
|||||||
padding = PaddingValues(0.dp)
|
padding = PaddingValues(0.dp)
|
||||||
) {
|
) {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
if (state.isLoading && state.chats.isEmpty() && state.contacts.isEmpty()) {
|
if (state.isLoading && state.contacts.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
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()) {
|
if (state.contacts.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
SectionLabel("Контакты")
|
SectionLabel("Контакты")
|
||||||
@@ -106,7 +129,7 @@ fun ShareTargetScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.isLoading && state.chats.isEmpty() && state.contacts.isEmpty()) {
|
if (!state.isLoading && state.contacts.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -115,7 +138,7 @@ fun ShareTargetScreen(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Text("Подходящих получателей нет", style = MaterialTheme.typography.headlineMedium)
|
Text("Подходящих контактов нет", style = MaterialTheme.typography.headlineMedium)
|
||||||
Text(
|
Text(
|
||||||
"Уточните поиск или откройте доступ к контактам.",
|
"Уточните поиск или откройте доступ к контактам.",
|
||||||
color = TelegramInkMuted,
|
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
|
@Composable
|
||||||
private fun ShareContactRow(
|
private fun ShareContactRow(
|
||||||
item: DiscoverUserItem,
|
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.contacts.PhoneNumberNormalizer
|
||||||
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
|
import com.seven.ikar.kotlin.core.model.ChatSummaryDto
|
||||||
import com.seven.ikar.kotlin.core.model.ChatType
|
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.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -19,17 +17,9 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class ShareChatTargetItemUiState(
|
|
||||||
val chat: ChatSummaryDto,
|
|
||||||
val resolvedTitle: String,
|
|
||||||
val lastActivityLabel: String,
|
|
||||||
val preview: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ShareTargetUiState(
|
data class ShareTargetUiState(
|
||||||
val payload: PendingExternalShare,
|
val payload: PendingExternalShare,
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val chats: List<ShareChatTargetItemUiState> = emptyList(),
|
|
||||||
val contacts: List<DiscoverUserItem> = emptyList(),
|
val contacts: List<DiscoverUserItem> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isSending: Boolean = false,
|
val isSending: Boolean = false,
|
||||||
@@ -43,8 +33,6 @@ class ShareTargetViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(ShareTargetUiState(payload = pendingShare))
|
private val _uiState = MutableStateFlow(ShareTargetUiState(payload = pendingShare))
|
||||||
private val _allChats = mutableListOf<ChatSummaryDto>()
|
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
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
val uiState: StateFlow<ShareTargetUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ShareTargetUiState> = _uiState.asStateFlow()
|
||||||
@@ -67,18 +55,14 @@ class ShareTargetViewModel(
|
|||||||
val query = _uiState.value.query.trim()
|
val query = _uiState.value.query.trim()
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
runCatching {
|
runCatching {
|
||||||
contactNamesByNormalizedPhone = loadDeviceContactNames()
|
|
||||||
val chats = container.executeAuthorized { session ->
|
val chats = container.executeAuthorized { session ->
|
||||||
currentUserId = session.user.id
|
|
||||||
container.apiClient.getChats(session.accessToken)
|
container.apiClient.getChats(session.accessToken)
|
||||||
}
|
}
|
||||||
_allChats.clear()
|
_allChats.clear()
|
||||||
_allChats.addAll(chats)
|
_allChats.addAll(chats)
|
||||||
val contacts = loadContactCandidates(query)
|
loadContactCandidates(query)
|
||||||
chats to contacts
|
}.onSuccess { contacts ->
|
||||||
}.onSuccess { (chats, contacts) ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
chats = filterChats(chats, query),
|
|
||||||
contacts = contacts,
|
contacts = contacts,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
@@ -89,10 +73,6 @@ class ShareTargetViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendToChat(chat: ShareChatTargetItemUiState) {
|
|
||||||
sendToChatId(chat.chat.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendToUser(user: DiscoverUserItem) {
|
fun sendToUser(user: DiscoverUserItem) {
|
||||||
if (_uiState.value.isSending) {
|
if (_uiState.value.isSending) {
|
||||||
return
|
return
|
||||||
@@ -119,26 +99,6 @@ class ShareTargetViewModel(
|
|||||||
_uiState.value = _uiState.value.copy(pendingChatId = null)
|
_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) {
|
private suspend fun sendPendingShare(accessToken: String, chatId: String) {
|
||||||
val sharedText = pendingShare.text?.trim().orEmpty()
|
val sharedText = pendingShare.text?.trim().orEmpty()
|
||||||
if (pendingShare.attachments.isEmpty()) {
|
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> {
|
private suspend fun loadContactCandidates(query: String): List<DiscoverUserItem> {
|
||||||
val candidates = when {
|
return when {
|
||||||
hasContactsPermission() -> container.resolvedContactsRepository.loadMergedCandidates(query)
|
hasContactsPermission() -> container.resolvedContactsRepository.loadMergedCandidates(query)
|
||||||
query.isBlank() -> emptyList()
|
query.isBlank() -> emptyList()
|
||||||
else -> container.executeAuthorized { session ->
|
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? =
|
private fun findExistingDirectChatId(userId: String): String? =
|
||||||
@@ -221,16 +136,6 @@ class ShareTargetViewModel(
|
|||||||
chat.type == ChatType.Direct && chat.participants.any { participant -> participant.id == userId }
|
chat.type == ChatType.Direct && chat.participants.any { participant -> participant.id == userId }
|
||||||
}?.id
|
}?.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 =
|
private fun hasContactsPermission(): Boolean =
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
container.appContext,
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Add
|
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.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
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 coil.compose.AsyncImage
|
||||||
import com.seven.ikar.kotlin.core.model.AttachmentDto
|
import com.seven.ikar.kotlin.core.model.AttachmentDto
|
||||||
import com.seven.ikar.kotlin.core.model.StoryDto
|
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.model.StoryVisibility
|
||||||
import com.seven.ikar.kotlin.core.network.ServerConfig
|
import com.seven.ikar.kotlin.core.network.ServerConfig
|
||||||
import com.seven.ikar.kotlin.ui.AuthenticatedAsyncImage
|
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.SecureWindowEffect
|
||||||
import com.seven.ikar.kotlin.ui.theme.TelegramBlue
|
import com.seven.ikar.kotlin.ui.theme.TelegramBlue
|
||||||
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
|
import com.seven.ikar.kotlin.ui.theme.TelegramInkMuted
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
private const val StoryAutoAdvanceDurationMillis = 5_000L
|
||||||
|
private const val StoryAutoAdvanceTickMillis = 50L
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StoriesScreen(
|
fun StoriesScreen(
|
||||||
state: StoriesUiState,
|
state: StoriesUiState,
|
||||||
@@ -85,6 +111,11 @@ fun StoriesScreen(
|
|||||||
onCloseStoryPreview: () -> Unit,
|
onCloseStoryPreview: () -> Unit,
|
||||||
onNextStory: () -> Unit,
|
onNextStory: () -> Unit,
|
||||||
onPreviousStory: () -> Unit,
|
onPreviousStory: () -> Unit,
|
||||||
|
onStoryReplyTextChanged: (String) -> Unit,
|
||||||
|
onSendStoryReply: (StoryDto) -> Unit,
|
||||||
|
onSendStoryReaction: (StoryDto, String) -> Unit,
|
||||||
|
onShowStoryViews: (StoryDto) -> Unit,
|
||||||
|
onCloseStoryViews: () -> Unit,
|
||||||
onRetryTransfer: (String) -> Unit,
|
onRetryTransfer: (String) -> Unit,
|
||||||
onCancelTransfer: (String) -> Unit
|
onCancelTransfer: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -96,9 +127,22 @@ fun StoriesScreen(
|
|||||||
stories = state.stories,
|
stories = state.stories,
|
||||||
accessToken = state.accessToken,
|
accessToken = state.accessToken,
|
||||||
isOwnStory = story.author.id == state.currentUserId,
|
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,
|
onClose = onCloseStoryPreview,
|
||||||
onNextStory = onNextStory,
|
onNextStory = onNextStory,
|
||||||
onPreviousStory = onPreviousStory,
|
onPreviousStory = onPreviousStory,
|
||||||
|
onReplyTextChanged = onStoryReplyTextChanged,
|
||||||
|
onSendReply = { onSendStoryReply(story) },
|
||||||
|
onSendReaction = { emoji -> onSendStoryReaction(story, emoji) },
|
||||||
|
onShowStoryViews = { onShowStoryViews(story) },
|
||||||
|
onCloseStoryViews = onCloseStoryViews,
|
||||||
onDeleteStory = { onDeleteStory(story) }
|
onDeleteStory = { onDeleteStory(story) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -346,9 +390,22 @@ private fun StoryViewerDialog(
|
|||||||
stories: List<StoryDto>,
|
stories: List<StoryDto>,
|
||||||
accessToken: String?,
|
accessToken: String?,
|
||||||
isOwnStory: Boolean,
|
isOwnStory: Boolean,
|
||||||
|
replyText: String,
|
||||||
|
isSendingReply: Boolean,
|
||||||
|
replyErrorMessage: String?,
|
||||||
|
replySent: Boolean,
|
||||||
|
storyViews: List<StoryViewDto>,
|
||||||
|
isLoadingStoryViews: Boolean,
|
||||||
|
storyViewsErrorMessage: String?,
|
||||||
|
showStoryViews: Boolean,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onNextStory: () -> Unit,
|
onNextStory: () -> Unit,
|
||||||
onPreviousStory: () -> Unit,
|
onPreviousStory: () -> Unit,
|
||||||
|
onReplyTextChanged: (String) -> Unit,
|
||||||
|
onSendReply: () -> Unit,
|
||||||
|
onSendReaction: (String) -> Unit,
|
||||||
|
onShowStoryViews: () -> Unit,
|
||||||
|
onCloseStoryViews: () -> Unit,
|
||||||
onDeleteStory: () -> Unit
|
onDeleteStory: () -> Unit
|
||||||
) {
|
) {
|
||||||
Dialog(
|
Dialog(
|
||||||
@@ -356,26 +413,64 @@ private fun StoryViewerDialog(
|
|||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onClose)
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
) {
|
) {
|
||||||
StoryViewerContent(story = story, accessToken = accessToken)
|
StoryViewerContent(story = story, accessToken = accessToken, mediaIndex = safeMediaIndex)
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.clickable(onClick = onPreviousStory)
|
.clickable {
|
||||||
|
if (safeMediaIndex > 0) {
|
||||||
|
selectedMediaIndex = safeMediaIndex - 1
|
||||||
|
} else {
|
||||||
|
onPreviousStory()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxSize()
|
.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,
|
stories = stories,
|
||||||
accessToken = accessToken,
|
accessToken = accessToken,
|
||||||
isOwnStory = isOwnStory,
|
isOwnStory = isOwnStory,
|
||||||
|
mediaIndex = safeMediaIndex,
|
||||||
|
mediaCount = story.attachments.size,
|
||||||
|
progress = autoProgress,
|
||||||
onClose = onClose,
|
onClose = onClose,
|
||||||
onDeleteStory = onDeleteStory
|
onDeleteStory = onDeleteStory
|
||||||
)
|
)
|
||||||
|
|
||||||
StoryViewerCaption(
|
StoryViewerBottomBar(
|
||||||
story = story,
|
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)
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -397,34 +510,38 @@ private fun StoryViewerDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StoryViewerContent(story: StoryDto, accessToken: String?) {
|
private fun StoryViewerContent(story: StoryDto, accessToken: String?, mediaIndex: Int) {
|
||||||
val imageAttachment = story.attachments.firstOrNull { it.contentType.startsWith("image/") }
|
val attachment = story.attachments.getOrNull(mediaIndex) ?: story.attachments.firstOrNull()
|
||||||
if (imageAttachment != null) {
|
if (attachment != null && attachment.contentType.startsWith("image/", ignoreCase = true)) {
|
||||||
AuthenticatedAsyncImage(
|
AuthenticatedAsyncImage(
|
||||||
imageUrl = ServerConfig.normalizeMediaUrl(imageAttachment.downloadPath),
|
imageUrl = ServerConfig.normalizeMediaUrl(attachment.downloadPath),
|
||||||
accessToken = accessToken,
|
accessToken = accessToken,
|
||||||
contentDescription = imageAttachment.fileName,
|
contentDescription = attachment.fileName,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attachment != null && attachment.contentType.startsWith("video/", ignoreCase = true)) {
|
||||||
|
StoryVideoPlayer(attachment = attachment, accessToken = accessToken)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(28.dp),
|
.padding(28.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (story.attachments.isNotEmpty()) {
|
if (attachment != null) {
|
||||||
val first = story.attachments.first()
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
Text("Видео", style = MaterialTheme.typography.headlineMedium, color = Color.White)
|
Text("Видео", style = MaterialTheme.typography.headlineMedium, color = Color.White)
|
||||||
Text(
|
Text(
|
||||||
first.fileName,
|
attachment.fileName,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color.White.copy(alpha = 0.72f),
|
color = Color.White.copy(alpha = 0.72f),
|
||||||
textAlign = TextAlign.Center,
|
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
|
@Composable
|
||||||
private fun StoryViewerTopBar(
|
private fun StoryViewerTopBar(
|
||||||
story: StoryDto,
|
story: StoryDto,
|
||||||
stories: List<StoryDto>,
|
stories: List<StoryDto>,
|
||||||
accessToken: String?,
|
accessToken: String?,
|
||||||
isOwnStory: Boolean,
|
isOwnStory: Boolean,
|
||||||
|
mediaIndex: Int,
|
||||||
|
mediaCount: Int,
|
||||||
|
progress: Float,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onDeleteStory: () -> Unit
|
onDeleteStory: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -459,7 +622,13 @@ private fun StoryViewerTopBar(
|
|||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
StoryProgressSegments(story = story, stories = stories)
|
StoryProgressSegments(
|
||||||
|
story = story,
|
||||||
|
stories = stories,
|
||||||
|
mediaIndex = mediaIndex,
|
||||||
|
mediaCount = mediaCount,
|
||||||
|
progress = progress
|
||||||
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -498,41 +667,111 @@ private fun StoryViewerTopBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 selectedIndex = stories.indexOfFirst { it.id == story.id }
|
||||||
val firstIndex = if (selectedIndex < 0) 0 else (selectedIndex - 6).coerceAtLeast(0)
|
val firstIndex = if (selectedIndex < 0) 0 else (selectedIndex - 6).coerceAtLeast(0)
|
||||||
val segmentStories = stories.drop(firstIndex).take(12).ifEmpty { listOf(story) }
|
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()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
segmentStories.forEach { item ->
|
segmentStories.forEachIndexed { index, _ ->
|
||||||
Box(
|
StoryProgressSegment(
|
||||||
modifier = Modifier
|
progress = when {
|
||||||
.weight(1f)
|
index < activeSegmentIndex -> 1f
|
||||||
.height(3.dp)
|
index == activeSegmentIndex -> progress
|
||||||
.clip(RoundedCornerShape(2.dp))
|
else -> 0f
|
||||||
.background(if (item.id == story.id) Color.White else Color.White.copy(alpha = 0.34f))
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.imePadding()
|
||||||
.background(Color.Black.copy(alpha = 0.54f))
|
.background(Color.Black.copy(alpha = 0.54f))
|
||||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
if (story.attachments.isNotEmpty() && story.text.isNotBlank()) {
|
if (story.attachments.isNotEmpty() && story.text.isNotBlank()) {
|
||||||
Text(story.text, style = MaterialTheme.typography.bodyLarge, color = Color.White)
|
Text(story.text, style = MaterialTheme.typography.bodyLarge, color = Color.White)
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
if (isOwnStory) {
|
||||||
"${story.viewCount} просмотров",
|
TextButton(onClick = if (showStoryViews) onCloseStoryViews else onShowStoryViews) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
Text(
|
||||||
color = Color.White.copy(alpha = 0.72f)
|
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) {
|
if (story.hasProtectedContent) {
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
Text(
|
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.app.AppContainer
|
||||||
import com.seven.ikar.kotlin.core.local.TransferQueueEntity
|
import com.seven.ikar.kotlin.core.local.TransferQueueEntity
|
||||||
import com.seven.ikar.kotlin.core.model.StoryDto
|
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.model.StoryVisibility
|
||||||
import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile
|
import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -43,7 +44,16 @@ data class StoriesUiState(
|
|||||||
val isImportingMedia: Boolean = false,
|
val isImportingMedia: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val createdStoryId: 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() {
|
class StoriesViewModel(private val container: AppContainer) : ViewModel() {
|
||||||
@@ -54,6 +64,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
attachStoryTransferState()
|
attachStoryTransferState()
|
||||||
|
attachRealtime()
|
||||||
refresh()
|
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)
|
_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) {
|
fun setVisibility(visibility: StoryVisibility) {
|
||||||
_uiState.value = _uiState.value.copy(visibility = visibility)
|
_uiState.value = _uiState.value.copy(visibility = visibility)
|
||||||
}
|
}
|
||||||
@@ -182,6 +201,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
isSaving = false,
|
isSaving = false,
|
||||||
createdStoryId = story.id
|
createdStoryId = story.id
|
||||||
)
|
)
|
||||||
|
container.realtimeService.notifyStoriesChangedLocally()
|
||||||
onCreated()
|
onCreated()
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(isSaving = false, errorMessage = error.message)
|
_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) {
|
fun openStory(story: StoryDto) {
|
||||||
viewModelScope.launch {
|
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 {
|
runCatching {
|
||||||
container.executeAuthorized { session ->
|
container.executeAuthorized { session ->
|
||||||
container.apiClient.getStory(session.accessToken, story.id)
|
container.apiClient.getStory(session.accessToken, story.id)
|
||||||
@@ -199,6 +230,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
}.onSuccess { viewedStory ->
|
}.onSuccess { viewedStory ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
selectedStory = viewedStory,
|
selectedStory = viewedStory,
|
||||||
|
selectedStoryAuthorId = viewedStory.author.id,
|
||||||
stories = _uiState.value.stories.map { if (it.id == viewedStory.id) viewedStory else it }
|
stories = _uiState.value.stories.map { if (it.id == viewedStory.id) viewedStory else it }
|
||||||
)
|
)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
@@ -223,8 +255,16 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
}.onSuccess { storyFromServer ->
|
}.onSuccess { storyFromServer ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
selectedStory = storyFromServer,
|
selectedStory = storyFromServer,
|
||||||
|
selectedStoryAuthorId = storyFromServer.author.id,
|
||||||
stories = listOf(storyFromServer) + _uiState.value.stories.filterNot { it.id == storyFromServer.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 ->
|
}.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
|
_uiState.value = _uiState.value.copy(isLoading = false, errorMessage = error.message)
|
||||||
@@ -241,7 +281,112 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun closeStoryPreview() {
|
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) {
|
fun deleteStory(story: StoryDto) {
|
||||||
@@ -253,6 +398,7 @@ class StoriesViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
stories = _uiState.value.stories.filterNot { it.id == story.id },
|
stories = _uiState.value.stories.filterNot { it.id == story.id },
|
||||||
selectedStory = _uiState.value.selectedStory?.takeUnless { it.id == story.id }
|
selectedStory = _uiState.value.selectedStory?.takeUnless { it.id == story.id }
|
||||||
)
|
)
|
||||||
|
container.realtimeService.notifyStoriesChangedLocally()
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(errorMessage = error.message)
|
_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) {
|
private fun openRelativeStory(offset: Int) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val selected = state.selectedStory ?: return
|
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()) {
|
if (stories.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-8
@@ -113,14 +113,16 @@ class AttachmentUploadWorker(
|
|||||||
ttlSeconds = first.ttlSeconds ?: 86_400,
|
ttlSeconds = first.ttlSeconds ?: 86_400,
|
||||||
files = files
|
files = files
|
||||||
)
|
)
|
||||||
} else if (files.size == 1) {
|
} else if (files.size == 1 || !files.all(::isMediaAlbumFile)) {
|
||||||
container.apiClient.uploadAttachment(
|
files.forEachIndexed { index, file ->
|
||||||
accessToken = session.accessToken,
|
container.apiClient.uploadAttachment(
|
||||||
chatId = chatId!!,
|
accessToken = session.accessToken,
|
||||||
text = first.text,
|
chatId = chatId!!,
|
||||||
replyToMessageId = first.replyToMessageId,
|
text = first.text.takeIf { index == 0 },
|
||||||
file = files.first()
|
replyToMessageId = first.replyToMessageId.takeIf { index == 0 },
|
||||||
)
|
file = file
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
container.apiClient.uploadAttachmentAlbum(
|
container.apiClient.uploadAttachmentAlbum(
|
||||||
accessToken = session.accessToken,
|
accessToken = session.accessToken,
|
||||||
@@ -134,6 +136,9 @@ class AttachmentUploadWorker(
|
|||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
dao.updateGroupStatus(groupId, TransferQueueStatus.Completed, 100, null, System.currentTimeMillis())
|
dao.updateGroupStatus(groupId, TransferQueueStatus.Completed, 100, null, System.currentTimeMillis())
|
||||||
|
if (isStoryUpload) {
|
||||||
|
container.realtimeService.notifyStoriesChangedLocally()
|
||||||
|
}
|
||||||
Result.success()
|
Result.success()
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
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 =
|
private fun String?.toStoryVisibility(): StoryVisibility =
|
||||||
runCatching { StoryVisibility.valueOf(this ?: StoryVisibility.Everyone.name) }
|
runCatching { StoryVisibility.valueOf(this ?: StoryVisibility.Everyone.name) }
|
||||||
.getOrDefault(StoryVisibility.Everyone)
|
.getOrDefault(StoryVisibility.Everyone)
|
||||||
|
|||||||
+2
-2
@@ -111,7 +111,7 @@ fun AppUpdateScreen(
|
|||||||
state.latestManifest?.let { manifest ->
|
state.latestManifest?.let { manifest ->
|
||||||
UpdateFieldRow(
|
UpdateFieldRow(
|
||||||
title = "Последний релиз Argus",
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
UpdateFieldRow(
|
UpdateFieldRow(
|
||||||
title = "Версия",
|
title = "Версия",
|
||||||
value = "${update.manifest.release.version} (${update.manifest.release.androidVersionCode ?: "?"})"
|
value = "${update.manifest.release.resolvedVersionName} (${update.manifest.release.resolvedVersionCode ?: "?"})"
|
||||||
)
|
)
|
||||||
UpdateFieldRow(
|
UpdateFieldRow(
|
||||||
title = "Размер APK",
|
title = "Размер APK",
|
||||||
|
|||||||
+1
-1
@@ -126,7 +126,7 @@ class AppUpdateViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
availableUpdate = result.update,
|
availableUpdate = result.update,
|
||||||
latestManifest = manifest,
|
latestManifest = manifest,
|
||||||
statusTitle = "Проверка проведена",
|
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.compose.AsyncImage
|
||||||
import coil.request.CachePolicy
|
import coil.request.CachePolicy
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AuthenticatedAsyncImage(
|
fun AuthenticatedAsyncImage(
|
||||||
@@ -24,9 +25,11 @@ fun AuthenticatedAsyncImage(
|
|||||||
val resolvedAccessToken = accessToken ?: LocalIkarAccessToken.current
|
val resolvedAccessToken = accessToken ?: LocalIkarAccessToken.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(imageUrl, resolvedAccessToken) {
|
val request = remember(imageUrl, resolvedAccessToken) {
|
||||||
|
val diskCacheKey = buildAuthenticatedDiskCacheKey(imageUrl, resolvedAccessToken)
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(imageUrl)
|
.data(imageUrl)
|
||||||
.diskCacheKey(imageUrl)
|
.diskCacheKey(diskCacheKey)
|
||||||
|
.memoryCacheKey(diskCacheKey)
|
||||||
.diskCachePolicy(CachePolicy.ENABLED)
|
.diskCachePolicy(CachePolicy.ENABLED)
|
||||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||||
.apply {
|
.apply {
|
||||||
@@ -45,3 +48,14 @@ fun AuthenticatedAsyncImage(
|
|||||||
contentScale = contentScale
|
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
|
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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@@ -15,6 +20,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.seven.ikar.kotlin.core.settings.DEFAULT_CHAT_WALLPAPER_PRESET_ID
|
import com.seven.ikar.kotlin.core.settings.DEFAULT_CHAT_WALLPAPER_PRESET_ID
|
||||||
@@ -166,6 +172,7 @@ object ChatWallpaperCatalog {
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatWallpaperBackground(
|
fun ChatWallpaperBackground(
|
||||||
snapshot: ChatSettingsSnapshot,
|
snapshot: ChatSettingsSnapshot,
|
||||||
|
animated: Boolean = false,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
@@ -188,6 +195,7 @@ fun ChatWallpaperBackground(
|
|||||||
} else {
|
} else {
|
||||||
PresetWallpaperBackground(
|
PresetWallpaperBackground(
|
||||||
preset = ChatWallpaperCatalog.resolvePreset(snapshot.selectedWallpaperPresetId),
|
preset = ChatWallpaperCatalog.resolvePreset(snapshot.selectedWallpaperPresetId),
|
||||||
|
animated = animated,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -197,8 +205,10 @@ fun ChatWallpaperBackground(
|
|||||||
@Composable
|
@Composable
|
||||||
fun PresetWallpaperBackground(
|
fun PresetWallpaperBackground(
|
||||||
preset: ChatWallpaperPreset,
|
preset: ChatWallpaperPreset,
|
||||||
|
animated: Boolean = false,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
val motion = wallpaperMotion(animated)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.background(
|
modifier = modifier.background(
|
||||||
Brush.linearGradient(colors = preset.gradientColors)
|
Brush.linearGradient(colors = preset.gradientColors)
|
||||||
@@ -210,6 +220,7 @@ fun PresetWallpaperBackground(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.offset(x = 36.dp, y = (-18).dp)
|
.offset(x = 36.dp, y = (-18).dp)
|
||||||
|
.wallpaperMotion(motion, x = 10f, y = -6f)
|
||||||
.size(132.dp),
|
.size(132.dp),
|
||||||
color = preset.accentPrimary
|
color = preset.accentPrimary
|
||||||
)
|
)
|
||||||
@@ -217,6 +228,7 @@ fun PresetWallpaperBackground(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.offset(x = (-28).dp, y = 24.dp)
|
.offset(x = (-28).dp, y = 24.dp)
|
||||||
|
.wallpaperMotion(motion, x = -8f, y = 7f)
|
||||||
.size(156.dp),
|
.size(156.dp),
|
||||||
color = preset.accentSecondary
|
color = preset.accentSecondary
|
||||||
)
|
)
|
||||||
@@ -227,6 +239,7 @@ fun PresetWallpaperBackground(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.offset(x = (-40).dp, y = (-24).dp)
|
.offset(x = (-40).dp, y = (-24).dp)
|
||||||
|
.wallpaperMotion(motion, x = -7f, y = 8f)
|
||||||
.size(150.dp),
|
.size(150.dp),
|
||||||
color = preset.accentPrimary
|
color = preset.accentPrimary
|
||||||
)
|
)
|
||||||
@@ -234,6 +247,7 @@ fun PresetWallpaperBackground(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.offset(x = 28.dp)
|
.offset(x = 28.dp)
|
||||||
|
.wallpaperMotion(motion, x = 9f, y = 4f)
|
||||||
.size(124.dp),
|
.size(124.dp),
|
||||||
color = preset.accentSecondary
|
color = preset.accentSecondary
|
||||||
)
|
)
|
||||||
@@ -241,6 +255,7 @@ fun PresetWallpaperBackground(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.offset(y = 34.dp)
|
.offset(y = 34.dp)
|
||||||
|
.wallpaperMotion(motion, x = 5f, y = -7f)
|
||||||
.size(108.dp),
|
.size(108.dp),
|
||||||
color = preset.accentPrimary.copy(alpha = 0.45f)
|
color = preset.accentPrimary.copy(alpha = 0.45f)
|
||||||
)
|
)
|
||||||
@@ -251,6 +266,7 @@ fun PresetWallpaperBackground(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.offset(x = (-18).dp, y = 22.dp)
|
.offset(x = (-18).dp, y = 22.dp)
|
||||||
|
.wallpaperMotion(motion, x = 8f, y = 5f)
|
||||||
.fillMaxWidth(0.9f)
|
.fillMaxWidth(0.9f)
|
||||||
.height(64.dp),
|
.height(64.dp),
|
||||||
color = preset.accentPrimary
|
color = preset.accentPrimary
|
||||||
@@ -259,6 +275,7 @@ fun PresetWallpaperBackground(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.offset(x = 22.dp, y = (-14).dp)
|
.offset(x = 22.dp, y = (-14).dp)
|
||||||
|
.wallpaperMotion(motion, x = -8f, y = -4f)
|
||||||
.fillMaxWidth(0.76f)
|
.fillMaxWidth(0.76f)
|
||||||
.height(56.dp),
|
.height(56.dp),
|
||||||
color = preset.accentSecondary
|
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
|
@Composable
|
||||||
private fun AccentCircle(
|
private fun AccentCircle(
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
|
|||||||
@@ -338,8 +338,19 @@ fun IkarSettingRow(
|
|||||||
content = leading
|
content = leading
|
||||||
)
|
)
|
||||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(title, style = MaterialTheme.typography.titleLarge)
|
Text(
|
||||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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()
|
trailing?.invoke()
|
||||||
}
|
}
|
||||||
@@ -371,8 +382,19 @@ fun IkarToggleRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(title, style = MaterialTheme.typography.titleLarge)
|
Text(
|
||||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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(
|
Switch(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
@@ -417,8 +439,19 @@ fun IkarValueRow(
|
|||||||
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center, content = leading)
|
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center, content = leading)
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(title, style = MaterialTheme.typography.titleLarge)
|
Text(
|
||||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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(
|
Text(
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ param(
|
|||||||
[string]$Channel = 'stable',
|
[string]$Channel = 'stable',
|
||||||
[string]$Platform = 'android',
|
[string]$Platform = 'android',
|
||||||
[string]$Notes,
|
[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',
|
[string]$HomepageUrl = 'https://ikar.kusoft.xyz',
|
||||||
[switch]$Unlisted,
|
[switch]$Unlisted,
|
||||||
[switch]$SkipSigningInitialization
|
[switch]$SkipSigningInitialization
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
param(
|
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)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$Username,
|
[string]$SshPassword,
|
||||||
[Parameter(Mandatory = $true)]
|
[Alias('BaseUrl')]
|
||||||
[string]$Password,
|
[string]$PublicBaseUrl = 'https://argus.kusoft.xyz',
|
||||||
|
[string]$ArgusDataPath = '/srv/argus-data',
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidatePattern('^[a-z0-9][a-z0-9-]{0,99}$')]
|
||||||
[string]$Slug,
|
[string]$Slug,
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$Name,
|
[string]$Name,
|
||||||
@@ -12,13 +18,11 @@ param(
|
|||||||
[string]$Summary,
|
[string]$Summary,
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$Description,
|
[string]$Description,
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string]$AndroidPackageName,
|
[string]$AndroidPackageName,
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$Version,
|
[string]$Version,
|
||||||
[Parameter(Mandatory = $true)]
|
[ValidateRange(0, [int]::MaxValue)]
|
||||||
[ValidateRange(1, [int]::MaxValue)]
|
[int]$AndroidVersionCode = 0,
|
||||||
[int]$AndroidVersionCode,
|
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$PackagePath,
|
[string]$PackagePath,
|
||||||
[string]$Channel = 'stable',
|
[string]$Channel = 'stable',
|
||||||
@@ -31,111 +35,380 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$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
|
$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path
|
||||||
if (-not (Test-Path $resolvedPackagePath)) {
|
$packageItem = Get-Item -LiteralPath $resolvedPackagePath
|
||||||
throw "Package file was not found: $resolvedPackagePath"
|
if ($packageItem.Length -le 0) {
|
||||||
|
throw "Package file is empty: $resolvedPackagePath"
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUri = $BaseUrl.TrimEnd('/')
|
$baseUrl = $PublicBaseUrl.TrimEnd('/')
|
||||||
$cookieContainer = [System.Net.CookieContainer]::new()
|
$manifestUrl = "{0}/api/apps/{1}/manifest?platform={2}&channel={3}" -f `
|
||||||
$handler = [System.Net.Http.HttpClientHandler]::new()
|
$baseUrl,
|
||||||
$handler.CookieContainer = $cookieContainer
|
[uri]::EscapeDataString($Slug),
|
||||||
$handler.UseCookies = $true
|
[uri]::EscapeDataString($Platform),
|
||||||
$client = [System.Net.Http.HttpClient]::new($handler)
|
[uri]::EscapeDataString($Channel)
|
||||||
$client.Timeout = [TimeSpan]::FromMinutes(15)
|
|
||||||
|
$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 {
|
try {
|
||||||
$loginPayload = @{
|
$publishOutput = $pythonScript | python -
|
||||||
username = $Username
|
$pythonExitCode = $LASTEXITCODE
|
||||||
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()
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
$client.Dispose()
|
Remove-Item Env:\IKAR_ARGUS_PUBLISH_PAYLOAD -ErrorAction SilentlyContinue
|
||||||
$handler.Dispose()
|
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
|
public sealed class BotsControllerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[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 database = new SqliteTestDatabase();
|
||||||
await using var dbContext = await database.CreateContextAsync();
|
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.UserSessions.AnyAsync(x => x.UserId == botUser.Id));
|
||||||
Assert.False(await dbContext.PushDevices.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.False(await dbContext.Users.AnyAsync(x => x.Id == botUser.Id));
|
||||||
Assert.True(reloadedBotUser.IsBot);
|
Assert.False(await dbContext.Chats.AnyAsync(x => x.Id == chat.Id));
|
||||||
Assert.True(reloadedBotUser.IsBlocked);
|
Assert.False(await dbContext.Messages.AnyAsync(x => x.Id == message.Id));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -49,6 +49,72 @@ public sealed class ChannelDtoMapperTests
|
|||||||
Assert.Equal(owner.Id, visibleMember.User.Id);
|
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]
|
[Fact]
|
||||||
public void ChannelPost_MasksSenderForRegularSubscriber_AndKeepsViewCount()
|
public void ChannelPost_MasksSenderForRegularSubscriber_AndKeepsViewCount()
|
||||||
{
|
{
|
||||||
@@ -83,6 +149,69 @@ public sealed class ChannelDtoMapperTests
|
|||||||
Assert.Equal(owner.Id, ownerDto.Sender.Id);
|
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)
|
private static Chat CreateChannel(User owner, params User[] subscribers)
|
||||||
{
|
{
|
||||||
var channel = new Chat
|
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.Auth;
|
||||||
using Ikar.Server.Infrastructure.Hubs;
|
using Ikar.Server.Infrastructure.Hubs;
|
||||||
using Ikar.Server.Infrastructure.Push;
|
using Ikar.Server.Infrastructure.Push;
|
||||||
|
using Ikar.Server.Infrastructure.Storage;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
|
||||||
namespace Ikar.Server.Tests;
|
namespace Ikar.Server.Tests;
|
||||||
|
|
||||||
@@ -37,15 +40,28 @@ internal sealed class SqliteTestDatabase : IAsyncDisposable
|
|||||||
|
|
||||||
internal static class ControllerTestFixture
|
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(
|
var controller = new ChatsController(
|
||||||
dbContext,
|
dbContext,
|
||||||
new PresenceTracker(),
|
new PresenceTracker(),
|
||||||
new NoopHubContext<MessengerHub>(),
|
hubContext,
|
||||||
attachmentStorageService: null!,
|
attachmentStorageService: attachmentStorageService!,
|
||||||
new PushDispatchQueue(),
|
pushDispatchQueue,
|
||||||
new BotUpdateService(dbContext));
|
new BotUpdateService(dbContext),
|
||||||
|
new BotCreatorService(
|
||||||
|
dbContext,
|
||||||
|
new BotTokenService(),
|
||||||
|
new BotDeletionService(dbContext, botAttachmentStorageService),
|
||||||
|
new PresenceTracker(),
|
||||||
|
hubContext,
|
||||||
|
pushDispatchQueue));
|
||||||
AttachUser(controller, userId);
|
AttachUser(controller, userId);
|
||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
@@ -62,21 +78,38 @@ internal static class ControllerTestFixture
|
|||||||
|
|
||||||
public static BotsController CreateBotsController(IkarDbContext dbContext, Guid userId)
|
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);
|
AttachUser(controller, userId);
|
||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StoriesController CreateStoriesController(IkarDbContext dbContext, Guid userId)
|
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(
|
var controller = new StoriesController(
|
||||||
dbContext,
|
dbContext,
|
||||||
new PresenceTracker(),
|
new PresenceTracker(),
|
||||||
attachmentStorageService: null!);
|
hubContext,
|
||||||
|
attachmentStorageService ?? new AttachmentStorageService(new TestWebHostEnvironment(Path.GetTempPath())));
|
||||||
AttachUser(controller, userId);
|
AttachUser(controller, userId);
|
||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static AttachmentStorageService CreateAttachmentStorageService(string contentRootPath) =>
|
||||||
|
new(new TestWebHostEnvironment(contentRootPath));
|
||||||
|
|
||||||
public static T ValueOf<T>(ActionResult<T> actionResult)
|
public static T ValueOf<T>(ActionResult<T> actionResult)
|
||||||
{
|
{
|
||||||
var ok = Assert.IsType<OkObjectResult>(actionResult.Result);
|
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>
|
internal sealed class NoopHubContext<THub> : IHubContext<THub>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
using Ikar.Server.Controllers;
|
||||||
using Ikar.Server.Data.Entities;
|
using Ikar.Server.Data.Entities;
|
||||||
|
using Ikar.Server.Infrastructure.Hubs;
|
||||||
using Ikar.Shared;
|
using Ikar.Shared;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Ikar.Server.Tests;
|
namespace Ikar.Server.Tests;
|
||||||
@@ -33,12 +37,103 @@ public sealed class PrivacyAndStoriesControllerTests
|
|||||||
Assert.Equal("contacts", settings.PhoneNumberVisibility);
|
Assert.Equal("contacts", settings.PhoneNumberVisibility);
|
||||||
Assert.Equal("nobody", settings.LastSeenVisibility);
|
Assert.Equal("nobody", settings.LastSeenVisibility);
|
||||||
Assert.Equal([bob.Id], settings.BlockedUserIds);
|
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.AlwaysAllowUserIds);
|
||||||
|
Assert.Empty(settings.AlwaysAllowUsers);
|
||||||
Assert.Equal([bob.Id], settings.NeverAllowUserIds);
|
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.True(settings.PasscodeLockEnabled);
|
||||||
Assert.False(settings.ShowMessagePreview);
|
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]
|
[Fact]
|
||||||
public async Task GetStories_ReturnsEveryoneStoriesWithoutSharedChat()
|
public async Task GetStories_ReturnsEveryoneStoriesWithoutSharedChat()
|
||||||
{
|
{
|
||||||
@@ -106,6 +201,118 @@ public sealed class PrivacyAndStoriesControllerTests
|
|||||||
Assert.Equal("own", story.Text);
|
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]
|
[Fact]
|
||||||
public async Task GetAvatar_RespectsProfilePhotoContactsVisibility()
|
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) =>
|
private static User CreateUser(string username) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
@@ -179,4 +424,14 @@ public sealed class PrivacyAndStoriesControllerTests
|
|||||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
|
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
|
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]
|
[Fact]
|
||||||
public async Task DispatchMessageReactionAsync_SendsOnlyToMessageAuthorDevices()
|
public async Task DispatchMessageReactionAsync_SendsOnlyToMessageAuthorDevices()
|
||||||
{
|
{
|
||||||
@@ -126,14 +243,18 @@ public sealed class PushNotificationDispatcherTests
|
|||||||
|
|
||||||
private sealed class RecordingPushNotificationSender : IPushNotificationSender
|
private sealed class RecordingPushNotificationSender : IPushNotificationSender
|
||||||
{
|
{
|
||||||
|
public List<MessagePushCall> MessageCalls { get; } = [];
|
||||||
public List<ReactionPushCall> ReactionCalls { get; } = [];
|
public List<ReactionPushCall> ReactionCalls { get; } = [];
|
||||||
|
|
||||||
public Task SendMessageAsync(
|
public Task SendMessageAsync(
|
||||||
Chat chat,
|
Chat chat,
|
||||||
Message message,
|
Message message,
|
||||||
IReadOnlyCollection<PushDevice> devices,
|
IReadOnlyCollection<PushDevice> devices,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default)
|
||||||
Task.CompletedTask;
|
{
|
||||||
|
MessageCalls.Add(new MessagePushCall(chat, message, devices.ToList()));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public Task SendMessageReactionAsync(
|
public Task SendMessageReactionAsync(
|
||||||
Chat chat,
|
Chat chat,
|
||||||
@@ -166,6 +287,11 @@ public sealed class PushNotificationDispatcherTests
|
|||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record MessagePushCall(
|
||||||
|
Chat Chat,
|
||||||
|
Message Message,
|
||||||
|
IReadOnlyCollection<PushDevice> Devices);
|
||||||
|
|
||||||
private sealed record ReactionPushCall(
|
private sealed record ReactionPushCall(
|
||||||
Chat Chat,
|
Chat Chat,
|
||||||
Message Message,
|
Message Message,
|
||||||
|
|||||||
@@ -81,6 +81,109 @@ public sealed class SearchControllerTests
|
|||||||
Assert.Equal(["alpha one"], secondPage.Messages.Select(x => x.Text).ToArray());
|
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]
|
[Fact]
|
||||||
public async Task SearchPage_ReturnsPublicChannelForNonMember_ByAtUsername()
|
public async Task SearchPage_ReturnsPublicChannelForNonMember_ByAtUsername()
|
||||||
{
|
{
|
||||||
@@ -104,6 +207,49 @@ public sealed class SearchControllerTests
|
|||||||
Assert.False(result.Chat.IsSubscribed);
|
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) =>
|
private static User CreateUser(string username) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
@@ -129,7 +275,12 @@ public sealed class SearchControllerTests
|
|||||||
return chat;
|
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
|
var message = new Message
|
||||||
{
|
{
|
||||||
@@ -139,7 +290,8 @@ public sealed class SearchControllerTests
|
|||||||
SenderId = sender.Id,
|
SenderId = sender.Id,
|
||||||
Sender = sender,
|
Sender = sender,
|
||||||
Text = text,
|
Text = text,
|
||||||
SentAt = sentAt
|
SentAt = sentAt,
|
||||||
|
ExpiresAt = expiresAt
|
||||||
};
|
};
|
||||||
chat.Messages.Add(message);
|
chat.Messages.Add(message);
|
||||||
return 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,
|
IkarDbContext dbContext,
|
||||||
TokenService tokenService,
|
TokenService tokenService,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
PhoneAuthChallengeStore phoneAuthChallengeStore) : ControllerBase
|
PhoneAuthChallengeStore phoneAuthChallengeStore,
|
||||||
|
EmailAuthChallengeStore emailAuthChallengeStore,
|
||||||
|
IEmailCodeSender emailCodeSender) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly PasswordHasher<User> _passwordHasher = new();
|
private readonly PasswordHasher<User> _passwordHasher = new();
|
||||||
|
|
||||||
@@ -146,12 +148,17 @@ public sealed class AuthController(
|
|||||||
var user = await dbContext.Users
|
var user = await dbContext.Users
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken);
|
.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)
|
if (user?.IsBlocked == true)
|
||||||
{
|
{
|
||||||
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
|
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(
|
return Ok(new PhoneCodeChallengeDto(
|
||||||
challenge.Id,
|
challenge.Id,
|
||||||
challenge.ExpiresAt,
|
challenge.ExpiresAt,
|
||||||
@@ -186,13 +193,7 @@ public sealed class AuthController(
|
|||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
var displayName = request.DisplayName?.Trim() ?? string.Empty;
|
return NotFound("Phone code login is available only for existing accounts. Register by email.");
|
||||||
if (string.IsNullOrWhiteSpace(displayName))
|
|
||||||
{
|
|
||||||
return ValidationProblem("Display name is required for a new account.");
|
|
||||||
}
|
|
||||||
|
|
||||||
user = await CreatePhoneOnlyUserAsync(normalizedPhoneNumber, request.PhoneNumber, displayName, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.IsBlocked)
|
if (user.IsBlocked)
|
||||||
@@ -207,6 +208,147 @@ public sealed class AuthController(
|
|||||||
return Ok(session with { User = user.ToDto(presenceTracker) });
|
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")]
|
[HttpPost("refresh")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult<AuthSessionDto>> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult<AuthSessionDto>> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken)
|
||||||
@@ -258,6 +400,104 @@ public sealed class AuthController(
|
|||||||
return user;
|
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)
|
private async Task<string> GenerateInternalUsernameAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
while (true)
|
while (true)
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ using Ikar.Server.Data.Entities;
|
|||||||
using Ikar.Server.Infrastructure;
|
using Ikar.Server.Infrastructure;
|
||||||
using Ikar.Server.Infrastructure.Auth;
|
using Ikar.Server.Infrastructure.Auth;
|
||||||
using Ikar.Server.Infrastructure.Bots;
|
using Ikar.Server.Infrastructure.Bots;
|
||||||
|
using Ikar.Server.Infrastructure.Channels;
|
||||||
using Ikar.Server.Infrastructure.Hubs;
|
using Ikar.Server.Infrastructure.Hubs;
|
||||||
using Ikar.Server.Infrastructure.Push;
|
using Ikar.Server.Infrastructure.Push;
|
||||||
|
using Ikar.Server.Infrastructure.Storage;
|
||||||
using Ikar.Shared;
|
using Ikar.Shared;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -23,8 +25,13 @@ public sealed class BotApiController(
|
|||||||
BotTokenService botTokenService,
|
BotTokenService botTokenService,
|
||||||
BotUpdateService botUpdateService,
|
BotUpdateService botUpdateService,
|
||||||
IHubContext<MessengerHub> hubContext,
|
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")]
|
[HttpGet("getMe")]
|
||||||
public async Task<ActionResult<UserSummaryDto>> GetMe(string token, CancellationToken cancellationToken)
|
public async Task<ActionResult<UserSummaryDto>> GetMe(string token, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -205,10 +212,334 @@ public sealed class BotApiController(
|
|||||||
|
|
||||||
chat.LastActivityAt = message.SentAt;
|
chat.LastActivityAt = message.SentAt;
|
||||||
dbContext.Messages.Add(message);
|
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 dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
|
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
|
||||||
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
|
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));
|
return Ok(message.ToDto(presenceTracker, bot.UserId));
|
||||||
}
|
}
|
||||||
@@ -257,6 +588,112 @@ public sealed class BotApiController(
|
|||||||
return Task.WhenAll(tasks);
|
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)
|
private static List<BotCommandDto> NormalizeCommands(IReadOnlyList<BotCommandDto> commands)
|
||||||
{
|
{
|
||||||
var normalized = new List<BotCommandDto>();
|
var normalized = new List<BotCommandDto>();
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ using Ikar.Server.Data;
|
|||||||
using Ikar.Server.Data.Entities;
|
using Ikar.Server.Data.Entities;
|
||||||
using Ikar.Server.Infrastructure;
|
using Ikar.Server.Infrastructure;
|
||||||
using Ikar.Server.Infrastructure.Auth;
|
using Ikar.Server.Infrastructure.Auth;
|
||||||
|
using Ikar.Server.Infrastructure.Bots;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Ikar.Shared;
|
using Ikar.Shared;
|
||||||
@@ -16,10 +17,10 @@ namespace Ikar.Server.Controllers;
|
|||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public sealed class BotsController(
|
public sealed class BotsController(
|
||||||
IkarDbContext dbContext,
|
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 static readonly Regex BotUsernameRegex = new("^[A-Za-z0-9_]{5,64}$", RegexOptions.Compiled);
|
||||||
private readonly PasswordHasher<User> _passwordHasher = new();
|
|
||||||
|
|
||||||
[HttpGet("mine")]
|
[HttpGet("mine")]
|
||||||
public async Task<ActionResult<IReadOnlyList<BotSummaryDto>>> GetMine(CancellationToken cancellationToken)
|
public async Task<ActionResult<IReadOnlyList<BotSummaryDto>>> GetMine(CancellationToken cancellationToken)
|
||||||
@@ -49,48 +50,12 @@ public sealed class BotsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<CreatedBotDto>> CreateBot(
|
public ActionResult<CreatedBotDto> CreateBot(CreateBotRequest request)
|
||||||
CreateBotRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var ownerUserId = User.GetRequiredUserId();
|
_ = request;
|
||||||
var validationError = await ValidateBotDefinitionAsync(
|
return StatusCode(
|
||||||
request.Username,
|
StatusCodes.Status410Gone,
|
||||||
request.DisplayName,
|
"Создание ботов доступно только через чат с @botfatherbot. Напишите /создать_бота Имя бота | usernamebot | описание.");
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{botId:guid}")]
|
[HttpPut("{botId:guid}")]
|
||||||
@@ -185,36 +150,14 @@ public sealed class BotsController(
|
|||||||
[HttpDelete("{botId:guid}")]
|
[HttpDelete("{botId:guid}")]
|
||||||
public async Task<IActionResult> DeleteBot(Guid botId, CancellationToken cancellationToken)
|
public async Task<IActionResult> DeleteBot(Guid botId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var ownerUserId = User.GetRequiredUserId();
|
var deleted = await botDeletionService.DeleteBotAsync(
|
||||||
var bot = await dbContext.Bots
|
botId,
|
||||||
.Include(x => x.User)
|
User.GetRequiredUserId(),
|
||||||
.ThenInclude(x => x.Sessions)
|
cancellationToken);
|
||||||
.Include(x => x.User)
|
if (deleted is null)
|
||||||
.ThenInclude(x => x.PushDevices)
|
|
||||||
.SingleOrDefaultAsync(
|
|
||||||
x => x.Id == botId && x.OwnerUserId == ownerUserId,
|
|
||||||
cancellationToken);
|
|
||||||
if (bot is null)
|
|
||||||
{
|
{
|
||||||
return NotFound();
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
|||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Ikar.Server.Data;
|
using Ikar.Server.Data;
|
||||||
using Ikar.Server.Data.Entities;
|
using Ikar.Server.Data.Entities;
|
||||||
using Ikar.Server.Infrastructure;
|
using Ikar.Server.Infrastructure;
|
||||||
|
using Ikar.Server.Infrastructure.Hubs;
|
||||||
using Ikar.Server.Infrastructure.Storage;
|
using Ikar.Server.Infrastructure.Storage;
|
||||||
using Ikar.Shared;
|
using Ikar.Shared;
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ namespace Ikar.Server.Controllers;
|
|||||||
public sealed class StoriesController(
|
public sealed class StoriesController(
|
||||||
IkarDbContext dbContext,
|
IkarDbContext dbContext,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
|
IHubContext<MessengerHub> hubContext,
|
||||||
AttachmentStorageService attachmentStorageService) : ControllerBase
|
AttachmentStorageService attachmentStorageService) : ControllerBase
|
||||||
{
|
{
|
||||||
private const int MaxStoryAttachmentCount = 10;
|
private const int MaxStoryAttachmentCount = 10;
|
||||||
@@ -32,6 +35,8 @@ public sealed class StoriesController(
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(x => x.AuthorUser)
|
.Include(x => x.AuthorUser)
|
||||||
.ThenInclude(x => x.Sessions)
|
.ThenInclude(x => x.Sessions)
|
||||||
|
.Include(x => x.AuthorUser)
|
||||||
|
.ThenInclude(x => x.PrivacySettings)
|
||||||
.Include(x => x.Attachments)
|
.Include(x => x.Attachments)
|
||||||
.Include(x => x.Views)
|
.Include(x => x.Views)
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
@@ -42,7 +47,12 @@ public sealed class StoriesController(
|
|||||||
.Take(100)
|
.Take(100)
|
||||||
.ToListAsync(cancellationToken);
|
.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}")]
|
[HttpGet("{storyId:guid}")]
|
||||||
@@ -55,6 +65,8 @@ public sealed class StoriesController(
|
|||||||
var story = await dbContext.Stories
|
var story = await dbContext.Stories
|
||||||
.Include(x => x.AuthorUser)
|
.Include(x => x.AuthorUser)
|
||||||
.ThenInclude(x => x.Sessions)
|
.ThenInclude(x => x.Sessions)
|
||||||
|
.Include(x => x.AuthorUser)
|
||||||
|
.ThenInclude(x => x.PrivacySettings)
|
||||||
.Include(x => x.Attachments)
|
.Include(x => x.Attachments)
|
||||||
.Include(x => x.Views)
|
.Include(x => x.Views)
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
@@ -79,7 +91,10 @@ public sealed class StoriesController(
|
|||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(story.ToDto(presenceTracker, userId));
|
return Ok(story.ToDto(
|
||||||
|
presenceTracker,
|
||||||
|
userId,
|
||||||
|
sharesChatWithViewer: peerAuthorIds.Contains(story.AuthorUserId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -110,6 +125,7 @@ public sealed class StoriesController(
|
|||||||
|
|
||||||
var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken)
|
var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken)
|
||||||
?? throw new InvalidOperationException("The created story could not be reloaded.");
|
?? throw new InvalidOperationException("The created story could not be reloaded.");
|
||||||
|
await NotifyStoriesChangedAsync(loaded, cancellationToken);
|
||||||
return Ok(loaded.ToDto(presenceTracker, userId));
|
return Ok(loaded.ToDto(presenceTracker, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +158,12 @@ public sealed class StoriesController(
|
|||||||
return BadRequest($"Story upload is limited to {MaxStoryUploadSizeBytes} bytes.");
|
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 now = DateTimeOffset.UtcNow;
|
||||||
var ttlSeconds = Math.Clamp(request.TtlSeconds, 60, 7 * 24 * 60 * 60);
|
var ttlSeconds = Math.Clamp(request.TtlSeconds, 60, 7 * 24 * 60 * 60);
|
||||||
var story = new Story
|
var story = new Story
|
||||||
@@ -180,6 +202,7 @@ public sealed class StoriesController(
|
|||||||
|
|
||||||
var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken)
|
var loaded = await LoadStoryForAuthorAsync(story.Id, userId, cancellationToken)
|
||||||
?? throw new InvalidOperationException("The created story could not be reloaded.");
|
?? throw new InvalidOperationException("The created story could not be reloaded.");
|
||||||
|
await NotifyStoriesChangedAsync(loaded, cancellationToken);
|
||||||
return Ok(loaded.ToDto(presenceTracker, userId));
|
return Ok(loaded.ToDto(presenceTracker, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +231,7 @@ public sealed class StoriesController(
|
|||||||
story.DeletedAt = DateTimeOffset.UtcNow;
|
story.DeletedAt = DateTimeOffset.UtcNow;
|
||||||
await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.deleted", cancellationToken);
|
await AddModerationLogAsync(userId, ModerationLogTargetType.Story, story.Id, "story.deleted", cancellationToken);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotifyStoriesChangedAsync(story, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
@@ -217,6 +241,7 @@ public sealed class StoriesController(
|
|||||||
public async Task<ActionResult<IReadOnlyList<StoryViewDto>>> GetStoryViews(Guid storyId, CancellationToken cancellationToken)
|
public async Task<ActionResult<IReadOnlyList<StoryViewDto>>> GetStoryViews(Guid storyId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userId = User.GetRequiredUserId();
|
var userId = User.GetRequiredUserId();
|
||||||
|
var peerAuthorIds = await LoadStoryPeerAuthorIdsAsync(userId, cancellationToken);
|
||||||
var storyExists = await dbContext.Stories
|
var storyExists = await dbContext.Stories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken);
|
.AnyAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken);
|
||||||
@@ -229,13 +254,21 @@ public sealed class StoriesController(
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(x => x.ViewerUser)
|
.Include(x => x.ViewerUser)
|
||||||
.ThenInclude(x => x.Sessions)
|
.ThenInclude(x => x.Sessions)
|
||||||
|
.Include(x => x.ViewerUser)
|
||||||
|
.ThenInclude(x => x.PrivacySettings)
|
||||||
.Where(x => x.StoryId == storyId)
|
.Where(x => x.StoryId == storyId)
|
||||||
.OrderByDescending(x => x.ViewedAt)
|
.OrderByDescending(x => x.ViewedAt)
|
||||||
.Take(250)
|
.Take(250)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return Ok(views
|
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());
|
.ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,14 +315,53 @@ public sealed class StoriesController(
|
|||||||
.ToList();
|
.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) =>
|
private async Task<Story?> LoadStoryForAuthorAsync(Guid storyId, Guid userId, CancellationToken cancellationToken) =>
|
||||||
await dbContext.Stories
|
await dbContext.Stories
|
||||||
.Include(x => x.AuthorUser)
|
.Include(x => x.AuthorUser)
|
||||||
.ThenInclude(x => x.Sessions)
|
.ThenInclude(x => x.Sessions)
|
||||||
|
.Include(x => x.AuthorUser)
|
||||||
|
.ThenInclude(x => x.PrivacySettings)
|
||||||
.Include(x => x.Attachments)
|
.Include(x => x.Attachments)
|
||||||
.Include(x => x.Views)
|
.Include(x => x.Views)
|
||||||
.SingleOrDefaultAsync(x => x.Id == storyId && x.AuthorUserId == userId, cancellationToken);
|
.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(
|
private async Task AddModerationLogAsync(
|
||||||
Guid actorUserId,
|
Guid actorUserId,
|
||||||
ModerationLogTargetType targetType,
|
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