Compare commits
78 Commits
9296695ed5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b40dea18f1 | |||
| 28cb80fbb8 | |||
| 9af7597f8b | |||
| e6f1727800 | |||
| cf53123724 | |||
| 2fa006747d | |||
| 4032b55b0b | |||
| a1163be30b | |||
| d649cf1cb4 | |||
| e5e4fd653e | |||
| f88d9a2a36 | |||
| 27f2ad8001 | |||
| d54dc9fe8b | |||
| 6e9e580b3f | |||
| 43c3fd0169 | |||
| 3f9aa83110 | |||
| 2ffc4cce09 | |||
| e591a3fa8d | |||
| e0728ac067 | |||
| c5c1db98ad | |||
| 92c4cba1b0 | |||
| 60d898bf21 | |||
| 732b21a4e3 | |||
| 10676e34ad | |||
| 3bc540e46d | |||
| 0510a2717a | |||
| cdb45abb21 | |||
| cd7fb878b3 | |||
| a4fd60919e | |||
|
|
3c9b97e102 | ||
|
|
f8ed889170 | ||
|
|
3844875d36 | ||
|
|
27fba86915 | ||
|
|
58b554731d | ||
|
|
2a72437d28 | ||
|
|
8522e32aea | ||
|
|
e3fdccdeaa | ||
|
|
23d636be7e | ||
|
|
842a9d2093 | ||
|
|
63c0cd098e | ||
|
|
fbe4db02ca | ||
|
|
7f1b0e09c5 | ||
|
|
f7b9753c2e | ||
|
|
e4ea18242a | ||
|
|
0208fbc5cc | ||
|
|
22ee59fd74 | ||
|
|
f7ef10b011 | ||
|
|
78934a5f28 | ||
|
|
0beb52e438 | ||
|
|
10e188b615 | ||
|
|
47365bba57 | ||
|
|
55af1f78b6 | ||
|
|
7781cf83e4 | ||
|
|
5a0bb9ff08 | ||
|
|
90c25c5eb8 | ||
|
|
2ed0e1f041 | ||
|
|
580a6683e3 | ||
|
|
4aa4946e82 | ||
|
|
895c132eb2 | ||
|
|
1099efc8c0 | ||
|
|
e21a54e2bf | ||
|
|
148870de14 | ||
|
|
158126555c | ||
|
|
eae6a2a90f | ||
|
|
bb1f59d1f4 | ||
|
|
4bab551f0e | ||
|
|
c609a7d72d | ||
|
|
09a77bd4d7 | ||
|
|
0bd7e1cd21 | ||
|
|
15f9836224 | ||
|
|
cdf7859668 | ||
|
|
daddbfd2a0 | ||
|
|
19471ac736 | ||
|
|
15e80262e0 | ||
|
|
5921215718 | ||
|
|
d54eb400c7 | ||
|
|
28b549e53e | ||
|
|
e44e8d1355 |
@@ -35,6 +35,7 @@ SMTP_USE_SSL=false
|
||||
SMTP_TIMEOUT_SECONDS=10
|
||||
SMTP_FROM_EMAIL=no-reply@benyamessenger.local
|
||||
FIREBASE_ENABLED=false
|
||||
FIREBASE_CREDENTIALS_HOST_PATH=./secrets/firebase-service-account.json
|
||||
FIREBASE_CREDENTIALS_PATH=
|
||||
FIREBASE_CREDENTIALS_JSON=
|
||||
FIREBASE_WEBPUSH_LINK=https://chat.daemonlord.ru/
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ test.db
|
||||
web/node_modules
|
||||
web/dist
|
||||
web/tsconfig.tsbuildinfo
|
||||
secrets/
|
||||
|
||||
@@ -700,3 +700,326 @@
|
||||
- unregister token on logout,
|
||||
- handle foreground push payload via existing notification service worker.
|
||||
- Added required env keys to `web/.env.example` and backend Firebase env keys to root `.env.example`.
|
||||
|
||||
### Step 106 - Unread counter stabilization in Chat screen
|
||||
- Fixed read acknowledgement strategy in `ChatViewModel`:
|
||||
- read status is now acknowledged by the latest visible message id in chat (not only latest incoming),
|
||||
- delivery status still uses latest incoming message.
|
||||
- This removes cases where unread badge reappears after chat list refresh because the previous read ack used an outdated incoming id.
|
||||
|
||||
### Step 107 - Read-on-visible + cross-device unread sync
|
||||
- Implemented read acknowledgement from actual visible messages in `ChatScreen`:
|
||||
- tracks visible `LazyColumn` rows and sends read up to max visible incoming message id.
|
||||
- unread now drops as messages appear on screen while scrolling.
|
||||
- Improved cross-device sync (web <-> android):
|
||||
- `message_read` realtime event now parses `user_id` and `last_read_message_id`.
|
||||
- on `message_read`, Android refreshes chat snapshot from backend to keep unread counters aligned across devices.
|
||||
|
||||
### Step 108 - Strict read boundary by visible incoming only
|
||||
- Removed fallback read-pointer advancement in `ChatViewModel.acknowledgeLatestMessages(...)` that previously moved `lastReadMessageId` by latest loaded message id.
|
||||
- Read pointer is now advanced only via `onVisibleIncomingMessageId(...)` from visible incoming rows in `ChatScreen`.
|
||||
- This prevents read acknowledgements from overshooting beyond what user actually saw during refresh/recompose scenarios.
|
||||
|
||||
### Step 109 - Telegram-like Settings/Profile visual refresh
|
||||
- Redesigned `SettingsScreen` to Telegram-inspired dark card layout:
|
||||
- profile header card with avatar/name/email/username,
|
||||
- grouped settings rows with material icons,
|
||||
- appearance controls (Light/Dark/System),
|
||||
- quick security/help sections and preserved logout/back actions.
|
||||
- Redesigned `ProfileScreen` to Telegram-inspired structure:
|
||||
- gradient hero header with centered avatar, status, and action buttons,
|
||||
- primary profile info card,
|
||||
- tab-like section (`Posts/Archived/Gifts`) with placeholder content,
|
||||
- inline edit card (name/username/bio/avatar URL) with existing save/upload behavior preserved.
|
||||
|
||||
### Step 110 - Multi-account foundation (switch active account)
|
||||
- Extended `TokenRepository` to support account list and active-account switching:
|
||||
- observe/list stored accounts,
|
||||
- get active account id,
|
||||
- switch/remove account,
|
||||
- clear all tokens.
|
||||
- Reworked `EncryptedPrefsTokenRepository` storage model:
|
||||
- stores tokens per `userId` and account metadata list in encrypted prefs,
|
||||
- migrates legacy single-account keys on first run,
|
||||
- preserves active account pointer.
|
||||
- `NetworkAuthRepository` now upserts account metadata after auth/me calls.
|
||||
- Added `Settings` UI account section:
|
||||
- shows saved accounts,
|
||||
- allows switch/remove,
|
||||
- triggers auth recheck + chats reload on switch.
|
||||
|
||||
### Step 111 - Real Settings + persistent theme + add-account UX
|
||||
- Implemented persistent app theme storage via DataStore (`ThemeRepository`) and applied theme mode on app start in `MainActivity`.
|
||||
- Reworked `SettingsScreen` to contain only working settings and actions:
|
||||
- multi-account list (`Switch`/`Remove`) + `Add account` dialog with email/password sign-in,
|
||||
- appearance (`Light`/`Dark`/`System`) wired to persisted theme,
|
||||
- notifications (`global` + `preview`) wired to `NotificationSettingsRepository`,
|
||||
- privacy update, blocked users management, sessions revoke/revoke-all, 2FA controls.
|
||||
- Updated `ProfileScreen` to follow current app theme colors instead of forced dark palette.
|
||||
|
||||
### Step 112 - Settings cleanup (privacy dropdowns + removed extra blocks)
|
||||
- Replaced free-text privacy inputs with dropdown selectors (`everyone`, `contacts`, `nobody`) for:
|
||||
- private messages,
|
||||
- last seen,
|
||||
- avatar visibility,
|
||||
- group invites.
|
||||
- Removed direct `block by user id` controls from Settings UI as requested.
|
||||
- Removed extra bottom Settings actions (`Profile` row and `Back to chats` button) and kept categorized section layout.
|
||||
|
||||
### Step 113 - Auth flow redesign (email -> password/register -> 2FA) + startup no-flicker
|
||||
- Added step-based auth domain/use-cases for:
|
||||
- `GET /api/v1/auth/check-email`
|
||||
- `POST /api/v1/auth/register`
|
||||
- login with optional `otp_code` / `recovery_code`.
|
||||
- Updated Android login UI to multi-step flow:
|
||||
- step 1: email input,
|
||||
- step 2: password for existing account or register form (`name`, `username`, `password`) for new account,
|
||||
- step 3: 2FA OTP/recovery code when backend requires it.
|
||||
- Improved login error mapping for 2FA-required responses, so app switches to OTP step instead of generic invalid-password message.
|
||||
- Removed auth screen flash on startup:
|
||||
- introduced dedicated `startup` route with session-check loader,
|
||||
- delayed auth/chats navigation until session check is finished.
|
||||
- Added safe fallback in `MainActivity` theme bootstrap to prevent crash if `ThemeRepository` injection is unexpectedly unavailable during startup.
|
||||
|
||||
### Step 114 - Multi-account switch sync fix (chats + realtime)
|
||||
- Fixed account switch flow to fully rebind app data context:
|
||||
- restart realtime socket on new active account token,
|
||||
- force refresh chats for both `archived=false` and `archived=true` right after switch.
|
||||
- Fixed navigation behavior on account switch to avoid noisy `popBackStack ... not found` and stale restored stack state.
|
||||
|
||||
### Step 115 - Settings UI restructured into Telegram-like folders
|
||||
- Reworked Settings into a menu-first screen with Telegram-style grouped rows.
|
||||
- Added per-item folder pages (subscreens) for:
|
||||
- Account
|
||||
- Chat settings
|
||||
- Privacy
|
||||
- Notifications
|
||||
- Devices
|
||||
- Data/Chat folders/Power/Language placeholders
|
||||
- Kept theme logic intact and moved appearance controls into `Chat settings` folder.
|
||||
|
||||
### Step 116 - Profile cleanup (remove non-working extras)
|
||||
- Removed non-functional profile tabs and placeholder blocks:
|
||||
- `Posts`
|
||||
- `Archived`
|
||||
- `Gifts`
|
||||
- Removed `Settings` hero button from profile header.
|
||||
- Removed bottom `Back to chats` button from profile screen.
|
||||
- Simplified profile layout so the editable profile form is the primary secondary section toggled by `Edit`.
|
||||
- Updated `ProfileRoute` navigation contract to match the simplified screen API.
|
||||
|
||||
### Step 117 - Settings folders cleanup (remove back button action)
|
||||
- Removed `Back to chats` button from all Settings folder pages.
|
||||
- Simplified Settings navigation contract by removing unused `onBackToChats` parameter from:
|
||||
- `SettingsRoute`
|
||||
- `SettingsScreen`
|
||||
- `SettingsFolderView`
|
||||
- Updated `AppNavGraph` Settings destination call-site accordingly.
|
||||
|
||||
### Step 118 - Android push notifications grouped by chat
|
||||
- Reworked `NotificationDispatcher` to aggregate incoming messages into one notification per chat:
|
||||
- stable notification id per `chatId`,
|
||||
- per-chat unread counter,
|
||||
- multi-line inbox preview of recent messages.
|
||||
- Added app-level summary notification that groups all active chat notifications.
|
||||
- Added deduplication guard for repeated push deliveries of the same `messageId`.
|
||||
- Added notification cleanup on chat open:
|
||||
- when push-open intent targets a chat in `MainActivity`,
|
||||
- when `ChatViewModel` enters a chat directly from app UI.
|
||||
|
||||
### Step 119 - Chat screen visual baseline (Telegram-like start)
|
||||
- Reworked chat top bar:
|
||||
- icon back button instead of text button,
|
||||
- cleaner title/subtitle styling,
|
||||
- dedicated search icon in top bar (inline search is now collapsible).
|
||||
- Updated pinned message strip:
|
||||
- cleaner card styling,
|
||||
- close icon action instead of full text button.
|
||||
- Updated composer baseline:
|
||||
- icon-based emoji/attach/send/mic controls,
|
||||
- cleaner container styling closer to Telegram-like bottom bar.
|
||||
|
||||
### Step 120 - Message bubble layout pass (Telegram-like geometry)
|
||||
- Reworked `MessageBubble` structure and density:
|
||||
- cleaner outgoing/incoming bubble geometry,
|
||||
- improved max width and alignment behavior,
|
||||
- tighter paddings and spacing for mobile density.
|
||||
- Redesigned forwarded/reply blocks:
|
||||
- compact forwarded caption styling,
|
||||
- reply block with accent stripe and nested preview text.
|
||||
- Improved message meta line:
|
||||
- cleaner time + status line placement and contrast.
|
||||
- Refined reactions and attachments rendering inside bubbles:
|
||||
- chip-like reaction containers,
|
||||
- rounded image/file/media surfaces closer to Telegram-like visual rhythm.
|
||||
|
||||
### Step 121 - Chat selection and message action UX cleanup
|
||||
- Added Telegram-like multi-select top bar in chat:
|
||||
- close selection,
|
||||
- selected counter,
|
||||
- quick forward/delete actions.
|
||||
- Simplified tap action menu flow for single message:
|
||||
- richer reaction row (`❤️ 👍 👎 🔥 🥰 👏 😁`),
|
||||
- reply/edit/forward/delete actions kept in one sheet.
|
||||
- Removed duplicate/conflicting selection controls between top and bottom action rows.
|
||||
|
||||
### Step 122 - Chat 3-dot menu + chat info media tabs shell
|
||||
- Added chat header `3-dot` popup menu with Telegram-like actions:
|
||||
- `Chat info`
|
||||
- `Search`
|
||||
- `Notifications`
|
||||
- `Change wallpaper`
|
||||
- `Clear history`
|
||||
- Added `Chat info` bottom sheet with tabbed sections:
|
||||
- `Media`
|
||||
- `Files`
|
||||
- `Links`
|
||||
- `Voice`
|
||||
- Implemented local tab content from current loaded chat messages/attachments to provide immediate media/files/links/voice overview.
|
||||
|
||||
### Step 123 - Chat info visual pass (Telegram-like density)
|
||||
- Updated `Chat info` tabs to pill-style horizontal chips with tighter Telegram-like spacing.
|
||||
- Improved tab content rendering:
|
||||
- `Media` now uses a 3-column thumbnail grid.
|
||||
- `Files / Links / Voice` use denser card rows with icon+meta layout.
|
||||
- `Voice` rows now show a dedicated play affordance.
|
||||
- Refined menu order in chat `3-dot` popup and kept actions consistent with current no-calls scope.
|
||||
|
||||
### Step 124 - Inline search close fix + message menu visual pass
|
||||
- Fixed inline chat search UX:
|
||||
- added explicit close button in the search row,
|
||||
- closing search now also clears active query/filter without re-entering chat.
|
||||
- Added automatic inline-search collapse when entering multi-select mode.
|
||||
- Reworked message tap action sheet to Telegram-like list actions with icons and clearer destructive `Delete` row styling.
|
||||
|
||||
### Step 125 - Chat header/top strips visual refinement
|
||||
- Refined chat header density and typography to be closer to Telegram-like proportions.
|
||||
- Updated pinned strip visual:
|
||||
- accent vertical marker,
|
||||
- tighter spacing,
|
||||
- cleaner title/content hierarchy.
|
||||
- Added top mini audio strip under pinned area:
|
||||
- shows latest audio/voice context from loaded chat,
|
||||
- includes play affordance, speed badge, and dismiss action.
|
||||
|
||||
### Step 126 - Message bubble/composer micro-polish
|
||||
- Updated message bubble sizing and density:
|
||||
- reduced bubble width for cleaner conversation rhythm,
|
||||
- tighter vertical spacing,
|
||||
- text style adjusted for better readability.
|
||||
- Refined bottom composer visuals:
|
||||
- switched to Telegram-like rounded input container look,
|
||||
- emoji/attach/send buttons now use circular tinted surfaces,
|
||||
- text input moved to filled style with hidden indicator lines.
|
||||
|
||||
### Step 127 - Top audio strip behavior fix (playback-driven)
|
||||
- Reworked top audio strip logic to be playback-driven instead of always-on:
|
||||
- strip appears only when user starts audio/voice playback,
|
||||
- strip switches to the currently playing file,
|
||||
- strip auto-hides when playback stops.
|
||||
- Added close (`X`) behavior that hides the strip and force-stops the currently playing source.
|
||||
|
||||
### Step 128 - Parity docs update: text formatting gap
|
||||
- Synced Android parity documentation with web-core status:
|
||||
- added explicit `Text formatting parity` gap to `docs/android-checklist.md`,
|
||||
- added dedicated Android gap block in `docs/backend-web-android-parity.md` for formatting parity.
|
||||
- Marked formatting parity as part of highest-priority Android parity block.
|
||||
|
||||
### Step 129 - Parity block (1/3/4/5/6): formatting, notifications inbox, resend verification, push sync
|
||||
- Completed Android text formatting parity in chat:
|
||||
- composer toolbar actions for `bold/italic/underline/strikethrough`,
|
||||
- spoiler, inline code, code block, quote, link insertion,
|
||||
- message bubble rich renderer for web-style markdown tokens and clickable links.
|
||||
- Added server notifications inbox flow in account/settings:
|
||||
- API wiring for `GET /api/v1/notifications`,
|
||||
- domain mapping and recent-notifications UI section.
|
||||
- Added resend verification support on Android:
|
||||
- API wiring for `POST /api/v1/auth/resend-verification`,
|
||||
- Verify Email screen action for resending link by email.
|
||||
- Hardened push token lifecycle sync:
|
||||
- token registration dedupe by `(userId, token)`,
|
||||
- marker cleanup on logout,
|
||||
- best-effort re-sync after account switch.
|
||||
- Notification delivery polish (foundation):
|
||||
- foreground notification body now respects preview setting and falls back to media-type labels when previews are disabled.
|
||||
- Verified with successful `:app:compileDebugKotlin` and `:app:assembleDebug`.
|
||||
|
||||
### Step 130 - Chat UX pack: video viewer, emoji/GIF/sticker picker, day separators
|
||||
- Added chat timeline day separators with Telegram-like chips:
|
||||
- `Сегодня`, `Вчера`, or localized date labels.
|
||||
- Added fullscreen video viewer:
|
||||
- video attachments now open in a fullscreen overlay with close action.
|
||||
- Added composer media picker sheet:
|
||||
- tabs: `Эмодзи`, `GIF`, `Стикеры`,
|
||||
- emoji insertion at cursor,
|
||||
- remote GIF/sticker selection with download+send flow.
|
||||
- Extended media type mapping in message send pipeline:
|
||||
- GIFs now sent as `gif`,
|
||||
- sticker-like payloads sent as `sticker` (filename/mime detection).
|
||||
- Added missing fallback resource theme declarations (`themes.xml`) to stabilize clean debug assembly on local builds.
|
||||
|
||||
### Step 131 - Channel chat Telegram-like visual alignment
|
||||
- Added channel-aware chat rendering path:
|
||||
- `MessageUiState` now carries `chatType` from `ChatViewModel`,
|
||||
- channel timeline bubbles are rendered as wider post-like cards (left-aligned feed style).
|
||||
- Refined channel message status presentation:
|
||||
- post cards now show cleaner timestamp-only footer instead of direct-message style checks.
|
||||
- Added dedicated read-only channel bottom bar (for non owner/admin):
|
||||
- compact Telegram-like controls with search + centered `Включить звук` action + notifications icon.
|
||||
- Kept existing full composer for roles allowed to post in channels (owner/admin).
|
||||
|
||||
### Step 132 - Voice recording composer overlap fix
|
||||
- Fixed composer overlap during voice recording:
|
||||
- recording status/hint is now rendered in a dedicated top block inside composer,
|
||||
- formatting toolbar is hidden while recording is active.
|
||||
- Prevented controls collision for locked-recording actions:
|
||||
- `Cancel/Send` now render on a separate row in locked state.
|
||||
|
||||
### Step 133 - Video/audio player controls upgrade
|
||||
- Upgraded fullscreen video viewer controls:
|
||||
- play/pause button,
|
||||
- seek slider (scrubbing),
|
||||
- current time / total duration labels.
|
||||
- Upgraded attachment audio player behavior (voice + audio):
|
||||
- added seek slider for manual rewind/fast-forward,
|
||||
- unified speed toggle for both `voice` and `audio` playback.
|
||||
|
||||
### Step 134 - Hilt startup crash fix (`MessengerApplication_GeneratedInjector`)
|
||||
- Fixed startup crash:
|
||||
- `NoClassDefFoundError: MessengerApplication_GeneratedInjector`.
|
||||
- Root cause observed in build pipeline:
|
||||
- `MessengerApplication_GeneratedInjector.class` existed after `javac`,
|
||||
- but was missing in `transformDebugClassesWithAsm/dirs` before dexing.
|
||||
- Added Gradle backfill task for `debug/release` variants:
|
||||
- copies `*Application_GeneratedInjector.class` from `intermediates/javac/.../classes`
|
||||
into `intermediates/classes/.../transform...ClassesWithAsm/dirs` if missing,
|
||||
- wired task as dependency of `dexBuilder<Variant>`.
|
||||
|
||||
### Step 135 - AppCompat launch crash fix (theme mismatch)
|
||||
- Fixed `MainActivity` startup crash:
|
||||
- `IllegalStateException: You need to use a Theme.AppCompat theme`.
|
||||
- Root cause:
|
||||
- `Theme.AppCompat.DayNight.NoActionBar` was accidentally overridden in app resources
|
||||
with non-AppCompat parent (`Theme.DeviceDefault.NoActionBar`).
|
||||
- Fix applied:
|
||||
- introduced dedicated app theme `Theme.Messenger` with parent `Theme.AppCompat.DayNight.NoActionBar`,
|
||||
- switched `AndroidManifest.xml` application theme to `@style/Theme.Messenger`.
|
||||
|
||||
### Step 136 - Message context menu dismiss selection fix
|
||||
- Fixed chat bug after closing message context menu by tapping outside:
|
||||
- selection state now clears on `ModalBottomSheet` dismiss,
|
||||
- prevents stale single-selection action bar from appearing after menu close.
|
||||
|
||||
### Step 137 - Telegram-like message actions cleanup
|
||||
- Removed legacy single-selection bottom action bar (`Close/Delete/Del for all/Edit`) in chat.
|
||||
- Message actions are now driven by Telegram-like context UI:
|
||||
- tap -> context sheet actions,
|
||||
- long-press -> selection mode flow.
|
||||
|
||||
### Step 138 - Multi-select UX closer to Telegram
|
||||
- Refined selection top bar:
|
||||
- removed extra overflow/load action from selection mode,
|
||||
- kept focused actions only: close, selected count, forward, delete.
|
||||
- In `MULTI` selection mode, composer is now replaced with a compact bottom action row:
|
||||
- `Reply` (enabled for single selected message),
|
||||
- `Forward`.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
@@ -9,6 +11,15 @@ plugins {
|
||||
id("com.google.firebase.crashlytics")
|
||||
}
|
||||
|
||||
val localProperties = Properties().apply {
|
||||
val file = rootProject.file("local.properties")
|
||||
if (file.exists()) {
|
||||
file.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun String.escapeForBuildConfig(): String = replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
|
||||
android {
|
||||
namespace = "ru.daemonlord.messenger"
|
||||
compileSdk = 35
|
||||
@@ -21,6 +32,12 @@ android {
|
||||
versionName = "0.1.0"
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
|
||||
buildConfigField("String", "API_VERSION_HEADER", "\"2026-03\"")
|
||||
val giphyApiKey = (
|
||||
localProperties.getProperty("GIPHY_API_KEY")
|
||||
?: System.getenv("GIPHY_API_KEY")
|
||||
?: ""
|
||||
).trim()
|
||||
buildConfigField("String", "GIPHY_API_KEY", "\"${giphyApiKey.escapeForBuildConfig()}\"")
|
||||
buildConfigField("boolean", "FEATURE_ACCOUNT_MANAGEMENT", "true")
|
||||
buildConfigField("boolean", "FEATURE_TWO_FACTOR", "true")
|
||||
buildConfigField("boolean", "FEATURE_MEDIA_GALLERY", "true")
|
||||
@@ -84,9 +101,17 @@ dependencies {
|
||||
implementation("androidx.compose.material:material-icons-extended:1.7.6")
|
||||
implementation("io.coil-kt:coil:2.7.0")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
implementation("io.coil-kt:coil-gif:2.7.0")
|
||||
implementation("io.coil-kt:coil-video:2.7.0")
|
||||
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
||||
implementation("androidx.media3:media3-ui:1.4.1")
|
||||
implementation("androidx.media3:media3-datasource:1.4.1")
|
||||
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
|
||||
implementation("androidx.camera:camera-core:1.4.2")
|
||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.4.2")
|
||||
implementation("androidx.camera:camera-video:1.4.2")
|
||||
implementation("androidx.camera:camera-view:1.4.2")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
@@ -127,3 +152,37 @@ dependencies {
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
|
||||
fun registerHiltInjectorBackfillTask(variantName: String) {
|
||||
val cap = variantName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
val taskName = "backfill${cap}HiltApplicationInjector"
|
||||
val dexBuilderTaskName = "dexBuilder$cap"
|
||||
val compileJavaTaskName = "compile${cap}JavaWithJavac"
|
||||
|
||||
tasks.register(taskName) {
|
||||
dependsOn(compileJavaTaskName)
|
||||
doLast {
|
||||
val javacOutput = file("$buildDir/intermediates/javac/$variantName/$compileJavaTaskName/classes")
|
||||
val asmOutput = file("$buildDir/intermediates/classes/$variantName/transform${cap}ClassesWithAsm/dirs")
|
||||
if (!javacOutput.exists() || !asmOutput.exists()) return@doLast
|
||||
|
||||
fileTree(javacOutput) {
|
||||
include("**/*Application_GeneratedInjector.class")
|
||||
}.forEach { source ->
|
||||
val relativePath = source.relativeTo(javacOutput).path
|
||||
val target = file("${asmOutput.path}/$relativePath")
|
||||
if (!target.exists()) {
|
||||
target.parentFile?.mkdirs()
|
||||
source.copyTo(target, overwrite = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == dexBuilderTaskName }.configureEach {
|
||||
dependsOn(taskName)
|
||||
}
|
||||
}
|
||||
|
||||
registerHiltInjectorBackfillTask("debug")
|
||||
registerHiltInjectorBackfillTask("release")
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:name=".MessengerApplication"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
|
||||
android:theme="@style/Theme.Messenger">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
@@ -46,6 +48,15 @@
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -12,13 +12,30 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
|
||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
|
||||
import ru.daemonlord.messenger.ui.theme.MessengerTheme
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@Inject
|
||||
lateinit var themeRepository: ThemeRepository
|
||||
|
||||
@Inject
|
||||
lateinit var languageRepository: LanguageRepository
|
||||
|
||||
@Inject
|
||||
lateinit var notificationDispatcher: NotificationDispatcher
|
||||
|
||||
private var pendingInviteToken by mutableStateOf<String?>(null)
|
||||
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
|
||||
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
|
||||
@@ -27,12 +44,32 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val savedThemeMode = if (this::themeRepository.isInitialized) {
|
||||
runBlocking { themeRepository.getThemeMode() }
|
||||
} else {
|
||||
AppThemeMode.SYSTEM
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (savedThemeMode) {
|
||||
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
)
|
||||
val savedLanguageTag = if (this::languageRepository.isInitialized) {
|
||||
runBlocking { languageRepository.getLanguage().tag }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val locales = savedLanguageTag?.let { LocaleListCompat.forLanguageTags(it) } ?: LocaleListCompat.getEmptyLocaleList()
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
pendingInviteToken = intent.extractInviteToken()
|
||||
pendingVerifyEmailToken = intent.extractVerifyEmailToken()
|
||||
pendingResetPasswordToken = intent.extractResetPasswordToken()
|
||||
val notificationPayload = intent.extractNotificationOpenPayload()
|
||||
pendingNotificationChatId = notificationPayload?.first
|
||||
pendingNotificationMessageId = notificationPayload?.second
|
||||
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MessengerTheme {
|
||||
@@ -66,6 +103,7 @@ class MainActivity : AppCompatActivity() {
|
||||
if (notificationPayload != null) {
|
||||
pendingNotificationChatId = notificationPayload.first
|
||||
pendingNotificationMessageId = notificationPayload.second
|
||||
notificationDispatcher.clearChatNotifications(notificationPayload.first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package ru.daemonlord.messenger
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
@@ -35,6 +38,13 @@ class MessengerApplication : Application(), ImageLoaderFactory {
|
||||
diskCacheDir.mkdirs()
|
||||
}
|
||||
return ImageLoader.Builder(this)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
|
||||
@@ -15,6 +15,8 @@ import kotlin.math.abs
|
||||
class NotificationDispatcher @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val chatStates = linkedMapOf<Long, ChatNotificationState>()
|
||||
|
||||
fun showChatMessage(payload: ChatNotificationPayload) {
|
||||
NotificationChannels.ensureCreated(context)
|
||||
val channelId = if (payload.isMention) {
|
||||
@@ -22,34 +24,126 @@ class NotificationDispatcher @Inject constructor(
|
||||
} else {
|
||||
NotificationChannels.CHANNEL_MESSAGES
|
||||
}
|
||||
|
||||
val state = synchronized(chatStates) {
|
||||
val existing = chatStates[payload.chatId]
|
||||
if (existing != null && payload.messageId != null && existing.lastMessageId == payload.messageId) {
|
||||
return
|
||||
}
|
||||
val updated = (existing ?: ChatNotificationState(title = payload.title))
|
||||
.copy(title = payload.title)
|
||||
.appendMessage(payload.body, payload.messageId)
|
||||
chatStates[payload.chatId] = updated
|
||||
updated
|
||||
}
|
||||
|
||||
val openIntent = Intent(context, MainActivity::class.java)
|
||||
.putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId)
|
||||
.putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId(payload.chatId, payload.messageId),
|
||||
chatNotificationId(payload.chatId),
|
||||
openIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val contentText = when {
|
||||
state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body
|
||||
else -> "${state.unreadCount} new messages"
|
||||
}
|
||||
val inboxStyle = NotificationCompat.InboxStyle()
|
||||
.setBigContentTitle(state.title)
|
||||
.setSummaryText("${state.unreadCount} messages")
|
||||
state.lines.reversed().forEach { inboxStyle.addLine(it) }
|
||||
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(payload.title)
|
||||
.setContentText(payload.body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(payload.body))
|
||||
.setContentTitle(state.title)
|
||||
.setContentText(contentText)
|
||||
.setStyle(inboxStyle)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setGroup("chat_${payload.chatId}")
|
||||
.setGroup(GROUP_KEY_CHATS)
|
||||
.setOnlyAlertOnce(false)
|
||||
.setNumber(state.unreadCount)
|
||||
.setPriority(
|
||||
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
|
||||
)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(context).notify(notificationId(payload.chatId, payload.messageId), notification)
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.notify(chatNotificationId(payload.chatId), notification)
|
||||
showSummaryNotification(manager)
|
||||
}
|
||||
|
||||
private fun notificationId(chatId: Long, messageId: Long?): Int {
|
||||
val raw = (chatId * 1_000_003L) + (messageId ?: 0L)
|
||||
return abs(raw.toInt())
|
||||
fun clearChatNotifications(chatId: Long) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
synchronized(chatStates) {
|
||||
chatStates.remove(chatId)
|
||||
}
|
||||
manager.cancel(chatNotificationId(chatId))
|
||||
showSummaryNotification(manager)
|
||||
}
|
||||
|
||||
private fun showSummaryNotification(manager: NotificationManagerCompat) {
|
||||
val snapshot = synchronized(chatStates) { chatStates.values.toList() }
|
||||
if (snapshot.isEmpty()) {
|
||||
manager.cancel(SUMMARY_NOTIFICATION_ID)
|
||||
return
|
||||
}
|
||||
val totalUnread = snapshot.sumOf { it.unreadCount }
|
||||
val inboxStyle = NotificationCompat.InboxStyle()
|
||||
.setBigContentTitle("Benya Messenger")
|
||||
.setSummaryText("$totalUnread messages")
|
||||
snapshot.take(6).forEach { state ->
|
||||
val preview = state.lines.firstOrNull().orEmpty()
|
||||
val line = if (preview.isBlank()) {
|
||||
"${state.title} (${state.unreadCount})"
|
||||
} else {
|
||||
"${state.title}: $preview (${state.unreadCount})"
|
||||
}
|
||||
inboxStyle.addLine(line)
|
||||
}
|
||||
val summary = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_MESSAGES)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Benya Messenger")
|
||||
.setContentText("$totalUnread new messages in ${snapshot.size} chats")
|
||||
.setStyle(inboxStyle)
|
||||
.setGroup(GROUP_KEY_CHATS)
|
||||
.setGroupSummary(true)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
manager.notify(SUMMARY_NOTIFICATION_ID, summary)
|
||||
}
|
||||
|
||||
private fun chatNotificationId(chatId: Long): Int {
|
||||
return abs((chatId * 1_000_003L).toInt())
|
||||
}
|
||||
|
||||
private data class ChatNotificationState(
|
||||
val title: String,
|
||||
val unreadCount: Int = 0,
|
||||
val lines: List<String> = emptyList(),
|
||||
val lastMessageId: Long? = null,
|
||||
) {
|
||||
fun appendMessage(body: String, messageId: Long?): ChatNotificationState {
|
||||
val normalized = body.trim().ifBlank { "New message" }
|
||||
val updatedLines = buildList {
|
||||
add(normalized)
|
||||
lines.forEach { add(it) }
|
||||
}.distinct().take(MAX_LINES)
|
||||
return copy(
|
||||
unreadCount = unreadCount + 1,
|
||||
lines = updatedLines,
|
||||
lastMessageId = messageId ?: lastMessageId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val GROUP_KEY_CHATS = "messenger_chats_group"
|
||||
private const val SUMMARY_NOTIFICATION_ID = 0x4D53_4752 // "MSGR"
|
||||
private const val MAX_LINES = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,40 @@ class DataStoreTokenRepository @Inject constructor(
|
||||
preferences.toTokenBundleOrNull()
|
||||
}
|
||||
|
||||
override fun observeAccounts(): Flow<List<StoredAccount>> {
|
||||
return observeTokens().map { tokens ->
|
||||
if (tokens == null) emptyList() else {
|
||||
val userId = tokens.accessToken.extractUserIdFromJwt() ?: return@map emptyList()
|
||||
listOf(
|
||||
StoredAccount(
|
||||
userId = userId,
|
||||
email = null,
|
||||
name = "User #$userId",
|
||||
username = null,
|
||||
avatarUrl = null,
|
||||
lastActiveAt = tokens.savedAtMillis,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun observeActiveUserId(): Flow<Long?> {
|
||||
return observeTokens().map { it?.accessToken?.extractUserIdFromJwt() }
|
||||
}
|
||||
|
||||
override suspend fun getTokens(): TokenBundle? {
|
||||
return observeTokens().first()
|
||||
}
|
||||
|
||||
override suspend fun getAccounts(): List<StoredAccount> {
|
||||
return observeAccounts().first()
|
||||
}
|
||||
|
||||
override suspend fun getActiveUserId(): Long? {
|
||||
return observeActiveUserId().first()
|
||||
}
|
||||
|
||||
override suspend fun saveTokens(tokens: TokenBundle) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken
|
||||
@@ -32,6 +62,20 @@ class DataStoreTokenRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upsertAccount(account: StoredAccount) {
|
||||
// DataStoreTokenRepository is not used in production DI currently.
|
||||
}
|
||||
|
||||
override suspend fun switchAccount(userId: Long): Boolean {
|
||||
return getActiveUserId() == userId
|
||||
}
|
||||
|
||||
override suspend fun removeAccount(userId: Long) {
|
||||
if (getActiveUserId() == userId) {
|
||||
clearTokens()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearTokens() {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(ACCESS_TOKEN_KEY)
|
||||
@@ -40,6 +84,10 @@ class DataStoreTokenRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearAllTokens() {
|
||||
clearTokens()
|
||||
}
|
||||
|
||||
private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
|
||||
val access = this[ACCESS_TOKEN_KEY]
|
||||
val refresh = this[REFRESH_TOKEN_KEY]
|
||||
@@ -56,6 +104,32 @@ class DataStoreTokenRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.extractUserIdFromJwt(): Long? {
|
||||
val payload = split('.').getOrNull(1) ?: return null
|
||||
val normalized = payload
|
||||
.replace('-', '+')
|
||||
.replace('_', '/')
|
||||
.let { source ->
|
||||
when (source.length % 4) {
|
||||
0 -> source
|
||||
2 -> source + "=="
|
||||
3 -> source + "="
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
return runCatching {
|
||||
val json = String(java.util.Base64.getDecoder().decode(normalized), Charsets.UTF_8)
|
||||
val marker = "\"sub\":\""
|
||||
val start = json.indexOf(marker)
|
||||
if (start < 0) null
|
||||
else {
|
||||
val valueStart = start + marker.length
|
||||
val valueEnd = json.indexOf('"', valueStart)
|
||||
if (valueEnd <= valueStart) null else json.substring(valueStart, valueEnd).toLongOrNull()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
|
||||
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package ru.daemonlord.messenger.core.token
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.daemonlord.messenger.di.TokenPrefs
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -13,48 +16,277 @@ class EncryptedPrefsTokenRepository @Inject constructor(
|
||||
@TokenPrefs private val sharedPreferences: SharedPreferences,
|
||||
) : TokenRepository {
|
||||
|
||||
private val tokensFlow = MutableStateFlow(readTokens())
|
||||
private val tokensFlow = MutableStateFlow<TokenBundle?>(null)
|
||||
private val accountsFlow = MutableStateFlow<List<StoredAccount>>(emptyList())
|
||||
private val activeUserIdFlow = MutableStateFlow<Long?>(null)
|
||||
|
||||
init {
|
||||
migrateLegacyIfNeeded()
|
||||
refreshFlows()
|
||||
}
|
||||
|
||||
override fun observeTokens(): Flow<TokenBundle?> = tokensFlow.asStateFlow()
|
||||
|
||||
override fun observeAccounts(): Flow<List<StoredAccount>> = accountsFlow.asStateFlow()
|
||||
|
||||
override fun observeActiveUserId(): Flow<Long?> = activeUserIdFlow.asStateFlow()
|
||||
|
||||
override suspend fun getTokens(): TokenBundle? = tokensFlow.value
|
||||
|
||||
override suspend fun getAccounts(): List<StoredAccount> = accountsFlow.value
|
||||
|
||||
override suspend fun getActiveUserId(): Long? = activeUserIdFlow.value
|
||||
|
||||
override suspend fun saveTokens(tokens: TokenBundle) {
|
||||
sharedPreferences.edit()
|
||||
.putString(ACCESS_TOKEN_KEY, tokens.accessToken)
|
||||
.putString(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
||||
.putLong(SAVED_AT_KEY, tokens.savedAtMillis)
|
||||
.apply()
|
||||
tokensFlow.value = tokens
|
||||
val userId = tokens.accessToken.extractUserIdFromJwt()
|
||||
?: activeUserIdFlow.value
|
||||
?: return
|
||||
val allTokens = readAllTokenEntries().toMutableMap()
|
||||
allTokens[userId] = tokens
|
||||
writeAllTokenEntries(allTokens)
|
||||
writeActiveUserId(userId)
|
||||
ensureAccountPlaceholder(userId = userId, lastActiveAt = tokens.savedAtMillis)
|
||||
refreshFlows()
|
||||
}
|
||||
|
||||
override suspend fun upsertAccount(account: StoredAccount) {
|
||||
val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
|
||||
val existing = accounts[account.userId]
|
||||
accounts[account.userId] = account.copy(
|
||||
lastActiveAt = maxOf(existing?.lastActiveAt ?: 0L, account.lastActiveAt),
|
||||
)
|
||||
writeAccounts(accounts.values.toList())
|
||||
refreshFlows()
|
||||
}
|
||||
|
||||
override suspend fun switchAccount(userId: Long): Boolean {
|
||||
val allTokens = readAllTokenEntries()
|
||||
if (!allTokens.containsKey(userId)) {
|
||||
return false
|
||||
}
|
||||
writeActiveUserId(userId)
|
||||
refreshFlows()
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun removeAccount(userId: Long) {
|
||||
val allTokens = readAllTokenEntries().toMutableMap()
|
||||
allTokens.remove(userId)
|
||||
writeAllTokenEntries(allTokens)
|
||||
|
||||
val accounts = readAccounts().filterNot { it.userId == userId }
|
||||
writeAccounts(accounts)
|
||||
|
||||
val active = readActiveUserId()
|
||||
if (active == userId) {
|
||||
val nextUserId = allTokens.entries
|
||||
.maxByOrNull { it.value.savedAtMillis }
|
||||
?.key
|
||||
writeActiveUserId(nextUserId)
|
||||
}
|
||||
refreshFlows()
|
||||
}
|
||||
|
||||
override suspend fun clearTokens() {
|
||||
val active = readActiveUserId() ?: return
|
||||
removeAccount(active)
|
||||
}
|
||||
|
||||
override suspend fun clearAllTokens() {
|
||||
sharedPreferences.edit()
|
||||
.remove(TOKENS_JSON_KEY)
|
||||
.remove(ACCOUNTS_JSON_KEY)
|
||||
.remove(ACTIVE_USER_ID_KEY)
|
||||
.remove(ACCESS_TOKEN_KEY)
|
||||
.remove(REFRESH_TOKEN_KEY)
|
||||
.remove(SAVED_AT_KEY)
|
||||
.apply()
|
||||
refreshFlows()
|
||||
}
|
||||
|
||||
private fun refreshFlows() {
|
||||
val activeUserId = readActiveUserId()
|
||||
activeUserIdFlow.value = activeUserId
|
||||
tokensFlow.value = activeUserId?.let { readAllTokenEntries()[it] }
|
||||
accountsFlow.value = readAccounts().sortedByDescending { it.lastActiveAt }
|
||||
}
|
||||
|
||||
private fun migrateLegacyIfNeeded() {
|
||||
val hasModernStorage = sharedPreferences.contains(TOKENS_JSON_KEY)
|
||||
if (hasModernStorage) return
|
||||
|
||||
val legacyAccess = sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
|
||||
val legacyRefresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
|
||||
val legacySavedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L)
|
||||
if (legacyAccess.isNullOrBlank() || legacyRefresh.isNullOrBlank() || legacySavedAt <= 0L) {
|
||||
return
|
||||
}
|
||||
|
||||
val userId = legacyAccess.extractUserIdFromJwt() ?: return
|
||||
val token = TokenBundle(
|
||||
accessToken = legacyAccess,
|
||||
refreshToken = legacyRefresh,
|
||||
savedAtMillis = legacySavedAt,
|
||||
)
|
||||
writeAllTokenEntries(mapOf(userId to token))
|
||||
writeActiveUserId(userId)
|
||||
writeAccounts(
|
||||
listOf(
|
||||
StoredAccount(
|
||||
userId = userId,
|
||||
email = null,
|
||||
name = "User #$userId",
|
||||
username = null,
|
||||
avatarUrl = null,
|
||||
lastActiveAt = legacySavedAt,
|
||||
)
|
||||
)
|
||||
)
|
||||
sharedPreferences.edit()
|
||||
.remove(ACCESS_TOKEN_KEY)
|
||||
.remove(REFRESH_TOKEN_KEY)
|
||||
.remove(SAVED_AT_KEY)
|
||||
.apply()
|
||||
tokensFlow.value = null
|
||||
}
|
||||
|
||||
private fun readTokens(): TokenBundle? {
|
||||
val access = sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
|
||||
val refresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
|
||||
val savedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L)
|
||||
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt <= 0L) {
|
||||
return null
|
||||
private fun ensureAccountPlaceholder(userId: Long, lastActiveAt: Long) {
|
||||
val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
|
||||
val existing = accounts[userId]
|
||||
if (existing == null) {
|
||||
accounts[userId] = StoredAccount(
|
||||
userId = userId,
|
||||
email = null,
|
||||
name = "User #$userId",
|
||||
username = null,
|
||||
avatarUrl = null,
|
||||
lastActiveAt = lastActiveAt,
|
||||
)
|
||||
} else {
|
||||
accounts[userId] = existing.copy(lastActiveAt = maxOf(existing.lastActiveAt, lastActiveAt))
|
||||
}
|
||||
return TokenBundle(
|
||||
accessToken = access,
|
||||
refreshToken = refresh,
|
||||
savedAtMillis = savedAt,
|
||||
)
|
||||
writeAccounts(accounts.values.toList())
|
||||
}
|
||||
|
||||
private fun readActiveUserId(): Long? {
|
||||
val value = sharedPreferences.getLong(ACTIVE_USER_ID_KEY, -1L)
|
||||
return value.takeIf { it > 0L }
|
||||
}
|
||||
|
||||
private fun writeActiveUserId(userId: Long?) {
|
||||
sharedPreferences.edit().apply {
|
||||
if (userId == null) {
|
||||
remove(ACTIVE_USER_ID_KEY)
|
||||
} else {
|
||||
putLong(ACTIVE_USER_ID_KEY, userId)
|
||||
}
|
||||
}.apply()
|
||||
}
|
||||
|
||||
private fun readAllTokenEntries(): Map<Long, TokenBundle> {
|
||||
val raw = sharedPreferences.getString(TOKENS_JSON_KEY, null).orEmpty()
|
||||
if (raw.isBlank()) return emptyMap()
|
||||
return runCatching {
|
||||
val root = JSONObject(raw)
|
||||
root.keys().asSequence().mapNotNull { key ->
|
||||
val userId = key.toLongOrNull() ?: return@mapNotNull null
|
||||
val node = root.optJSONObject(key) ?: return@mapNotNull null
|
||||
val access = node.optString("access", "")
|
||||
val refresh = node.optString("refresh", "")
|
||||
val savedAt = node.optLong("savedAt", -1L)
|
||||
if (access.isBlank() || refresh.isBlank() || savedAt <= 0L) {
|
||||
null
|
||||
} else {
|
||||
userId to TokenBundle(access, refresh, savedAt)
|
||||
}
|
||||
}.toMap()
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun writeAllTokenEntries(tokens: Map<Long, TokenBundle>) {
|
||||
val root = JSONObject()
|
||||
tokens.forEach { (userId, token) ->
|
||||
root.put(
|
||||
userId.toString(),
|
||||
JSONObject().apply {
|
||||
put("access", token.accessToken)
|
||||
put("refresh", token.refreshToken)
|
||||
put("savedAt", token.savedAtMillis)
|
||||
}
|
||||
)
|
||||
}
|
||||
sharedPreferences.edit().putString(TOKENS_JSON_KEY, root.toString()).apply()
|
||||
}
|
||||
|
||||
private fun readAccounts(): List<StoredAccount> {
|
||||
val raw = sharedPreferences.getString(ACCOUNTS_JSON_KEY, null).orEmpty()
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(raw)
|
||||
buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val node = array.optJSONObject(index) ?: continue
|
||||
val userId = node.optLong("userId", -1L)
|
||||
if (userId <= 0L) continue
|
||||
add(
|
||||
StoredAccount(
|
||||
userId = userId,
|
||||
email = node.optString("email", "").ifBlank { null },
|
||||
name = node.optString("name", "User #$userId").ifBlank { "User #$userId" },
|
||||
username = node.optString("username", "").ifBlank { null },
|
||||
avatarUrl = node.optString("avatarUrl", "").ifBlank { null },
|
||||
lastActiveAt = node.optLong("lastActiveAt", 0L),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun writeAccounts(accounts: List<StoredAccount>) {
|
||||
val array = JSONArray()
|
||||
accounts.forEach { account ->
|
||||
array.put(
|
||||
JSONObject().apply {
|
||||
put("userId", account.userId)
|
||||
put("email", account.email.orEmpty())
|
||||
put("name", account.name)
|
||||
put("username", account.username.orEmpty())
|
||||
put("avatarUrl", account.avatarUrl.orEmpty())
|
||||
put("lastActiveAt", account.lastActiveAt)
|
||||
}
|
||||
)
|
||||
}
|
||||
sharedPreferences.edit().putString(ACCOUNTS_JSON_KEY, array.toString()).apply()
|
||||
}
|
||||
|
||||
private fun String.extractUserIdFromJwt(): Long? {
|
||||
val parts = split('.')
|
||||
if (parts.size < 2) return null
|
||||
val payload = parts[1]
|
||||
val normalized = payload
|
||||
.replace('-', '+')
|
||||
.replace('_', '/')
|
||||
.let { source ->
|
||||
when (source.length % 4) {
|
||||
0 -> source
|
||||
2 -> source + "=="
|
||||
3 -> source + "="
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
return runCatching {
|
||||
val payloadJson = String(Base64.decode(normalized, Base64.DEFAULT), Charsets.UTF_8)
|
||||
JSONObject(payloadJson).optString("sub").toLongOrNull()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ACCESS_TOKEN_KEY = "access_token"
|
||||
const val REFRESH_TOKEN_KEY = "refresh_token"
|
||||
const val SAVED_AT_KEY = "tokens_saved_at"
|
||||
|
||||
const val TOKENS_JSON_KEY = "tokens_json"
|
||||
const val ACCOUNTS_JSON_KEY = "accounts_json"
|
||||
const val ACTIVE_USER_ID_KEY = "active_user_id"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.daemonlord.messenger.core.token
|
||||
|
||||
data class StoredAccount(
|
||||
val userId: Long,
|
||||
val email: String?,
|
||||
val name: String,
|
||||
val username: String?,
|
||||
val avatarUrl: String?,
|
||||
val lastActiveAt: Long,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,15 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface TokenRepository {
|
||||
fun observeTokens(): Flow<TokenBundle?>
|
||||
fun observeAccounts(): Flow<List<StoredAccount>>
|
||||
fun observeActiveUserId(): Flow<Long?>
|
||||
suspend fun getTokens(): TokenBundle?
|
||||
suspend fun getAccounts(): List<StoredAccount>
|
||||
suspend fun getActiveUserId(): Long?
|
||||
suspend fun saveTokens(tokens: TokenBundle)
|
||||
suspend fun upsertAccount(account: StoredAccount)
|
||||
suspend fun switchAccount(userId: Long): Boolean
|
||||
suspend fun removeAccount(userId: Long)
|
||||
suspend fun clearTokens()
|
||||
suspend fun clearAllTokens()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.MessageResponseDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.ResendVerificationRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
|
||||
@@ -51,6 +52,10 @@ interface AuthApiService {
|
||||
@POST("/api/v1/auth/request-password-reset")
|
||||
suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/resend-verification")
|
||||
suspend fun resendVerification(@Body request: ResendVerificationRequestDto): MessageResponseDto
|
||||
|
||||
@Headers("No-Auth: true")
|
||||
@POST("/api/v1/auth/reset-password")
|
||||
suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto
|
||||
|
||||
@@ -86,6 +86,11 @@ data class RequestPasswordResetDto(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResendVerificationRequestDto(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResetPasswordRequestDto(
|
||||
val token: String,
|
||||
|
||||
@@ -3,15 +3,19 @@ package ru.daemonlord.messenger.data.auth.repository
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.daemonlord.messenger.core.token.TokenBundle
|
||||
import ru.daemonlord.messenger.core.token.StoredAccount
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
|
||||
import ru.daemonlord.messenger.data.common.ApiErrorMode
|
||||
import ru.daemonlord.messenger.data.common.toAppError
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||
@@ -29,12 +33,56 @@ class NetworkAuthRepository @Inject constructor(
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : AuthRepository {
|
||||
|
||||
override suspend fun login(email: String, password: String): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
override suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus> = withContext(ioDispatcher) {
|
||||
val normalized = email.trim().lowercase()
|
||||
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
|
||||
try {
|
||||
AppResult.Success(authApiService.checkEmailStatus(normalized).toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun register(
|
||||
email: String,
|
||||
name: String,
|
||||
username: String,
|
||||
password: String,
|
||||
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
val normalizedEmail = email.trim().lowercase()
|
||||
val normalizedName = name.trim()
|
||||
val normalizedUsername = username.trim().removePrefix("@")
|
||||
if (normalizedEmail.isBlank() || normalizedName.isBlank() || normalizedUsername.isBlank() || password.isBlank()) {
|
||||
return@withContext AppResult.Error(AppError.Server("Email, name, username and password are required"))
|
||||
}
|
||||
try {
|
||||
authApiService.register(
|
||||
request = RegisterRequestDto(
|
||||
email = normalizedEmail,
|
||||
name = normalizedName,
|
||||
username = normalizedUsername,
|
||||
password = password,
|
||||
)
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
password: String,
|
||||
otpCode: String?,
|
||||
recoveryCode: String?,
|
||||
): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val tokenResponse = authApiService.login(
|
||||
request = LoginRequestDto(
|
||||
email = email,
|
||||
password = password,
|
||||
otpCode = otpCode?.trim()?.ifBlank { null },
|
||||
recoveryCode = recoveryCode?.trim()?.ifBlank { null },
|
||||
)
|
||||
)
|
||||
tokenRepository.saveTokens(
|
||||
@@ -45,7 +93,13 @@ class NetworkAuthRepository @Inject constructor(
|
||||
)
|
||||
)
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
getMe()
|
||||
when (val meResult = getMe()) {
|
||||
is AppResult.Success -> {
|
||||
tokenRepository.upsertAccount(meResult.data.toStoredAccount())
|
||||
meResult
|
||||
}
|
||||
is AppResult.Error -> meResult
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
|
||||
}
|
||||
@@ -76,6 +130,7 @@ class NetworkAuthRepository @Inject constructor(
|
||||
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val user = authApiService.me().toDomain()
|
||||
tokenRepository.upsertAccount(user.toStoredAccount())
|
||||
AppResult.Success(user)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
@@ -92,7 +147,10 @@ class NetworkAuthRepository @Inject constructor(
|
||||
}
|
||||
|
||||
when (val meResult = getMe()) {
|
||||
is AppResult.Success -> meResult
|
||||
is AppResult.Success -> {
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
meResult
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
if (meResult.reason is AppError.Unauthorized) {
|
||||
tokenRepository.clearTokens()
|
||||
@@ -150,6 +208,17 @@ class NetworkAuthRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun AuthUser.toStoredAccount(): StoredAccount {
|
||||
return StoredAccount(
|
||||
userId = id,
|
||||
email = email,
|
||||
name = name,
|
||||
username = username,
|
||||
avatarUrl = avatarUrl,
|
||||
lastActiveAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun AuthSessionDto.toDomain(): AuthSession {
|
||||
return AuthSession(
|
||||
jti = jti,
|
||||
@@ -161,4 +230,13 @@ class NetworkAuthRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun CheckEmailStatusDto.toDomain(): AuthEmailStatus {
|
||||
return AuthEmailStatus(
|
||||
email = email,
|
||||
registered = registered,
|
||||
emailVerified = emailVerified,
|
||||
twofaEnabled = twofaEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.data.common
|
||||
import retrofit2.HttpException
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import java.io.IOException
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class ApiErrorMode {
|
||||
DEFAULT,
|
||||
@@ -13,15 +14,24 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
|
||||
return when (this) {
|
||||
is IOException -> AppError.Network
|
||||
is HttpException -> when (mode) {
|
||||
ApiErrorMode.LOGIN -> when (code()) {
|
||||
400, 401, 403 -> AppError.InvalidCredentials
|
||||
else -> AppError.Server(message = message())
|
||||
ApiErrorMode.LOGIN -> {
|
||||
val detail = extractErrorDetail()
|
||||
when (code()) {
|
||||
400, 401, 403 -> {
|
||||
if (detail?.contains("2fa code required", ignoreCase = true) == true) {
|
||||
AppError.Server(message = detail)
|
||||
} else {
|
||||
AppError.InvalidCredentials
|
||||
}
|
||||
}
|
||||
else -> AppError.Server(message = detail ?: message())
|
||||
}
|
||||
}
|
||||
|
||||
ApiErrorMode.DEFAULT -> if (code() == 401 || code() == 403) {
|
||||
AppError.Unauthorized
|
||||
} else {
|
||||
AppError.Server(message = message())
|
||||
AppError.Server(message = extractErrorDetail() ?: message())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +39,11 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpException.extractErrorDetail(): String? {
|
||||
return runCatching {
|
||||
val body = response()?.errorBody()?.string()?.trim().orEmpty()
|
||||
if (body.isBlank()) return@runCatching null
|
||||
val json = JSONObject(body)
|
||||
json.optString("detail").takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package ru.daemonlord.messenger.data.media.repository
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Movie
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
@@ -16,6 +18,7 @@ import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.di.RefreshClient
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
|
||||
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.roundToInt
|
||||
@@ -34,7 +37,7 @@ class NetworkMediaRepository @Inject constructor(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
): AppResult<UploadedAttachment> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val uploadPayload = prepareUploadPayload(
|
||||
fileName = fileName,
|
||||
@@ -74,7 +77,13 @@ class NetworkMediaRepository @Inject constructor(
|
||||
fileSize = uploadPayload.bytes.size.toLong(),
|
||||
)
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
AppResult.Success(
|
||||
UploadedAttachment(
|
||||
fileUrl = uploadInfo.fileUrl,
|
||||
fileType = uploadPayload.mimeType,
|
||||
fileSize = uploadPayload.bytes.size.toLong(),
|
||||
)
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
@@ -89,7 +98,26 @@ class NetworkMediaRepository @Inject constructor(
|
||||
return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
|
||||
}
|
||||
if (mimeType.equals("image/gif", ignoreCase = true)) {
|
||||
return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
|
||||
val still = gifToPngPayload(fileName = fileName, bytes = bytes)
|
||||
if (still != null) return still
|
||||
val bitmapFallback = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
|
||||
if (bitmapFallback != null) {
|
||||
val output = ByteArrayOutputStream()
|
||||
val compressed = bitmapFallback.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||
bitmapFallback.recycle()
|
||||
if (compressed) {
|
||||
val pngBytes = output.toByteArray()
|
||||
if (pngBytes.isNotEmpty()) {
|
||||
val baseName = fileName.substringBeforeLast('.').ifBlank { "gif" }
|
||||
return UploadPayload(
|
||||
fileName = "${baseName}-still.png",
|
||||
mimeType = "image/png",
|
||||
bytes = pngBytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return UploadPayload(fileName = fileName, mimeType = "application/octet-stream", bytes = bytes)
|
||||
}
|
||||
val source = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
|
||||
?: return UploadPayload(fileName = fileName, mimeType = mimeType, bytes = bytes)
|
||||
@@ -133,4 +161,32 @@ class NetworkMediaRepository @Inject constructor(
|
||||
val mimeType: String,
|
||||
val bytes: ByteArray,
|
||||
)
|
||||
|
||||
private fun gifToPngPayload(
|
||||
fileName: String,
|
||||
bytes: ByteArray,
|
||||
): UploadPayload? {
|
||||
val movie = runCatching { Movie.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return null
|
||||
val width = movie.width().coerceAtLeast(1)
|
||||
val height = movie.height().coerceAtLeast(1)
|
||||
val bitmap = runCatching { Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) }.getOrNull() ?: return null
|
||||
return try {
|
||||
val canvas = Canvas(bitmap)
|
||||
movie.setTime(0)
|
||||
movie.draw(canvas, 0f, 0f)
|
||||
val output = ByteArrayOutputStream()
|
||||
val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||
if (!compressed) return null
|
||||
val pngBytes = output.toByteArray()
|
||||
if (pngBytes.isEmpty()) return null
|
||||
val baseName = fileName.substringBeforeLast('.').ifBlank { "gif" }
|
||||
UploadPayload(
|
||||
fileName = "${baseName}-still.png",
|
||||
mimeType = "image/png",
|
||||
bytes = pngBytes,
|
||||
)
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ fun MessageLocalModel.toDomain(currentUserId: Long?): MessageItem {
|
||||
chatId = message.chatId,
|
||||
senderId = message.senderId,
|
||||
senderDisplayName = message.senderDisplayName,
|
||||
senderUsername = message.senderUsername,
|
||||
type = message.type,
|
||||
text = message.text,
|
||||
createdAt = message.createdAt,
|
||||
|
||||
@@ -16,6 +16,7 @@ import ru.daemonlord.messenger.data.common.toAppError
|
||||
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
|
||||
import ru.daemonlord.messenger.data.message.local.dao.PendingMessageActionDao
|
||||
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
||||
import ru.daemonlord.messenger.data.message.local.entity.MessageAttachmentEntity
|
||||
import ru.daemonlord.messenger.data.message.local.entity.PendingMessageActionEntity
|
||||
import ru.daemonlord.messenger.data.message.mapper.toDomain
|
||||
import ru.daemonlord.messenger.data.message.mapper.toEntity
|
||||
@@ -360,7 +361,27 @@ class NetworkMessageRepository @Inject constructor(
|
||||
)) {
|
||||
is AppResult.Success -> {
|
||||
messageDao.deleteMessage(tempId)
|
||||
syncRecentMessages(chatId = chatId)
|
||||
messageDao.upsertMessages(listOf(created.toEntity()))
|
||||
messageDao.upsertAttachments(
|
||||
listOf(
|
||||
MessageAttachmentEntity(
|
||||
id = -System.currentTimeMillis(),
|
||||
messageId = created.id,
|
||||
fileUrl = mediaResult.data.fileUrl,
|
||||
fileType = mediaResult.data.fileType,
|
||||
fileSize = mediaResult.data.fileSize,
|
||||
waveformPointsJson = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
chatDao.updateLastMessage(
|
||||
chatId = chatId,
|
||||
lastMessageText = created.text,
|
||||
lastMessageType = created.type,
|
||||
lastMessageCreatedAt = created.createdAt,
|
||||
updatedSortAt = created.createdAt,
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
}
|
||||
|
||||
is AppResult.Error -> {
|
||||
@@ -374,6 +395,70 @@ class NetworkMessageRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendImageUrlMessage(
|
||||
chatId: Long,
|
||||
imageUrl: String,
|
||||
replyToMessageId: Long?,
|
||||
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
val normalizedUrl = imageUrl.trim()
|
||||
if (normalizedUrl.isBlank()) {
|
||||
return@withContext AppResult.Error(AppError.Server("Image URL is empty"))
|
||||
}
|
||||
val tempId = -System.currentTimeMillis()
|
||||
val now = java.time.Instant.now().toString()
|
||||
val tempMessage = MessageEntity(
|
||||
id = tempId,
|
||||
chatId = chatId,
|
||||
senderId = currentUserId ?: 0L,
|
||||
senderDisplayName = null,
|
||||
senderUsername = null,
|
||||
senderAvatarUrl = null,
|
||||
replyToMessageId = replyToMessageId,
|
||||
replyPreviewText = null,
|
||||
replyPreviewSenderName = null,
|
||||
forwardedFromMessageId = null,
|
||||
forwardedFromDisplayName = null,
|
||||
type = "image",
|
||||
text = normalizedUrl,
|
||||
status = "pending",
|
||||
attachmentWaveformJson = null,
|
||||
createdAt = now,
|
||||
updatedAt = null,
|
||||
)
|
||||
messageDao.upsertMessages(listOf(tempMessage))
|
||||
chatDao.updateLastMessage(
|
||||
chatId = chatId,
|
||||
lastMessageText = normalizedUrl,
|
||||
lastMessageType = "image",
|
||||
lastMessageCreatedAt = now,
|
||||
updatedSortAt = now,
|
||||
)
|
||||
try {
|
||||
val sent = messageApiService.sendMessage(
|
||||
request = MessageCreateRequestDto(
|
||||
chatId = chatId,
|
||||
type = "image",
|
||||
text = normalizedUrl,
|
||||
clientMessageId = UUID.randomUUID().toString(),
|
||||
replyToMessageId = replyToMessageId,
|
||||
)
|
||||
)
|
||||
messageDao.deleteMessage(tempId)
|
||||
messageDao.upsertMessages(listOf(sent.toEntity()))
|
||||
chatDao.updateLastMessage(
|
||||
chatId = chatId,
|
||||
lastMessageText = sent.text,
|
||||
lastMessageType = sent.type,
|
||||
lastMessageCreatedAt = sent.createdAt,
|
||||
updatedSortAt = sent.createdAt,
|
||||
)
|
||||
AppResult.Success(Unit)
|
||||
} catch (error: Throwable) {
|
||||
messageDao.deleteMessage(tempId)
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
messageApiService.updateMessageStatus(
|
||||
@@ -490,11 +575,16 @@ class NetworkMessageRepository @Inject constructor(
|
||||
mimeType: String,
|
||||
fileName: String,
|
||||
): String {
|
||||
val normalizedMime = mimeType.lowercase()
|
||||
val normalizedName = fileName.lowercase()
|
||||
return when {
|
||||
mimeType.startsWith("image/") -> "image"
|
||||
mimeType.startsWith("video/") -> "video"
|
||||
mimeType.startsWith("audio/") && fileName.startsWith("voice_", ignoreCase = true) -> "voice"
|
||||
mimeType.startsWith("audio/") -> "audio"
|
||||
normalizedName.startsWith("circle_") -> "circle_video"
|
||||
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image"
|
||||
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image"
|
||||
normalizedMime.startsWith("image/") -> "image"
|
||||
normalizedMime.startsWith("video/") -> "video"
|
||||
normalizedMime.startsWith("audio/") && normalizedName.startsWith("voice_") -> "voice"
|
||||
normalizedMime.startsWith("audio/") -> "audio"
|
||||
else -> "file"
|
||||
}
|
||||
}
|
||||
@@ -597,6 +687,7 @@ class NetworkMessageRepository @Inject constructor(
|
||||
chatId = chatId,
|
||||
senderId = senderId,
|
||||
senderDisplayName = senderDisplayName,
|
||||
senderUsername = senderUsername,
|
||||
type = type,
|
||||
text = text,
|
||||
createdAt = createdAt,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.daemonlord.messenger.data.notifications.api
|
||||
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
import ru.daemonlord.messenger.data.notifications.dto.NotificationReadDto
|
||||
|
||||
interface NotificationApiService {
|
||||
@GET("/api/v1/notifications")
|
||||
suspend fun list(@Query("limit") limit: Int = 50): List<NotificationReadDto>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.daemonlord.messenger.data.notifications.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NotificationReadDto(
|
||||
val id: Long,
|
||||
@SerialName("user_id")
|
||||
val userId: Long,
|
||||
@SerialName("event_type")
|
||||
val eventType: String,
|
||||
val payload: String,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String,
|
||||
)
|
||||
@@ -102,7 +102,12 @@ class RealtimeEventParser @Inject constructor(
|
||||
"message_read" -> {
|
||||
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
val messageId = payload["message_id"].longOrNull() ?: return RealtimeEvent.Ignored
|
||||
RealtimeEvent.MessageRead(chatId = chatId, messageId = messageId)
|
||||
RealtimeEvent.MessageRead(
|
||||
chatId = chatId,
|
||||
messageId = messageId,
|
||||
userId = payload["user_id"].longOrNull(),
|
||||
lastReadMessageId = payload["last_read_message_id"].longOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
"typing_start" -> {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package ru.daemonlord.messenger.data.settings.repository
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||
|
||||
@Singleton
|
||||
class DataStoreLanguageRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : LanguageRepository {
|
||||
|
||||
override fun observeLanguage(): Flow<AppLanguage> {
|
||||
return dataStore.data.map { prefs ->
|
||||
AppLanguage.fromTag(prefs[LANGUAGE_TAG_KEY])
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getLanguage(): AppLanguage {
|
||||
return observeLanguage().first()
|
||||
}
|
||||
|
||||
override suspend fun setLanguage(language: AppLanguage) {
|
||||
dataStore.edit { prefs ->
|
||||
if (language.tag == null) {
|
||||
prefs.remove(LANGUAGE_TAG_KEY)
|
||||
} else {
|
||||
prefs[LANGUAGE_TAG_KEY] = language.tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LANGUAGE_TAG_KEY = stringPreferencesKey("app_language_tag")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package ru.daemonlord.messenger.data.settings.repository
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DataStoreThemeRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : ThemeRepository {
|
||||
|
||||
override fun observeThemeMode(): Flow<AppThemeMode> {
|
||||
return dataStore.data.map { prefs ->
|
||||
prefs[THEME_MODE_KEY]
|
||||
?.let { raw -> runCatching { AppThemeMode.valueOf(raw) }.getOrNull() }
|
||||
?: AppThemeMode.SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getThemeMode(): AppThemeMode {
|
||||
return observeThemeMode().first()
|
||||
}
|
||||
|
||||
override suspend fun setThemeMode(mode: AppThemeMode) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[THEME_MODE_KEY] = mode.name
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val THEME_MODE_KEY = stringPreferencesKey("app_theme_mode")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,15 @@ import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.ResendVerificationRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
|
||||
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
|
||||
import ru.daemonlord.messenger.data.common.toAppError
|
||||
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
|
||||
import ru.daemonlord.messenger.data.notifications.api.NotificationApiService
|
||||
import ru.daemonlord.messenger.data.notifications.dto.NotificationReadDto
|
||||
import ru.daemonlord.messenger.di.RefreshClient
|
||||
import ru.daemonlord.messenger.data.user.api.UserApiService
|
||||
import ru.daemonlord.messenger.data.user.dto.AddContactByEmailRequestDto
|
||||
@@ -23,11 +26,16 @@ import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
|
||||
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.account.model.AccountNotification
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -36,6 +44,7 @@ class NetworkAccountRepository @Inject constructor(
|
||||
private val authApiService: AuthApiService,
|
||||
private val userApiService: UserApiService,
|
||||
private val mediaApiService: MediaApiService,
|
||||
private val notificationApiService: NotificationApiService,
|
||||
@RefreshClient private val uploadClient: OkHttpClient,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : AccountRepository {
|
||||
@@ -206,6 +215,14 @@ class NetworkAccountRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listNotifications(limit: Int): AppResult<List<AccountNotification>> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(notificationApiService.list(limit = limit).map { it.toDomain() })
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
|
||||
try {
|
||||
authApiService.revokeSession(jti)
|
||||
@@ -242,6 +259,15 @@ class NetworkAccountRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resendVerification(email: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val result = authApiService.resendVerification(ResendVerificationRequestDto(email = email.trim()))
|
||||
AppResult.Success(result.message)
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resetPassword(token: String, password: String): AppResult<String> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val result = authApiService.resetPassword(
|
||||
@@ -336,4 +362,24 @@ class NetworkAccountRepository @Inject constructor(
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun NotificationReadDto.toDomain(): AccountNotification {
|
||||
val payloadObject = runCatching {
|
||||
Json.parseToJsonElement(payload).jsonObject
|
||||
}.getOrNull()
|
||||
val chatId = payloadObject?.get("chat_id")?.jsonPrimitive?.contentOrNull?.toLongOrNull()
|
||||
val messageId = payloadObject?.get("message_id")?.jsonPrimitive?.contentOrNull?.toLongOrNull()
|
||||
val text = payloadObject?.get("text")?.jsonPrimitive?.contentOrNull
|
||||
?: payloadObject?.get("body")?.jsonPrimitive?.contentOrNull
|
||||
?: payloadObject?.get("title")?.jsonPrimitive?.contentOrNull
|
||||
return AccountNotification(
|
||||
id = id,
|
||||
eventType = eventType,
|
||||
createdAt = createdAt,
|
||||
payloadRaw = payload,
|
||||
chatId = chatId,
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||
import ru.daemonlord.messenger.data.message.api.MessageApiService
|
||||
import ru.daemonlord.messenger.data.notifications.api.NotificationApiService
|
||||
import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService
|
||||
import ru.daemonlord.messenger.data.search.api.SearchApiService
|
||||
import ru.daemonlord.messenger.data.user.api.UserApiService
|
||||
@@ -161,4 +162,10 @@ object NetworkModule {
|
||||
fun providePushTokenApiService(retrofit: Retrofit): PushTokenApiService {
|
||||
return retrofit.create(PushTokenApiService::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNotificationApiService(retrofit: Retrofit): NotificationApiService {
|
||||
return retrofit.create(NotificationApiService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
|
||||
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
|
||||
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.data.search.repository.NetworkSearchRepository
|
||||
import ru.daemonlord.messenger.data.settings.repository.DataStoreLanguageRepository
|
||||
import ru.daemonlord.messenger.data.settings.repository.DataStoreThemeRepository
|
||||
import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||
@@ -22,6 +24,8 @@ import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
|
||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -81,4 +85,16 @@ abstract class RepositoryModule {
|
||||
abstract fun bindSearchRepository(
|
||||
repository: NetworkSearchRepository,
|
||||
): SearchRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindThemeRepository(
|
||||
repository: DataStoreThemeRepository,
|
||||
): ThemeRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLanguageRepository(
|
||||
repository: DataStoreLanguageRepository,
|
||||
): LanguageRepository
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.daemonlord.messenger.domain.account.model
|
||||
|
||||
data class AccountNotification(
|
||||
val id: Long,
|
||||
val eventType: String,
|
||||
val createdAt: String,
|
||||
val payloadRaw: String,
|
||||
val chatId: Long? = null,
|
||||
val messageId: Long? = null,
|
||||
val text: String? = null,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.daemonlord.messenger.domain.account.repository
|
||||
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.account.model.AccountNotification
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
@@ -33,9 +34,11 @@ interface AccountRepository {
|
||||
suspend fun blockUser(userId: Long): AppResult<Unit>
|
||||
suspend fun unblockUser(userId: Long): AppResult<Unit>
|
||||
suspend fun listSessions(): AppResult<List<AuthSession>>
|
||||
suspend fun listNotifications(limit: Int = 50): AppResult<List<AccountNotification>>
|
||||
suspend fun revokeSession(jti: String): AppResult<Unit>
|
||||
suspend fun revokeAllSessions(): AppResult<Unit>
|
||||
suspend fun verifyEmail(token: String): AppResult<String>
|
||||
suspend fun resendVerification(email: String): AppResult<String>
|
||||
suspend fun requestPasswordReset(email: String): AppResult<String>
|
||||
suspend fun resetPassword(token: String, password: String): AppResult<String>
|
||||
suspend fun setupTwoFactor(): AppResult<Pair<String, String>>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.daemonlord.messenger.domain.auth.model
|
||||
|
||||
data class AuthEmailStatus(
|
||||
val email: String,
|
||||
val registered: Boolean,
|
||||
val emailVerified: Boolean,
|
||||
val twofaEnabled: Boolean,
|
||||
)
|
||||
|
||||
@@ -2,10 +2,18 @@ package ru.daemonlord.messenger.domain.auth.repository
|
||||
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
|
||||
interface AuthRepository {
|
||||
suspend fun login(email: String, password: String): AppResult<AuthUser>
|
||||
suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus>
|
||||
suspend fun register(email: String, name: String, username: String, password: String): AppResult<Unit>
|
||||
suspend fun login(
|
||||
email: String,
|
||||
password: String,
|
||||
otpCode: String? = null,
|
||||
recoveryCode: String? = null,
|
||||
): AppResult<AuthUser>
|
||||
suspend fun refreshTokens(): AppResult<Unit>
|
||||
suspend fun getMe(): AppResult<AuthUser>
|
||||
suspend fun restoreSession(): AppResult<AuthUser>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.daemonlord.messenger.domain.auth.usecase
|
||||
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import javax.inject.Inject
|
||||
|
||||
class CheckEmailStatusUseCase @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend operator fun invoke(email: String): AppResult<AuthEmailStatus> {
|
||||
return authRepository.checkEmailStatus(email)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,17 @@ import javax.inject.Inject
|
||||
class LoginUseCase @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend operator fun invoke(email: String, password: String): AppResult<AuthUser> {
|
||||
return authRepository.login(email = email, password = password)
|
||||
suspend operator fun invoke(
|
||||
email: String,
|
||||
password: String,
|
||||
otpCode: String? = null,
|
||||
recoveryCode: String? = null,
|
||||
): AppResult<AuthUser> {
|
||||
return authRepository.login(
|
||||
email = email,
|
||||
password = password,
|
||||
otpCode = otpCode,
|
||||
recoveryCode = recoveryCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.daemonlord.messenger.domain.auth.usecase
|
||||
|
||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import javax.inject.Inject
|
||||
|
||||
class RegisterUseCase @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
email: String,
|
||||
name: String,
|
||||
username: String,
|
||||
password: String,
|
||||
): AppResult<Unit> {
|
||||
return authRepository.register(
|
||||
email = email,
|
||||
name = name,
|
||||
username = username,
|
||||
password = password,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.daemonlord.messenger.domain.media.model
|
||||
|
||||
data class UploadedAttachment(
|
||||
val fileUrl: String,
|
||||
val fileType: String,
|
||||
val fileSize: Long,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.daemonlord.messenger.domain.media.repository
|
||||
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
|
||||
|
||||
interface MediaRepository {
|
||||
suspend fun uploadAndAttach(
|
||||
@@ -8,5 +9,5 @@ interface MediaRepository {
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): AppResult<Unit>
|
||||
): AppResult<UploadedAttachment>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.daemonlord.messenger.domain.media.usecase
|
||||
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.media.model.UploadedAttachment
|
||||
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -12,7 +13,7 @@ class UploadAndAttachMediaUseCase @Inject constructor(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): AppResult<Unit> {
|
||||
): AppResult<UploadedAttachment> {
|
||||
return mediaRepository.uploadAndAttach(
|
||||
messageId = messageId,
|
||||
fileName = fileName,
|
||||
|
||||
@@ -5,6 +5,7 @@ data class MessageItem(
|
||||
val chatId: Long,
|
||||
val senderId: Long,
|
||||
val senderDisplayName: String?,
|
||||
val senderUsername: String? = null,
|
||||
val type: String,
|
||||
val text: String?,
|
||||
val createdAt: String,
|
||||
|
||||
@@ -20,6 +20,11 @@ interface MessageRepository {
|
||||
caption: String? = null,
|
||||
replyToMessageId: Long? = null,
|
||||
): AppResult<Unit>
|
||||
suspend fun sendImageUrlMessage(
|
||||
chatId: Long,
|
||||
imageUrl: String,
|
||||
replyToMessageId: Long? = null,
|
||||
): AppResult<Unit>
|
||||
suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit>
|
||||
suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit>
|
||||
suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.daemonlord.messenger.domain.message.usecase
|
||||
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class SendImageUrlMessageUseCase @Inject constructor(
|
||||
private val messageRepository: MessageRepository,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
chatId: Long,
|
||||
imageUrl: String,
|
||||
replyToMessageId: Long? = null,
|
||||
): AppResult<Unit> {
|
||||
return messageRepository.sendImageUrlMessage(
|
||||
chatId = chatId,
|
||||
imageUrl = imageUrl,
|
||||
replyToMessageId = replyToMessageId,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ sealed interface RealtimeEvent {
|
||||
data class MessageRead(
|
||||
val chatId: Long,
|
||||
val messageId: Long,
|
||||
val userId: Long?,
|
||||
val lastReadMessageId: Long?,
|
||||
) : RealtimeEvent
|
||||
|
||||
data class TypingStart(
|
||||
|
||||
@@ -10,9 +10,11 @@ import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||
import ru.daemonlord.messenger.core.notifications.ChatNotificationPayload
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
|
||||
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
|
||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
|
||||
@@ -27,6 +29,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
private val messageDao: MessageDao,
|
||||
private val notificationDispatcher: NotificationDispatcher,
|
||||
private val activeChatTracker: ActiveChatTracker,
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val notificationSettingsRepository: NotificationSettingsRepository,
|
||||
private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase,
|
||||
) {
|
||||
|
||||
@@ -81,16 +85,36 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
} else {
|
||||
chatDao.incrementUnread(chatId = event.chatId)
|
||||
}
|
||||
val activeUserId = tokenRepository.getActiveUserId()
|
||||
val myUsername = activeUserId?.let { userId ->
|
||||
tokenRepository.getAccounts()
|
||||
.firstOrNull { it.userId == userId }
|
||||
?.username
|
||||
?.trim()
|
||||
?.removePrefix("@")
|
||||
?.lowercase()
|
||||
}
|
||||
val isMentionByText = if (myUsername.isNullOrBlank()) {
|
||||
false
|
||||
} else {
|
||||
Regex("(^|\\W)@${Regex.escape(myUsername)}(\\W|$)", RegexOption.IGNORE_CASE)
|
||||
.containsMatchIn(event.text.orEmpty())
|
||||
}
|
||||
val isMention = event.isMention || isMentionByText
|
||||
val muted = chatDao.isChatMuted(event.chatId) == true
|
||||
val shouldNotify = shouldShowMessageNotificationUseCase(
|
||||
chatId = event.chatId,
|
||||
isMention = event.isMention,
|
||||
isMention = isMention,
|
||||
serverMuted = muted,
|
||||
)
|
||||
if (activeChatId != event.chatId && shouldNotify) {
|
||||
val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message"
|
||||
val body = event.text?.takeIf { it.isNotBlank() }
|
||||
?: when (event.type?.lowercase()) {
|
||||
val previewEnabled = notificationSettingsRepository.getSettings().previewEnabled
|
||||
val body = (if (previewEnabled) {
|
||||
event.text?.takeIf { it.isNotBlank() }
|
||||
} else {
|
||||
null
|
||||
}) ?: when (event.type?.lowercase()) {
|
||||
"image" -> "Photo"
|
||||
"video" -> "Video"
|
||||
"audio" -> "Audio"
|
||||
@@ -104,7 +128,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
messageId = event.messageId,
|
||||
title = title,
|
||||
body = body,
|
||||
isMention = event.isMention,
|
||||
isMention = isMention,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -168,6 +192,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
|
||||
messageId = event.messageId,
|
||||
status = "read",
|
||||
)
|
||||
chatRepository.refreshChat(chatId = event.chatId)
|
||||
}
|
||||
|
||||
is RealtimeEvent.TypingStart -> Unit
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.daemonlord.messenger.domain.settings.model
|
||||
|
||||
enum class AppLanguage(val tag: String?) {
|
||||
SYSTEM(null),
|
||||
RUSSIAN("ru"),
|
||||
ENGLISH("en");
|
||||
|
||||
companion object {
|
||||
fun fromTag(tag: String?): AppLanguage {
|
||||
if (tag.isNullOrBlank()) return SYSTEM
|
||||
return entries.firstOrNull { it.tag.equals(tag, ignoreCase = true) } ?: SYSTEM
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.daemonlord.messenger.domain.settings.model
|
||||
|
||||
enum class AppThemeMode {
|
||||
LIGHT,
|
||||
DARK,
|
||||
SYSTEM,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.daemonlord.messenger.domain.settings.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
|
||||
interface LanguageRepository {
|
||||
fun observeLanguage(): Flow<AppLanguage>
|
||||
suspend fun getLanguage(): AppLanguage
|
||||
suspend fun setLanguage(language: AppLanguage)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.daemonlord.messenger.domain.settings.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
|
||||
interface ThemeRepository {
|
||||
fun observeThemeMode(): Flow<AppThemeMode>
|
||||
suspend fun getThemeMode(): AppThemeMode
|
||||
suspend fun setThemeMode(mode: AppThemeMode)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -16,8 +17,14 @@ class MessengerFirebaseMessagingService : FirebaseMessagingService() {
|
||||
@Inject
|
||||
lateinit var pushTokenSyncManager: PushTokenSyncManager
|
||||
|
||||
@Inject
|
||||
lateinit var activeChatTracker: ActiveChatTracker
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
val payload = PushPayloadParser.parse(message) ?: return
|
||||
if (activeChatTracker.activeChatId.value == payload.chatId) {
|
||||
return
|
||||
}
|
||||
notificationDispatcher.showChatMessage(payload)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ object PushPayloadParser {
|
||||
?: data["text"]
|
||||
?: "Open chat"
|
||||
val isMention = data["is_mention"]?.equals("true", ignoreCase = true) == true ||
|
||||
data["mention"]?.equals("true", ignoreCase = true) == true
|
||||
data["mention"]?.equals("true", ignoreCase = true) == true ||
|
||||
data["type"]?.equals("mention", ignoreCase = true) == true
|
||||
|
||||
return ChatNotificationPayload(
|
||||
chatId = chatId,
|
||||
|
||||
@@ -71,6 +71,10 @@ class PushTokenSyncManager @Inject constructor(
|
||||
}.onFailure { error ->
|
||||
Timber.w(error, "Failed to unregister push token on logout")
|
||||
}
|
||||
securePrefs.edit()
|
||||
.remove(KEY_LAST_SYNCED_TOKEN)
|
||||
.remove(KEY_LAST_SYNCED_USER_ID)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private suspend fun registerTokenIfPossible(token: String) {
|
||||
@@ -78,6 +82,15 @@ class PushTokenSyncManager @Inject constructor(
|
||||
if (!hasTokens) {
|
||||
return
|
||||
}
|
||||
val activeUserId = tokenRepository.getActiveUserId()
|
||||
if (activeUserId == null) {
|
||||
return
|
||||
}
|
||||
val lastSyncedToken = securePrefs.getString(KEY_LAST_SYNCED_TOKEN, null)?.trim()
|
||||
val lastSyncedUserId = securePrefs.getLong(KEY_LAST_SYNCED_USER_ID, -1L).takeIf { it > 0L }
|
||||
if (lastSyncedToken == token && lastSyncedUserId == activeUserId) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
pushTokenApiService.upsert(
|
||||
request = PushTokenUpsertRequestDto(
|
||||
@@ -87,6 +100,10 @@ class PushTokenSyncManager @Inject constructor(
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
)
|
||||
)
|
||||
securePrefs.edit()
|
||||
.putString(KEY_LAST_SYNCED_TOKEN, token)
|
||||
.putLong(KEY_LAST_SYNCED_USER_ID, activeUserId)
|
||||
.apply()
|
||||
}.onFailure { error ->
|
||||
Timber.w(error, "Failed to sync push token")
|
||||
}
|
||||
@@ -94,6 +111,8 @@ class PushTokenSyncManager @Inject constructor(
|
||||
|
||||
private companion object {
|
||||
const val KEY_LAST_FCM_TOKEN = "last_fcm_token"
|
||||
const val KEY_LAST_SYNCED_TOKEN = "last_synced_push_token"
|
||||
const val KEY_LAST_SYNCED_USER_ID = "last_synced_push_user_id"
|
||||
const val PLATFORM_ANDROID = "android"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package ru.daemonlord.messenger.ui.account
|
||||
|
||||
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
|
||||
import ru.daemonlord.messenger.domain.account.model.AccountNotification
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
|
||||
data class AccountUiState(
|
||||
val isLoading: Boolean = false,
|
||||
@@ -14,6 +17,22 @@ data class AccountUiState(
|
||||
val twoFactorOtpAuthUrl: String? = null,
|
||||
val recoveryCodes: List<String> = emptyList(),
|
||||
val recoveryCodesRemaining: Int? = null,
|
||||
val activeUserId: Long? = null,
|
||||
val storedAccounts: List<StoredAccountUi> = emptyList(),
|
||||
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||
val appLanguage: AppLanguage = AppLanguage.SYSTEM,
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val notificationsPreviewEnabled: Boolean = true,
|
||||
val notificationsHistory: List<AccountNotification> = emptyList(),
|
||||
val isAddingAccount: Boolean = false,
|
||||
val message: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
data class StoredAccountUi(
|
||||
val userId: Long,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val avatarUrl: String?,
|
||||
val isActive: Boolean,
|
||||
)
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
package ru.daemonlord.messenger.ui.account
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.push.PushTokenSyncManager
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import ru.daemonlord.messenger.R
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AccountViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val loginUseCase: LoginUseCase,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val realtimeManager: RealtimeManager,
|
||||
private val notificationSettingsRepository: NotificationSettingsRepository,
|
||||
private val languageRepository: LanguageRepository,
|
||||
private val themeRepository: ThemeRepository,
|
||||
private val pushTokenSyncManager: PushTokenSyncManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(AccountUiState())
|
||||
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
|
||||
@@ -30,13 +54,38 @@ class AccountViewModel @Inject constructor(
|
||||
val me = accountRepository.getMe()
|
||||
val sessions = accountRepository.listSessions()
|
||||
val blocked = accountRepository.listBlockedUsers()
|
||||
val notifications = accountRepository.listNotifications(limit = 50)
|
||||
val activeUserId = tokenRepository.getActiveUserId()
|
||||
val storedAccounts = tokenRepository.getAccounts()
|
||||
val notificationSettings = notificationSettingsRepository.getSettings()
|
||||
val appThemeMode = themeRepository.getThemeMode()
|
||||
val appLanguage = languageRepository.getLanguage()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
profile = (me as? AppResult.Success)?.data ?: state.profile,
|
||||
sessions = (sessions as? AppResult.Success)?.data ?: state.sessions,
|
||||
blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers,
|
||||
errorMessage = listOf(me, sessions, blocked)
|
||||
notificationsHistory = (notifications as? AppResult.Success)?.data ?: state.notificationsHistory,
|
||||
activeUserId = activeUserId,
|
||||
storedAccounts = storedAccounts.map { account ->
|
||||
val fallbackUser = context.getString(R.string.account_user_fallback, account.userId)
|
||||
StoredAccountUi(
|
||||
userId = account.userId,
|
||||
title = account.name.ifBlank { fallbackUser },
|
||||
subtitle = listOfNotNull(
|
||||
account.username?.takeIf { it.isNotBlank() }?.let { "@$it" },
|
||||
account.email?.takeIf { it.isNotBlank() },
|
||||
).joinToString(" • ").ifBlank { fallbackUser },
|
||||
avatarUrl = account.avatarUrl,
|
||||
isActive = activeUserId == account.userId,
|
||||
)
|
||||
},
|
||||
notificationsEnabled = notificationSettings.globalEnabled,
|
||||
notificationsPreviewEnabled = notificationSettings.previewEnabled,
|
||||
appThemeMode = appThemeMode,
|
||||
appLanguage = appLanguage,
|
||||
errorMessage = listOf(me, sessions, blocked, notifications)
|
||||
.filterIsInstance<AppResult.Error>()
|
||||
.firstOrNull()
|
||||
?.reason
|
||||
@@ -46,6 +95,111 @@ class AccountViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setThemeMode(mode: AppThemeMode) {
|
||||
viewModelScope.launch {
|
||||
themeRepository.setThemeMode(mode)
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (mode) {
|
||||
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
)
|
||||
_uiState.update { it.copy(appThemeMode = mode) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setLanguage(language: AppLanguage) {
|
||||
viewModelScope.launch {
|
||||
languageRepository.setLanguage(language)
|
||||
val locales = language.tag?.let { LocaleListCompat.forLanguageTags(it) }
|
||||
?: LocaleListCompat.getEmptyLocaleList()
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
_uiState.update { it.copy(appLanguage = language) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setGlobalNotificationsEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
notificationSettingsRepository.setGlobalEnabled(enabled)
|
||||
_uiState.update { it.copy(notificationsEnabled = enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setNotificationPreviewEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
notificationSettingsRepository.setPreviewEnabled(enabled)
|
||||
_uiState.update { it.copy(notificationsPreviewEnabled = enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addAccount(email: String, password: String, onDone: (Boolean) -> Unit = {}) {
|
||||
val normalizedEmail = email.trim()
|
||||
if (normalizedEmail.isBlank() || password.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.account_error_email_password_required)) }
|
||||
onDone(false)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isAddingAccount = true, errorMessage = null, message = null) }
|
||||
when (val result = loginUseCase(normalizedEmail, password)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAddingAccount = false,
|
||||
message = context.getString(R.string.account_info_added),
|
||||
)
|
||||
}
|
||||
refresh()
|
||||
onDone(true)
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAddingAccount = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
onDone(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun switchStoredAccount(userId: Long, onSwitched: (Boolean) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
val switched = tokenRepository.switchAccount(userId)
|
||||
if (!switched) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.account_error_no_saved_session)) }
|
||||
onSwitched(false)
|
||||
return@launch
|
||||
}
|
||||
// Force data/context switch to the newly active account.
|
||||
realtimeManager.disconnect()
|
||||
realtimeManager.connect()
|
||||
pushTokenSyncManager.triggerBestEffortSync()
|
||||
val allResult = chatRepository.refreshChats(archived = false)
|
||||
val archivedResult = chatRepository.refreshChats(archived = true)
|
||||
refresh()
|
||||
val syncFailed = allResult is AppResult.Error && archivedResult is AppResult.Error
|
||||
if (syncFailed) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = context.getString(R.string.account_error_switch_sync_failed),
|
||||
)
|
||||
}
|
||||
}
|
||||
onSwitched(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeStoredAccount(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
tokenRepository.removeAccount(userId)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProfile(
|
||||
name: String,
|
||||
username: String,
|
||||
@@ -59,7 +213,7 @@ class AccountViewModel @Inject constructor(
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
profile = result.data,
|
||||
message = "Profile updated.",
|
||||
message = context.getString(R.string.account_info_profile_updated),
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
@@ -82,7 +236,7 @@ class AccountViewModel @Inject constructor(
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.uploadAvatar(fileName, mimeType, bytes)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(isSaving = false, message = "Avatar uploaded.") }
|
||||
_uiState.update { it.copy(isSaving = false, message = context.getString(R.string.account_info_avatar_uploaded)) }
|
||||
onUploaded(result.data)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
@@ -115,7 +269,7 @@ class AccountViewModel @Inject constructor(
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
profile = result.data,
|
||||
message = "Privacy settings updated.",
|
||||
message = context.getString(R.string.account_info_privacy_updated),
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
@@ -168,7 +322,7 @@ class AccountViewModel @Inject constructor(
|
||||
it.copy(
|
||||
twoFactorSecret = result.data.first,
|
||||
twoFactorOtpAuthUrl = result.data.second,
|
||||
message = "2FA secret generated. Enter code to enable.",
|
||||
message = context.getString(R.string.account_info_2fa_secret_generated),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
@@ -217,7 +371,7 @@ class AccountViewModel @Inject constructor(
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
recoveryCodes = result.data,
|
||||
message = "Recovery codes regenerated.",
|
||||
message = context.getString(R.string.account_info_recovery_codes_regenerated),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
@@ -246,6 +400,26 @@ class AccountViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun resendVerification(email: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
when (val result = accountRepository.resendVerification(email)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
message = result.data,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPasswordReset(email: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }
|
||||
@@ -292,11 +466,11 @@ class AccountViewModel @Inject constructor(
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.InvalidCredentials -> "Invalid credentials."
|
||||
AppError.Unauthorized -> "Unauthorized."
|
||||
AppError.Network -> "Network error."
|
||||
is AppError.Server -> message ?: "Server error."
|
||||
is AppError.Unknown -> cause?.message ?: "Unknown error."
|
||||
AppError.InvalidCredentials -> context.getString(R.string.account_error_invalid_credentials)
|
||||
AppError.Unauthorized -> context.getString(R.string.account_error_unauthorized)
|
||||
AppError.Network -> context.getString(R.string.error_network)
|
||||
is AppError.Server -> message ?: context.getString(R.string.error_server)
|
||||
is AppError.Unknown -> cause?.message ?: context.getString(R.string.error_unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
package ru.daemonlord.messenger.ui.auth
|
||||
|
||||
enum class AuthStep {
|
||||
EMAIL,
|
||||
PASSWORD,
|
||||
REGISTER,
|
||||
OTP,
|
||||
}
|
||||
|
||||
data class AuthUiState(
|
||||
val step: AuthStep = AuthStep.EMAIL,
|
||||
val email: String = "",
|
||||
val name: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val otpCode: String = "",
|
||||
val recoveryCode: String = "",
|
||||
val useRecoveryCode: Boolean = false,
|
||||
val isCheckingSession: Boolean = true,
|
||||
val isLoading: Boolean = false,
|
||||
val isAuthenticated: Boolean = false,
|
||||
val authCompletedNonce: Long = 0L,
|
||||
val successMessage: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
package ru.daemonlord.messenger.ui.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.CheckEmailStatusUseCase
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.LogoutUseCase
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.RegisterUseCase
|
||||
import ru.daemonlord.messenger.domain.auth.usecase.RestoreSessionUseCase
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.R
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val restoreSessionUseCase: RestoreSessionUseCase,
|
||||
private val checkEmailStatusUseCase: CheckEmailStatusUseCase,
|
||||
private val registerUseCase: RegisterUseCase,
|
||||
private val loginUseCase: LoginUseCase,
|
||||
private val logoutUseCase: LogoutUseCase,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AuthUiState())
|
||||
@@ -30,33 +38,235 @@ class AuthViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun onEmailChanged(value: String) {
|
||||
_uiState.update { it.copy(email = value, errorMessage = null) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
email = value,
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onNameChanged(value: String) {
|
||||
_uiState.update { it.copy(name = value, errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onUsernameChanged(value: String) {
|
||||
_uiState.update { it.copy(username = value.replace("@", ""), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onPasswordChanged(value: String) {
|
||||
_uiState.update { it.copy(password = value, errorMessage = null) }
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val state = uiState.value
|
||||
if (state.email.isBlank() || state.password.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "Email and password are required.") }
|
||||
fun onOtpCodeChanged(value: String) {
|
||||
_uiState.update { it.copy(otpCode = value.filter(Char::isDigit).take(8), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onRecoveryCodeChanged(value: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
recoveryCode = value.uppercase().filter { ch -> ch.isLetterOrDigit() || ch == '-' }.take(32),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRecoveryCodeMode() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
useRecoveryCode = !it.useRecoveryCode,
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun backToEmailStep() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AuthStep.EMAIL,
|
||||
password = "",
|
||||
otpCode = "",
|
||||
recoveryCode = "",
|
||||
useRecoveryCode = false,
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun continueWithEmail() {
|
||||
val email = uiState.value.email.trim().lowercase()
|
||||
if (email.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_enter_email)) }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
when (val result = checkEmailStatusUseCase(email)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
email = result.data.email,
|
||||
isLoading = false,
|
||||
step = if (result.data.registered) AuthStep.PASSWORD else AuthStep.REGISTER,
|
||||
errorMessage = null,
|
||||
successMessage = if (result.data.registered) {
|
||||
null
|
||||
} else {
|
||||
context.getString(R.string.auth_info_email_not_registered)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submitAuthStep() {
|
||||
when (uiState.value.step) {
|
||||
AuthStep.EMAIL -> continueWithEmail()
|
||||
AuthStep.REGISTER -> register()
|
||||
AuthStep.PASSWORD -> loginWithoutOtp()
|
||||
AuthStep.OTP -> loginWithOtp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun register() {
|
||||
val state = uiState.value
|
||||
if (state.name.isBlank() || state.username.isBlank() || state.password.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_register_fields_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
|
||||
when (
|
||||
val result = registerUseCase(
|
||||
email = state.email.trim().lowercase(),
|
||||
name = state.name.trim(),
|
||||
username = state.username.trim().removePrefix("@"),
|
||||
password = state.password,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
step = AuthStep.PASSWORD,
|
||||
successMessage = context.getString(R.string.auth_info_account_created),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginWithoutOtp() {
|
||||
val state = uiState.value
|
||||
if (state.password.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_password_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
|
||||
when (val result = loginUseCase(state.email.trim(), state.password)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isAuthenticated = true,
|
||||
authCompletedNonce = System.currentTimeMillis(),
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
val isOtpRequired = (result.reason as? AppError.Server)
|
||||
?.message
|
||||
?.contains("2fa code required", ignoreCase = true) == true
|
||||
if (isOtpRequired) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
step = AuthStep.OTP,
|
||||
errorMessage = null,
|
||||
successMessage = context.getString(R.string.auth_info_enter_2fa_or_recovery),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isAuthenticated = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginWithOtp() {
|
||||
val state = uiState.value
|
||||
val otpCode = state.otpCode.trim().ifBlank { null }
|
||||
val recoveryCode = state.recoveryCode.trim().ifBlank { null }
|
||||
if (!state.useRecoveryCode && otpCode.isNullOrBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_enter_2fa_code)) }
|
||||
return
|
||||
}
|
||||
if (state.useRecoveryCode && recoveryCode.isNullOrBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.auth_error_enter_recovery_code)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
|
||||
when (
|
||||
val result = loginUseCase(
|
||||
email = state.email.trim(),
|
||||
password = state.password,
|
||||
otpCode = if (state.useRecoveryCode) null else otpCode,
|
||||
recoveryCode = if (state.useRecoveryCode) recoveryCode else null,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isAuthenticated = true,
|
||||
authCompletedNonce = System.currentTimeMillis(),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -76,16 +286,47 @@ class AuthViewModel @Inject constructor(
|
||||
runCatching { logoutUseCase() }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AuthStep.EMAIL,
|
||||
email = "",
|
||||
name = "",
|
||||
username = "",
|
||||
password = "",
|
||||
otpCode = "",
|
||||
recoveryCode = "",
|
||||
useRecoveryCode = false,
|
||||
isLoading = false,
|
||||
isAuthenticated = false,
|
||||
errorMessage = null,
|
||||
successMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun recheckSession() {
|
||||
restoreSession()
|
||||
}
|
||||
|
||||
fun startAddAccountFlow() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AuthStep.EMAIL,
|
||||
email = "",
|
||||
name = "",
|
||||
username = "",
|
||||
password = "",
|
||||
otpCode = "",
|
||||
recoveryCode = "",
|
||||
useRecoveryCode = false,
|
||||
isCheckingSession = false,
|
||||
isLoading = false,
|
||||
isAuthenticated = false,
|
||||
successMessage = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreSession() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isCheckingSession = true) }
|
||||
@@ -99,7 +340,6 @@ class AuthViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AppResult.Error -> {
|
||||
val keepAuthenticatedOffline = result.reason is AppError.Network
|
||||
_uiState.update {
|
||||
@@ -116,11 +356,11 @@ class AuthViewModel @Inject constructor(
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.InvalidCredentials -> "Invalid email or password."
|
||||
AppError.Network -> "Network error. Check your connection."
|
||||
AppError.Unauthorized -> "Session expired. Please sign in again."
|
||||
is AppError.Server -> "Server error. Please try again."
|
||||
is AppError.Unknown -> "Unknown error. Please try again."
|
||||
AppError.InvalidCredentials -> context.getString(R.string.auth_error_invalid_credentials)
|
||||
AppError.Network -> context.getString(R.string.auth_error_network)
|
||||
AppError.Unauthorized -> context.getString(R.string.auth_error_session_expired)
|
||||
is AppError.Server -> message ?: context.getString(R.string.auth_error_server)
|
||||
is AppError.Unknown -> context.getString(R.string.auth_error_unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,19 +20,32 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.daemonlord.messenger.R
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
state: AuthUiState,
|
||||
headerTitle: String = "",
|
||||
onEmailChanged: (String) -> Unit,
|
||||
onNameChanged: (String) -> Unit,
|
||||
onUsernameChanged: (String) -> Unit,
|
||||
onPasswordChanged: (String) -> Unit,
|
||||
onLoginClick: () -> Unit,
|
||||
onOtpCodeChanged: (String) -> Unit,
|
||||
onRecoveryCodeChanged: (String) -> Unit,
|
||||
onToggleRecoveryCodeMode: () -> Unit,
|
||||
onContinueEmail: () -> Unit,
|
||||
onSubmitStep: () -> Unit,
|
||||
onBackToEmail: () -> Unit,
|
||||
onOpenVerifyEmail: () -> Unit,
|
||||
onOpenResetPassword: () -> Unit,
|
||||
) {
|
||||
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
||||
val isBusy = state.isLoading
|
||||
val resolvedHeaderTitle = headerTitle.ifBlank { stringResource(id = R.string.auth_header_login) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -47,71 +60,200 @@ fun LoginScreen(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Messenger Login",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 24.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = onEmailChanged,
|
||||
label = { Text(text = "Email") },
|
||||
singleLine = true,
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = onPasswordChanged,
|
||||
label = { Text(text = "Password") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onLoginClick,
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.padding(2.dp),
|
||||
)
|
||||
} else {
|
||||
Text(text = "Login")
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.errorMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = state.errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
text = resolvedHeaderTitle,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 14.dp),
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onOpenVerifyEmail,
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
Text(text = "Verify email by token")
|
||||
}
|
||||
TextButton(
|
||||
onClick = onOpenResetPassword,
|
||||
enabled = !state.isLoading,
|
||||
) {
|
||||
Text(text = "Forgot password")
|
||||
}
|
||||
|
||||
val subtitle = when (state.step) {
|
||||
AuthStep.EMAIL -> stringResource(id = R.string.auth_subtitle_enter_email)
|
||||
AuthStep.PASSWORD -> stringResource(id = R.string.auth_subtitle_enter_password, state.email)
|
||||
AuthStep.REGISTER -> stringResource(id = R.string.auth_subtitle_create_account, state.email)
|
||||
AuthStep.OTP -> stringResource(id = R.string.auth_subtitle_2fa_enabled)
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 18.dp),
|
||||
)
|
||||
|
||||
when (state.step) {
|
||||
AuthStep.EMAIL -> {
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = onEmailChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_email)) },
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = onContinueEmail,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isBusy) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
|
||||
} else {
|
||||
Text(stringResource(id = R.string.auth_continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AuthStep.PASSWORD, AuthStep.REGISTER, AuthStep.OTP -> {
|
||||
OutlinedTextField(
|
||||
value = state.email,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_email)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onBackToEmail,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(bottom = 6.dp),
|
||||
) {
|
||||
Text(stringResource(id = R.string.auth_change_email))
|
||||
}
|
||||
|
||||
if (state.step == AuthStep.REGISTER) {
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = onNameChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_name)) },
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = onUsernameChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_username)) },
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.step != AuthStep.OTP) {
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = onPasswordChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.step == AuthStep.OTP) {
|
||||
TextButton(
|
||||
onClick = onToggleRecoveryCodeMode,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(bottom = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
if (state.useRecoveryCode) {
|
||||
stringResource(id = R.string.auth_use_otp_code)
|
||||
} else {
|
||||
stringResource(id = R.string.auth_use_recovery_code)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state.useRecoveryCode) {
|
||||
OutlinedTextField(
|
||||
value = state.recoveryCode,
|
||||
onValueChange = onRecoveryCodeChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_recovery_code)) },
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.otpCode,
|
||||
onValueChange = onOtpCodeChanged,
|
||||
label = { Text(text = stringResource(id = R.string.auth_label_2fa_code)) },
|
||||
singleLine = true,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSubmitStep,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isBusy) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
|
||||
} else {
|
||||
Text(
|
||||
when (state.step) {
|
||||
AuthStep.PASSWORD -> stringResource(id = R.string.auth_sign_in)
|
||||
AuthStep.REGISTER -> stringResource(id = R.string.auth_create_account)
|
||||
AuthStep.OTP -> stringResource(id = R.string.auth_confirm_2fa)
|
||||
AuthStep.EMAIL -> stringResource(id = R.string.auth_continue)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.errorMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = state.errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
if (!state.successMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = state.successMessage,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onOpenVerifyEmail,
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.auth_verify_email_by_token))
|
||||
}
|
||||
TextButton(
|
||||
onClick = onOpenResetPassword,
|
||||
enabled = !isBusy,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.auth_forgot_password))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.ui.account.AccountViewModel
|
||||
|
||||
@Composable
|
||||
@@ -51,11 +53,11 @@ fun ResetPasswordRoute(
|
||||
.then(if (isTabletLayout) Modifier.widthIn(max = 620.dp) else Modifier),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Password reset", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(stringResource(id = R.string.auth_password_reset_title), style = MaterialTheme.typography.headlineSmall)
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email") },
|
||||
label = { Text(stringResource(id = R.string.auth_label_email)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
@@ -63,12 +65,12 @@ fun ResetPasswordRoute(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isSaving && email.isNotBlank(),
|
||||
) {
|
||||
Text("Send reset link")
|
||||
Text(stringResource(id = R.string.auth_send_reset_link))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("New password") },
|
||||
label = { Text(stringResource(id = R.string.auth_new_password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
@@ -80,7 +82,7 @@ fun ResetPasswordRoute(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isSaving && !token.isNullOrBlank() && password.length >= 8,
|
||||
) {
|
||||
Text("Reset with token")
|
||||
Text(stringResource(id = R.string.auth_reset_with_token))
|
||||
}
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator()
|
||||
@@ -92,7 +94,7 @@ fun ResetPasswordRoute(
|
||||
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Back to login")
|
||||
Text(stringResource(id = R.string.auth_back_to_login))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.ui.account.AccountViewModel
|
||||
|
||||
@Composable
|
||||
@@ -37,6 +39,7 @@ fun VerifyEmailRoute(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var editableToken by remember(token) { mutableStateOf(token.orEmpty()) }
|
||||
var resendEmail by remember { mutableStateOf(state.profile?.email.orEmpty()) }
|
||||
|
||||
LaunchedEffect(token) {
|
||||
if (!token.isNullOrBlank()) {
|
||||
@@ -58,11 +61,11 @@ fun VerifyEmailRoute(
|
||||
.then(if (isTabletLayout) Modifier.widthIn(max = 620.dp) else Modifier),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Verify email", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(stringResource(id = R.string.auth_verify_email_title), style = MaterialTheme.typography.headlineSmall)
|
||||
OutlinedTextField(
|
||||
value = editableToken,
|
||||
onValueChange = { editableToken = it },
|
||||
label = { Text("Verification token") },
|
||||
label = { Text(stringResource(id = R.string.auth_verification_token)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
@@ -70,7 +73,21 @@ fun VerifyEmailRoute(
|
||||
enabled = !state.isSaving && editableToken.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Verify")
|
||||
Text(stringResource(id = R.string.auth_verify))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = resendEmail,
|
||||
onValueChange = { resendEmail = it },
|
||||
label = { Text(stringResource(id = R.string.auth_email_for_resend)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.resendVerification(resendEmail) },
|
||||
enabled = !state.isSaving && resendEmail.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(id = R.string.auth_resend_verification_link))
|
||||
}
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator()
|
||||
@@ -82,7 +99,7 @@ fun VerifyEmailRoute(
|
||||
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Button(onClick = onBackToLogin, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Back to login")
|
||||
Text(stringResource(id = R.string.auth_back_to_login))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
||||
package ru.daemonlord.messenger.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -14,6 +16,10 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase
|
||||
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
@@ -28,6 +34,7 @@ import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageDeliveredUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.SendImageUrlMessageUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.SendMediaMessageUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
|
||||
import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase
|
||||
@@ -45,6 +52,7 @@ class ChatViewModel @Inject constructor(
|
||||
private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase,
|
||||
private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase,
|
||||
private val sendTextMessageUseCase: SendTextMessageUseCase,
|
||||
private val sendImageUrlMessageUseCase: SendImageUrlMessageUseCase,
|
||||
private val sendMediaMessageUseCase: SendMediaMessageUseCase,
|
||||
private val editMessageUseCase: EditMessageUseCase,
|
||||
private val deleteMessageUseCase: DeleteMessageUseCase,
|
||||
@@ -54,10 +62,14 @@ class ChatViewModel @Inject constructor(
|
||||
private val forwardMessageBulkUseCase: ForwardMessageBulkUseCase,
|
||||
private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
|
||||
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val observeChatUseCase: ObserveChatUseCase,
|
||||
private val observeChatsUseCase: ObserveChatsUseCase,
|
||||
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
||||
private val activeChatTracker: ActiveChatTracker,
|
||||
private val notificationDispatcher: NotificationDispatcher,
|
||||
private val tokenRepository: TokenRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
|
||||
@@ -66,9 +78,12 @@ class ChatViewModel @Inject constructor(
|
||||
private val visibleMessagesLimit = MutableStateFlow(MESSAGES_PAGE_SIZE)
|
||||
private var lastDeliveredMessageId: Long? = null
|
||||
private var lastReadMessageId: Long? = null
|
||||
private val reactionsRequestedMessageIds = mutableSetOf<Long>()
|
||||
private var membersLoadKey: String? = null
|
||||
|
||||
init {
|
||||
activeChatTracker.setActiveChat(chatId)
|
||||
notificationDispatcher.clearChatNotifications(chatId)
|
||||
handleRealtimeEventsUseCase.start()
|
||||
observeChatPermissions()
|
||||
observeMessages()
|
||||
@@ -122,7 +137,7 @@ class ChatViewModel @Inject constructor(
|
||||
isRecordingVoice = true,
|
||||
isVoiceLocked = false,
|
||||
voiceRecordingDurationMs = 0L,
|
||||
voiceRecordingHint = "Slide up to lock, slide left to cancel",
|
||||
voiceRecordingHint = context.getString(R.string.chat_voice_hint_slide),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
@@ -139,7 +154,7 @@ class ChatViewModel @Inject constructor(
|
||||
if (!it.isRecordingVoice) it else {
|
||||
it.copy(
|
||||
isVoiceLocked = true,
|
||||
voiceRecordingHint = "Recording locked",
|
||||
voiceRecordingHint = context.getString(R.string.chat_voice_hint_locked),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -169,7 +184,7 @@ class ChatViewModel @Inject constructor(
|
||||
isVoiceLocked = false,
|
||||
voiceRecordingDurationMs = 0L,
|
||||
voiceRecordingHint = null,
|
||||
errorMessage = "Voice message is too short.",
|
||||
errorMessage = context.getString(R.string.chat_error_voice_too_short),
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -189,6 +204,17 @@ class ChatViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
fun onVisibleIncomingMessageId(messageId: Long?) {
|
||||
val visibleIncomingId = messageId ?: return
|
||||
if ((lastReadMessageId ?: 0L) >= visibleIncomingId) {
|
||||
return
|
||||
}
|
||||
lastReadMessageId = visibleIncomingId
|
||||
viewModelScope.launch {
|
||||
markMessageReadUseCase(chatId = chatId, messageId = visibleIncomingId)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSelectMessage(message: MessageItem?) {
|
||||
if (message == null) {
|
||||
onClearSelection()
|
||||
@@ -304,7 +330,7 @@ class ChatViewModel @Inject constructor(
|
||||
val actionState = uiState.value.actionState
|
||||
if (actionState.mode == MessageSelectionMode.MULTI) {
|
||||
if (forAll) {
|
||||
_uiState.update { it.copy(errorMessage = "Delete for all is available only for single message selection.") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_delete_for_all_single)) }
|
||||
return
|
||||
}
|
||||
val selectedIds = actionState.selectedMessageIds.toList().sorted()
|
||||
@@ -332,7 +358,7 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
val selected = getFocusedSelectedMessage() ?: return
|
||||
if (forAll && !canDeleteForAll(selected)) {
|
||||
_uiState.update { it.copy(errorMessage = "Delete for all is available only for your own messages.") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_delete_for_all_own)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -429,6 +455,104 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun onToggleChatNotifications() {
|
||||
viewModelScope.launch {
|
||||
when (val current = chatRepository.getChatNotifications(chatId = chatId)) {
|
||||
is AppResult.Success -> {
|
||||
when (val updated = chatRepository.updateChatNotifications(chatId = chatId, muted = !current.data.muted)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(chatMuted = updated.data.muted) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = updated.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = current.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onClearHistory() {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.clearChat(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
selectedMessage = null,
|
||||
selectedCanEdit = false,
|
||||
selectedCanDeleteForAll = false,
|
||||
actionState = it.actionState.clearSelection(),
|
||||
errorMessage = context.getString(R.string.chat_info_history_cleared),
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDeleteOrLeaveChat() {
|
||||
viewModelScope.launch {
|
||||
val type = uiState.value.chatType.lowercase()
|
||||
val result = when (type) {
|
||||
"group", "channel" -> chatRepository.leaveChat(chatId = chatId)
|
||||
else -> chatRepository.removeChat(chatId = chatId, forAll = false)
|
||||
}
|
||||
when (result) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
selectedMessage = null,
|
||||
selectedCanEdit = false,
|
||||
selectedCanDeleteForAll = false,
|
||||
actionState = it.actionState.clearSelection(),
|
||||
errorMessage = null,
|
||||
chatDeletedNonce = it.chatDeletedNonce + 1L,
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun promoteMember(userId: Long) {
|
||||
if (!ensureCanManageTarget(userId = userId, action = "promote")) return
|
||||
updateMemberRole(userId = userId, role = "admin")
|
||||
}
|
||||
|
||||
fun demoteMember(userId: Long) {
|
||||
if (!ensureCanManageTarget(userId = userId, action = "demote")) return
|
||||
updateMemberRole(userId = userId, role = "member")
|
||||
}
|
||||
|
||||
fun transferOwnership(userId: Long) {
|
||||
if (!ensureCanManageTarget(userId = userId, action = "transfer_ownership", ownerOnly = true)) return
|
||||
updateMemberRole(userId = userId, role = "owner")
|
||||
}
|
||||
|
||||
fun kickMember(userId: Long) {
|
||||
if (!ensureCanManageTarget(userId = userId, action = "kick")) return
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> refreshMembersAndBans()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun banMember(userId: Long) {
|
||||
if (!ensureCanManageTarget(userId = userId, action = "ban")) return
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> refreshMembersAndBans()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unbanMember(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> refreshMembersAndBans()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSendClick() {
|
||||
val text = uiState.value.inputText.trim()
|
||||
if (text.isBlank()) return
|
||||
@@ -440,7 +564,7 @@ class ChatViewModel @Inject constructor(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSending = false,
|
||||
errorMessage = "This message can no longer be edited.",
|
||||
errorMessage = context.getString(R.string.chat_error_edit_expired),
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
@@ -452,7 +576,8 @@ class ChatViewModel @Inject constructor(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSending = false,
|
||||
errorMessage = uiState.value.sendRestrictionText ?: "Sending is restricted in this chat.",
|
||||
errorMessage = uiState.value.sendRestrictionText
|
||||
?: context.getString(R.string.chat_error_send_restricted),
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
@@ -526,6 +651,37 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun onSendPresetMediaUrl(url: String) {
|
||||
val normalizedUrl = url.trim()
|
||||
if (normalizedUrl.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) }
|
||||
when (
|
||||
val result = sendImageUrlMessageUseCase(
|
||||
chatId = chatId,
|
||||
imageUrl = normalizedUrl,
|
||||
replyToMessageId = uiState.value.replyToMessage?.id,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
isUploadingMedia = false,
|
||||
inputText = "",
|
||||
replyToMessage = null,
|
||||
editingMessage = null,
|
||||
)
|
||||
}
|
||||
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isUploadingMedia = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val oldest = uiState.value.messages.firstOrNull() ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -548,13 +704,14 @@ class ChatViewModel @Inject constructor(
|
||||
visibleMessagesLimit
|
||||
.flatMapLatest { limit -> observeMessagesUseCase(chatId = chatId, limit = limit) }
|
||||
.collectLatest { messages ->
|
||||
val sortedMessages = messages.sortedBy { msg -> msg.id }
|
||||
_uiState.update {
|
||||
val pinnedId = it.pinnedMessageId
|
||||
val normalized = it.inlineSearchQuery.trim().lowercase()
|
||||
val inlineMatches = if (normalized.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
messages
|
||||
sortedMessages
|
||||
.filter { msg -> (msg.text ?: "").lowercase().contains(normalized) }
|
||||
.map { msg -> msg.id }
|
||||
}
|
||||
@@ -565,13 +722,14 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
messages = messages.sortedBy { msg -> msg.id },
|
||||
pinnedMessage = pinnedId?.let { id -> messages.firstOrNull { msg -> msg.id == id } },
|
||||
messages = sortedMessages,
|
||||
pinnedMessage = pinnedId?.let { id -> sortedMessages.firstOrNull { msg -> msg.id == id } },
|
||||
inlineSearchMatches = inlineMatches,
|
||||
highlightedMessageId = highlighted,
|
||||
)
|
||||
}
|
||||
acknowledgeLatestIncoming(messages)
|
||||
preloadReactions(sortedMessages)
|
||||
acknowledgeLatestMessages(sortedMessages)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -588,14 +746,18 @@ class ChatViewModel @Inject constructor(
|
||||
val restriction = if (canSend) {
|
||||
null
|
||||
} else {
|
||||
"Only channel owner/admin can send messages."
|
||||
context.getString(R.string.chat_restriction_owner_admin)
|
||||
}
|
||||
val chatTitle = chat.displayTitle.ifBlank {
|
||||
context.getString(R.string.chat_title_fallback, chatId)
|
||||
}
|
||||
val chatTitle = chat.displayTitle.ifBlank { "Chat #$chatId" }
|
||||
val chatSubtitle = when {
|
||||
chat.type.equals("private", ignoreCase = true) && chat.counterpartIsOnline == true -> "online"
|
||||
chat.type.equals("private", ignoreCase = true) && !chat.counterpartLastSeenAt.isNullOrBlank() -> "last seen recently"
|
||||
chat.type.equals("group", ignoreCase = true) -> "group"
|
||||
chat.type.equals("channel", ignoreCase = true) -> "channel"
|
||||
chat.type.equals("private", ignoreCase = true) && chat.counterpartIsOnline == true ->
|
||||
context.getString(R.string.chat_status_online)
|
||||
chat.type.equals("private", ignoreCase = true) && !chat.counterpartLastSeenAt.isNullOrBlank() ->
|
||||
context.getString(R.string.chat_status_last_seen_recently)
|
||||
chat.type.equals("group", ignoreCase = true) -> context.getString(R.string.chat_type_group)
|
||||
chat.type.equals("channel", ignoreCase = true) -> context.getString(R.string.chat_type_channel)
|
||||
else -> ""
|
||||
}
|
||||
_uiState.update {
|
||||
@@ -603,15 +765,56 @@ class ChatViewModel @Inject constructor(
|
||||
it.messages.firstOrNull { message -> message.id == pinnedId }
|
||||
}
|
||||
it.copy(
|
||||
chatType = chat.type,
|
||||
chatRole = role,
|
||||
chatMuted = chat.muted,
|
||||
chatTitle = chatTitle,
|
||||
chatSubtitle = chatSubtitle,
|
||||
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,
|
||||
chatUnreadCount = chat.unreadCount.coerceAtLeast(0),
|
||||
canManageMembers = role == "owner" || role == "admin",
|
||||
canSendMessages = canSend,
|
||||
sendRestrictionText = restriction,
|
||||
pinnedMessageId = chat.pinnedMessageId,
|
||||
pinnedMessage = pinnedMessage,
|
||||
)
|
||||
}
|
||||
|
||||
val shouldLoadMembers = chat.type.equals("group", ignoreCase = true) ||
|
||||
(chat.type.equals("channel", ignoreCase = true) && (role == "owner" || role == "admin"))
|
||||
val nextLoadKey = "${chat.id}:${chat.type.lowercase()}:${role ?: "none"}"
|
||||
if (shouldLoadMembers && membersLoadKey != nextLoadKey) {
|
||||
membersLoadKey = nextLoadKey
|
||||
refreshMembersAndBans()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMemberRole(userId: Long, role: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
|
||||
is AppResult.Success -> refreshMembersAndBans()
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMembersAndBans() {
|
||||
viewModelScope.launch {
|
||||
val membersResult = chatRepository.listMembers(chatId = chatId)
|
||||
val bansResult = chatRepository.listBans(chatId = chatId)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
chatMembers = (membersResult as? AppResult.Success)?.data ?: it.chatMembers,
|
||||
chatBans = (bansResult as? AppResult.Success)?.data ?: it.chatBans,
|
||||
errorMessage = listOf(membersResult, bansResult)
|
||||
.filterIsInstance<AppResult.Error>()
|
||||
.firstOrNull()
|
||||
?.reason
|
||||
?.toUiMessage()
|
||||
?: it.errorMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,11 +822,13 @@ class ChatViewModel @Inject constructor(
|
||||
private fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
val selfUserId = tokenRepository.getActiveUserId()
|
||||
when (val result = syncRecentMessagesUseCase(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(isLoading = false) }
|
||||
is AppResult.Success -> _uiState.update { it.copy(isLoading = false, selfUserId = selfUserId) }
|
||||
is AppResult.Error -> _uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
selfUserId = selfUserId,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
@@ -632,6 +837,7 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun loadReactions(messageId: Long) {
|
||||
if (messageId <= 0L) return
|
||||
viewModelScope.launch {
|
||||
when (val result = listMessageReactionsUseCase(messageId = messageId)) {
|
||||
is AppResult.Success -> {
|
||||
@@ -644,24 +850,38 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun acknowledgeLatestIncoming(messages: List<MessageItem>) {
|
||||
val latestIncoming = messages
|
||||
private fun preloadReactions(messages: List<MessageItem>) {
|
||||
val toRequest = messages
|
||||
.asReversed()
|
||||
.firstOrNull { !it.isOutgoing }
|
||||
?: return
|
||||
.map { it.id }
|
||||
.filter { it > 0L }
|
||||
.filter { reactionsRequestedMessageIds.add(it) }
|
||||
|
||||
if (lastDeliveredMessageId != latestIncoming.id) {
|
||||
if (toRequest.isEmpty()) return
|
||||
|
||||
toRequest.forEach { messageId ->
|
||||
viewModelScope.launch {
|
||||
when (val result = listMessageReactionsUseCase(messageId = messageId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(reactionByMessageId = it.reactionByMessageId + (messageId to result.data))
|
||||
}
|
||||
}
|
||||
is AppResult.Error -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun acknowledgeLatestMessages(messages: List<MessageItem>) {
|
||||
val latestIncoming = messages.asReversed().firstOrNull { !it.isOutgoing }
|
||||
|
||||
if (latestIncoming != null && lastDeliveredMessageId != latestIncoming.id) {
|
||||
lastDeliveredMessageId = latestIncoming.id
|
||||
viewModelScope.launch {
|
||||
markMessageDeliveredUseCase(chatId = chatId, messageId = latestIncoming.id)
|
||||
}
|
||||
}
|
||||
if (lastReadMessageId != latestIncoming.id) {
|
||||
lastReadMessageId = latestIncoming.id
|
||||
viewModelScope.launch {
|
||||
markMessageReadUseCase(chatId = chatId, messageId = latestIncoming.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canEdit(message: MessageItem): Boolean {
|
||||
@@ -713,19 +933,57 @@ class ChatViewModel @Inject constructor(
|
||||
return uiState.value.messages.firstOrNull { it.id == messageId }
|
||||
}
|
||||
|
||||
private fun ensureCanManageTarget(
|
||||
userId: Long,
|
||||
action: String,
|
||||
ownerOnly: Boolean = false,
|
||||
): Boolean {
|
||||
val state = uiState.value
|
||||
val selfId = state.selfUserId
|
||||
if (selfId != null && userId == selfId) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_action_self)) }
|
||||
return false
|
||||
}
|
||||
|
||||
val actorRole = state.chatRole?.lowercase()
|
||||
if (actorRole != "owner" && actorRole != "admin") {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_permissions)) }
|
||||
return false
|
||||
}
|
||||
if (ownerOnly && actorRole != "owner") {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_owner_only)) }
|
||||
return false
|
||||
}
|
||||
|
||||
val targetRole = state.chatMembers.firstOrNull { it.userId == userId }?.role?.lowercase()
|
||||
if (targetRole == "owner") {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_manage_owner)) }
|
||||
return false
|
||||
}
|
||||
if (actorRole == "admin" && (targetRole == "admin" || targetRole == "owner")) {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_admin_manage_admin_owner)) }
|
||||
return false
|
||||
}
|
||||
|
||||
if (action == "transfer_ownership" && targetRole == "owner") {
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_error_transfer_choose_another)) }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.Network -> "Network error."
|
||||
AppError.Unauthorized -> "Session expired."
|
||||
AppError.InvalidCredentials -> "Authorization error."
|
||||
is AppError.Server -> "Server error."
|
||||
is AppError.Unknown -> "Unknown error."
|
||||
AppError.Network -> context.getString(R.string.error_network)
|
||||
AppError.Unauthorized -> context.getString(R.string.error_session_expired)
|
||||
AppError.InvalidCredentials -> context.getString(R.string.error_authorization)
|
||||
is AppError.Server -> context.getString(R.string.error_server)
|
||||
is AppError.Unknown -> context.getString(R.string.error_unknown)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
activeChatTracker.clearActiveChat(chatId)
|
||||
handleRealtimeEventsUseCase.stop()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package ru.daemonlord.messenger.ui.chat
|
||||
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
private data class InlineToken(
|
||||
val marker: String,
|
||||
val style: SpanStyle,
|
||||
)
|
||||
|
||||
fun applyInlineFormatting(
|
||||
value: TextFieldValue,
|
||||
prefix: String,
|
||||
suffix: String = prefix,
|
||||
placeholder: String = "text",
|
||||
): TextFieldValue {
|
||||
val start = value.selection.min
|
||||
val end = value.selection.max
|
||||
val selected = value.text.substring(start, end)
|
||||
val middle = if (selected.isNotBlank()) selected else placeholder
|
||||
val replacement = "$prefix$middle$suffix"
|
||||
val nextText = value.text.replaceRange(start, end, replacement)
|
||||
val selection = if (selected.isNotBlank()) {
|
||||
TextRange(start + replacement.length)
|
||||
} else {
|
||||
TextRange(start + prefix.length, start + prefix.length + middle.length)
|
||||
}
|
||||
return value.copy(text = nextText, selection = selection)
|
||||
}
|
||||
|
||||
fun applyQuoteFormatting(value: TextFieldValue): TextFieldValue {
|
||||
val start = value.selection.min
|
||||
val end = value.selection.max
|
||||
val selected = value.text.substring(start, end).ifBlank { "quote" }
|
||||
val quoted = selected
|
||||
.split('\n')
|
||||
.joinToString("\n") { line -> "> $line" }
|
||||
val nextText = value.text.replaceRange(start, end, quoted)
|
||||
return value.copy(text = nextText, selection = TextRange(start + quoted.length))
|
||||
}
|
||||
|
||||
fun applyLinkFormatting(value: TextFieldValue, url: String = "https://"): TextFieldValue {
|
||||
val start = value.selection.min
|
||||
val end = value.selection.max
|
||||
val selected = value.text.substring(start, end).trim().ifBlank { "text" }
|
||||
val replacement = "[$selected]($url)"
|
||||
val nextText = value.text.replaceRange(start, end, replacement)
|
||||
return value.copy(
|
||||
text = nextText,
|
||||
selection = TextRange(start + selected.length + 3, start + selected.length + 3 + url.length),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FormattedMessageText(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val annotated = buildFormattedMessage(text = text, baseColor = color)
|
||||
ClickableText(
|
||||
text = annotated,
|
||||
style = style,
|
||||
modifier = modifier,
|
||||
onClick = { offset ->
|
||||
annotated
|
||||
.getStringAnnotations(tag = "url", start = offset, end = offset)
|
||||
.firstOrNull()
|
||||
?.item
|
||||
?.let { link ->
|
||||
runCatching { uriHandler.openUri(link) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildFormattedMessage(text: String, baseColor: Color): AnnotatedString {
|
||||
val codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = Color(0x332A2A2A),
|
||||
)
|
||||
val spoilerStyle = SpanStyle(
|
||||
color = Color.Transparent,
|
||||
background = baseColor.copy(alpha = 0.45f),
|
||||
)
|
||||
val linkStyle = SpanStyle(
|
||||
color = Color(0xFF8AB4F8),
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
val quotePrefixStyle = SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.72f),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val tokens = listOf(
|
||||
InlineToken("||", spoilerStyle),
|
||||
InlineToken("**", SpanStyle(fontWeight = FontWeight.Bold)),
|
||||
InlineToken("__", SpanStyle(textDecoration = TextDecoration.Underline)),
|
||||
InlineToken("~~", SpanStyle(textDecoration = TextDecoration.LineThrough)),
|
||||
InlineToken("*", SpanStyle(fontStyle = FontStyle.Italic)),
|
||||
)
|
||||
|
||||
return buildAnnotatedString {
|
||||
val parts = text.split("```")
|
||||
parts.forEachIndexed { index, part ->
|
||||
val isCodeBlock = index % 2 == 1
|
||||
if (isCodeBlock) {
|
||||
val cleanCode = part.trim('\n')
|
||||
val blockStart = length
|
||||
append(cleanCode)
|
||||
addStyle(codeStyle, blockStart, length)
|
||||
if (index != parts.lastIndex) append('\n')
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
val lines = part.split('\n')
|
||||
lines.forEachIndexed { lineIndex, line ->
|
||||
if (line.startsWith("> ")) {
|
||||
val prefixStart = length
|
||||
append("▍ ")
|
||||
addStyle(quotePrefixStyle, prefixStart, length)
|
||||
appendInlineFormatted(
|
||||
source = line.removePrefix("> "),
|
||||
tokens = tokens,
|
||||
codeStyle = codeStyle,
|
||||
linkStyle = linkStyle,
|
||||
)
|
||||
} else {
|
||||
appendInlineFormatted(
|
||||
source = line,
|
||||
tokens = tokens,
|
||||
codeStyle = codeStyle,
|
||||
linkStyle = linkStyle,
|
||||
)
|
||||
}
|
||||
if (lineIndex != lines.lastIndex) append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendInlineFormatted(
|
||||
source: String,
|
||||
tokens: List<InlineToken>,
|
||||
codeStyle: SpanStyle,
|
||||
linkStyle: SpanStyle,
|
||||
) {
|
||||
var i = 0
|
||||
while (i < source.length) {
|
||||
val linkMatch = Regex("""^\[([^\]]+)]\(([^)]+)\)""").find(source.substring(i))
|
||||
if (linkMatch != null) {
|
||||
val label = linkMatch.groupValues[1]
|
||||
val href = linkMatch.groupValues[2].trim()
|
||||
if (href.startsWith("http://", ignoreCase = true) || href.startsWith("https://", ignoreCase = true)) {
|
||||
val start = length
|
||||
appendInlineFormatted(
|
||||
source = label,
|
||||
tokens = tokens,
|
||||
codeStyle = codeStyle,
|
||||
linkStyle = linkStyle,
|
||||
)
|
||||
addStyle(linkStyle, start, length)
|
||||
addStringAnnotation(tag = "url", annotation = href, start = start, end = length)
|
||||
i += linkMatch.value.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
val bareUrl = Regex("""^https?://[^\s<>()]+""", RegexOption.IGNORE_CASE).find(source.substring(i))
|
||||
if (bareUrl != null) {
|
||||
val raw = bareUrl.value
|
||||
val trimmed = raw.trimEnd(',', ')', '.', ';', '!', '?')
|
||||
val trailing = raw.removePrefix(trimmed)
|
||||
val start = length
|
||||
append(trimmed)
|
||||
addStyle(linkStyle, start, length)
|
||||
addStringAnnotation(tag = "url", annotation = trimmed, start = start, end = length)
|
||||
append(trailing)
|
||||
i += raw.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (source.startsWith("`", i)) {
|
||||
val end = source.indexOf('`', startIndex = i + 1)
|
||||
if (end > i + 1) {
|
||||
val codeStart = length
|
||||
append(source.substring(i + 1, end))
|
||||
addStyle(codeStyle, codeStart, length)
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var matched = false
|
||||
for (token in tokens) {
|
||||
if (!source.startsWith(token.marker, i)) continue
|
||||
val end = source.indexOf(token.marker, startIndex = i + token.marker.length)
|
||||
if (end <= i + token.marker.length) continue
|
||||
val inner = source.substring(i + token.marker.length, end)
|
||||
if (inner.isBlank()) continue
|
||||
val rangeStart = length
|
||||
appendInlineFormatted(
|
||||
source = inner,
|
||||
tokens = tokens,
|
||||
codeStyle = codeStyle,
|
||||
linkStyle = linkStyle,
|
||||
)
|
||||
addStyle(token.style, rangeStart, length)
|
||||
i = end + token.marker.length
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if (matched) continue
|
||||
|
||||
append(source[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package ru.daemonlord.messenger.ui.chat
|
||||
|
||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||
import ru.daemonlord.messenger.domain.message.model.MessageReaction
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||
|
||||
data class MessageUiState(
|
||||
val chatId: Long = 0L,
|
||||
val selfUserId: Long? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val isSending: Boolean = false,
|
||||
@@ -13,6 +16,13 @@ data class MessageUiState(
|
||||
val chatTitle: String = "",
|
||||
val chatSubtitle: String = "",
|
||||
val chatAvatarUrl: String? = null,
|
||||
val chatType: String = "",
|
||||
val chatRole: String? = null,
|
||||
val chatMuted: Boolean = false,
|
||||
val chatUnreadCount: Int = 0,
|
||||
val chatMembers: List<ChatMemberItem> = emptyList(),
|
||||
val chatBans: List<ChatBanItem> = emptyList(),
|
||||
val canManageMembers: Boolean = false,
|
||||
val messages: List<MessageItem> = emptyList(),
|
||||
val pinnedMessageId: Long? = null,
|
||||
val pinnedMessage: MessageItem? = null,
|
||||
@@ -36,6 +46,7 @@ data class MessageUiState(
|
||||
val inlineSearchMatches: List<Long> = emptyList(),
|
||||
val highlightedMessageId: Long? = null,
|
||||
val actionState: MessageActionState = MessageActionState(),
|
||||
val chatDeletedNonce: Long = 0L,
|
||||
)
|
||||
|
||||
data class ForwardTargetUiModel(
|
||||
|
||||
@@ -30,9 +30,11 @@ import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -40,6 +42,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -56,7 +59,9 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -68,9 +73,11 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Inventory2
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.BookmarkBorder
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
@@ -79,6 +86,8 @@ import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import coil.compose.AsyncImage
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
@@ -134,6 +143,7 @@ fun ChatListRoute(
|
||||
onUpdateChatProfile = viewModel::updateChatProfile,
|
||||
onClearChat = viewModel::clearChat,
|
||||
onDeleteChat = viewModel::deleteChatForMe,
|
||||
onDeleteChatForAll = viewModel::deleteChatForAll,
|
||||
onToggleChatMute = viewModel::toggleChatMute,
|
||||
onSelectManageChat = viewModel::onManagementChatSelected,
|
||||
onCreateInvite = viewModel::createInvite,
|
||||
@@ -142,6 +152,7 @@ fun ChatListRoute(
|
||||
onRemoveMember = viewModel::removeMember,
|
||||
onBanMember = viewModel::banMember,
|
||||
onUnbanMember = viewModel::unbanMember,
|
||||
onToggleDayNightMode = viewModel::toggleDayNightMode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -173,6 +184,7 @@ fun ChatListScreen(
|
||||
onUpdateChatProfile: (Long, String?, String?) -> Unit,
|
||||
onClearChat: (Long) -> Unit,
|
||||
onDeleteChat: (Long) -> Unit,
|
||||
onDeleteChatForAll: (Long) -> Unit,
|
||||
onToggleChatMute: (Long) -> Unit,
|
||||
onSelectManageChat: (Long?) -> Unit,
|
||||
onCreateInvite: (Long) -> Unit,
|
||||
@@ -181,6 +193,7 @@ fun ChatListScreen(
|
||||
onRemoveMember: (Long, Long) -> Unit,
|
||||
onBanMember: (Long, Long) -> Unit,
|
||||
onUnbanMember: (Long, Long) -> Unit,
|
||||
onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var managementExpanded by remember { mutableStateOf(false) }
|
||||
@@ -200,6 +213,13 @@ fun ChatListScreen(
|
||||
var selectedManageChatIdText by remember { mutableStateOf("") }
|
||||
var manageUserIdText by remember { mutableStateOf("") }
|
||||
var manageRoleText by remember { mutableStateOf("member") }
|
||||
var showCreateGroupDialog by remember { mutableStateOf(false) }
|
||||
var showCreateChannelDialog by remember { mutableStateOf(false) }
|
||||
var quickCreateGroupTitle by remember { mutableStateOf("") }
|
||||
var quickCreateChannelTitle by remember { mutableStateOf("") }
|
||||
var quickCreateChannelHandle by remember { mutableStateOf("") }
|
||||
var showDeleteChatsDialog by remember { mutableStateOf(false) }
|
||||
var deleteSelectedForAll by remember { mutableStateOf(false) }
|
||||
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
||||
val listState = rememberLazyListState()
|
||||
val selectedChats = remember(state.chats, selectedChatIds) {
|
||||
@@ -282,9 +302,9 @@ fun ChatListScreen(
|
||||
when {
|
||||
selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString()
|
||||
isSearchMode -> ""
|
||||
state.isConnecting -> "Connecting..."
|
||||
state.selectedTab == ChatTab.ARCHIVED -> "Archived"
|
||||
else -> "Chats"
|
||||
state.isConnecting -> stringResource(id = R.string.chats_connecting)
|
||||
state.selectedTab == ChatTab.ARCHIVED -> stringResource(id = R.string.chats_archived)
|
||||
else -> stringResource(id = R.string.nav_chats)
|
||||
}
|
||||
)
|
||||
},
|
||||
@@ -307,23 +327,22 @@ fun ChatListScreen(
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FolderOpen,
|
||||
contentDescription = "Архивировать",
|
||||
contentDescription = stringResource(id = R.string.chats_contentdesc_archive_selected),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
selectedChatIds.forEach { chatId -> onDeleteChat(chatId) }
|
||||
selectedChatIds = emptySet()
|
||||
showDeleteChatsDialog = true
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = "Delete selected",
|
||||
contentDescription = stringResource(id = R.string.chats_contentdesc_delete_selected),
|
||||
)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showSelectionMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = "Меню выбранного",
|
||||
contentDescription = stringResource(id = R.string.chats_contentdesc_selection_menu),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -331,7 +350,15 @@ fun ChatListScreen(
|
||||
onDismissRequest = { showSelectionMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (allSelectedPinned) "Открепить" else "Закрепить") },
|
||||
text = {
|
||||
Text(
|
||||
if (allSelectedPinned) {
|
||||
stringResource(id = R.string.chats_selection_unpin)
|
||||
} else {
|
||||
stringResource(id = R.string.chats_selection_pin)
|
||||
},
|
||||
)
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) },
|
||||
onClick = {
|
||||
showSelectionMenu = false
|
||||
@@ -342,23 +369,23 @@ fun ChatListScreen(
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Добавить в папку") },
|
||||
text = { Text(stringResource(id = R.string.chats_selection_add_to_folder)) },
|
||||
leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) },
|
||||
onClick = {
|
||||
showSelectionMenu = false
|
||||
Toast.makeText(context, "Папки чатов будут добавлены позже.", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.chats_toast_folders_coming_soon), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Пометить непрочитанным") },
|
||||
text = { Text(stringResource(id = R.string.chats_selection_mark_unread)) },
|
||||
leadingIcon = { Icon(Icons.Filled.DoneAll, contentDescription = null) },
|
||||
onClick = {
|
||||
showSelectionMenu = false
|
||||
Toast.makeText(context, "Отметка непрочитанным будет добавлена позже.", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.chats_toast_mark_unread_coming_soon), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Удалить из кэша") },
|
||||
text = { Text(stringResource(id = R.string.chats_selection_clear_cache)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
|
||||
onClick = {
|
||||
showSelectionMenu = false
|
||||
@@ -394,45 +421,44 @@ fun ChatListScreen(
|
||||
onDismissRequest = { showDefaultMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Day mode") },
|
||||
text = {
|
||||
Text(
|
||||
if (MaterialTheme.colorScheme.background.luminance() < 0.5f) {
|
||||
stringResource(id = R.string.menu_day_mode)
|
||||
} else {
|
||||
stringResource(id = R.string.menu_night_mode)
|
||||
}
|
||||
)
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) },
|
||||
onClick = {
|
||||
showDefaultMenu = false
|
||||
Toast.makeText(context, "Theme switch in Settings.", Toast.LENGTH_SHORT).show()
|
||||
onToggleDayNightMode { nextMode ->
|
||||
val toastRes = when (nextMode) {
|
||||
AppThemeMode.LIGHT -> R.string.toast_day_mode_enabled
|
||||
AppThemeMode.DARK -> R.string.toast_night_mode_enabled
|
||||
AppThemeMode.SYSTEM -> R.string.toast_day_mode_enabled
|
||||
}
|
||||
Toast.makeText(context, context.getString(toastRes), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Create group") },
|
||||
leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) },
|
||||
text = { Text(stringResource(id = R.string.menu_create_group)) },
|
||||
leadingIcon = { Icon(Icons.Filled.Groups, contentDescription = null) },
|
||||
onClick = {
|
||||
showDefaultMenu = false
|
||||
managementExpanded = true
|
||||
showCreateGroupDialog = true
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Create channel") },
|
||||
leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) },
|
||||
onClick = {
|
||||
showDefaultMenu = false
|
||||
managementExpanded = true
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Saved") },
|
||||
leadingIcon = { Icon(Icons.Filled.Inventory2, contentDescription = null) },
|
||||
text = { Text(stringResource(id = R.string.menu_saved)) },
|
||||
leadingIcon = { Icon(Icons.Filled.BookmarkBorder, contentDescription = null) },
|
||||
onClick = {
|
||||
showDefaultMenu = false
|
||||
onOpenSaved()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Proxy") },
|
||||
leadingIcon = { Icon(Icons.Filled.NotificationsOff, contentDescription = null) },
|
||||
onClick = {
|
||||
showDefaultMenu = false
|
||||
Toast.makeText(context, "Proxy settings will be added next.", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,22 +473,22 @@ fun ChatListScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
label = "All",
|
||||
label = stringResource(id = R.string.filter_all),
|
||||
selected = state.selectedFilter == ChatListFilter.ALL,
|
||||
onClick = { onFilterSelected(ChatListFilter.ALL) },
|
||||
)
|
||||
FilterChip(
|
||||
label = "People",
|
||||
label = stringResource(id = R.string.filter_people),
|
||||
selected = state.selectedFilter == ChatListFilter.PEOPLE,
|
||||
onClick = { onFilterSelected(ChatListFilter.PEOPLE) },
|
||||
)
|
||||
FilterChip(
|
||||
label = "Groups",
|
||||
label = stringResource(id = R.string.filter_groups),
|
||||
selected = state.selectedFilter == ChatListFilter.GROUPS,
|
||||
onClick = { onFilterSelected(ChatListFilter.GROUPS) },
|
||||
)
|
||||
FilterChip(
|
||||
label = "Channels",
|
||||
label = stringResource(id = R.string.filter_channels),
|
||||
selected = state.selectedFilter == ChatListFilter.CHANNELS,
|
||||
onClick = { onFilterSelected(ChatListFilter.CHANNELS) },
|
||||
)
|
||||
@@ -475,7 +501,7 @@ fun ChatListScreen(
|
||||
) {
|
||||
when {
|
||||
state.isLoading -> {
|
||||
CenterState(text = "Loading chats...", loading = true)
|
||||
CenterState(text = stringResource(id = R.string.chats_loading), loading = true)
|
||||
}
|
||||
|
||||
!state.errorMessage.isNullOrBlank() -> {
|
||||
@@ -483,7 +509,7 @@ fun ChatListScreen(
|
||||
}
|
||||
|
||||
state.chats.isEmpty() -> {
|
||||
CenterState(text = "No chats found", loading = false)
|
||||
CenterState(text = stringResource(id = R.string.chats_not_found), loading = false)
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -759,6 +785,120 @@ fun ChatListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showCreateGroupDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCreateGroupDialog = false },
|
||||
title = { Text(stringResource(id = R.string.chats_dialog_create_group_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = quickCreateGroupTitle,
|
||||
onValueChange = { quickCreateGroupTitle = it },
|
||||
label = { Text(stringResource(id = R.string.chats_dialog_group_title_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val title = quickCreateGroupTitle.trim()
|
||||
if (title.isNotBlank()) {
|
||||
onCreateGroup(title, emptyList())
|
||||
showCreateGroupDialog = false
|
||||
quickCreateGroupTitle = ""
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(id = R.string.common_create)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showCreateGroupDialog = false }) { Text(stringResource(id = R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showCreateChannelDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCreateChannelDialog = false },
|
||||
title = { Text(stringResource(id = R.string.chats_dialog_create_channel_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = quickCreateChannelTitle,
|
||||
onValueChange = { quickCreateChannelTitle = it },
|
||||
label = { Text(stringResource(id = R.string.chats_dialog_channel_title_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = quickCreateChannelHandle,
|
||||
onValueChange = { quickCreateChannelHandle = it },
|
||||
label = { Text(stringResource(id = R.string.chats_dialog_channel_handle_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val title = quickCreateChannelTitle.trim()
|
||||
val handle = quickCreateChannelHandle.trim()
|
||||
if (title.isNotBlank() && handle.isNotBlank()) {
|
||||
onCreateChannel(title, handle, null)
|
||||
showCreateChannelDialog = false
|
||||
quickCreateChannelTitle = ""
|
||||
quickCreateChannelHandle = ""
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(id = R.string.common_create)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showCreateChannelDialog = false }) { Text(stringResource(id = R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showDeleteChatsDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteChatsDialog = false },
|
||||
title = { Text(stringResource(id = R.string.chats_dialog_delete_selected_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(id = R.string.chats_dialog_delete_selected_body))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { deleteSelectedForAll = !deleteSelectedForAll },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = deleteSelectedForAll,
|
||||
onCheckedChange = { deleteSelectedForAll = it },
|
||||
)
|
||||
Text(stringResource(id = R.string.chats_dialog_delete_for_all))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
selectedChatIds.forEach { chatId ->
|
||||
if (deleteSelectedForAll) onDeleteChatForAll(chatId) else onDeleteChat(chatId)
|
||||
}
|
||||
selectedChatIds = emptySet()
|
||||
deleteSelectedForAll = false
|
||||
showDeleteChatsDialog = false
|
||||
},
|
||||
) { Text(stringResource(id = R.string.common_delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteChatsDialog = false
|
||||
deleteSelectedForAll = false
|
||||
},
|
||||
) { Text(stringResource(id = R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1505,6 +1645,10 @@ private fun CenterState(
|
||||
|
||||
private fun ChatItem.previewText(): String {
|
||||
val raw = lastMessageText.orEmpty().trim()
|
||||
val isGifImage = lastMessageType == "image" && isGifLikeUrl(raw)
|
||||
val isStickerImage = lastMessageType == "image" && isStickerLikeUrl(raw)
|
||||
if (isGifImage) return "🖼 GIF"
|
||||
if (isStickerImage) return "🖼 Sticker"
|
||||
val prefix = when (lastMessageType) {
|
||||
"image" -> "🖼"
|
||||
"video" -> "🎥"
|
||||
@@ -1537,3 +1681,15 @@ private fun ChatItem.previewText(): String {
|
||||
else -> "Media"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGifLikeUrl(url: String): Boolean {
|
||||
if (url.isBlank()) return false
|
||||
val normalized = url.lowercase()
|
||||
return normalized.contains(".gif") || normalized.contains("giphy.com")
|
||||
}
|
||||
|
||||
private fun isStickerLikeUrl(url: String): Boolean {
|
||||
if (url.isBlank()) return false
|
||||
val normalized = url.lowercase()
|
||||
return normalized.contains("twemoji") || normalized.endsWith(".webp")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package ru.daemonlord.messenger.ui.chats
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -24,7 +27,10 @@ import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
|
||||
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
|
||||
import ru.daemonlord.messenger.R
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -38,6 +44,8 @@ class ChatListViewModel @Inject constructor(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val chatSearchRepository: ChatSearchRepository,
|
||||
private val searchRepository: SearchRepository,
|
||||
private val themeRepository: ThemeRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val selectedTab = MutableStateFlow(ChatTab.ALL)
|
||||
@@ -170,6 +178,29 @@ class ChatListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDayNightMode(onResult: (AppThemeMode) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
val current = themeRepository.getThemeMode()
|
||||
val next = when (current) {
|
||||
AppThemeMode.DARK -> AppThemeMode.LIGHT
|
||||
AppThemeMode.LIGHT -> AppThemeMode.DARK
|
||||
AppThemeMode.SYSTEM -> {
|
||||
val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES
|
||||
if (isNight) AppThemeMode.LIGHT else AppThemeMode.DARK
|
||||
}
|
||||
}
|
||||
themeRepository.setThemeMode(next)
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (next) {
|
||||
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
)
|
||||
onResult(next)
|
||||
}
|
||||
}
|
||||
|
||||
fun onManagementChatSelected(chatId: Long?) {
|
||||
_uiState.update { it.copy(selectedManageChatId = chatId) }
|
||||
if (chatId != null) {
|
||||
@@ -205,7 +236,7 @@ class ChatListViewModel @Inject constructor(
|
||||
handle = null,
|
||||
description = null,
|
||||
memberIds = memberIds,
|
||||
successMessage = "Group created.",
|
||||
successMessageResId = R.string.chat_list_info_group_created,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,7 +248,7 @@ class ChatListViewModel @Inject constructor(
|
||||
handle = handle,
|
||||
description = description,
|
||||
memberIds = emptyList(),
|
||||
successMessage = "Channel created.",
|
||||
successMessageResId = R.string.chat_list_info_channel_created,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -227,7 +258,7 @@ class ChatListViewModel @Inject constructor(
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
managementMessage = "Joined chat.",
|
||||
managementMessage = context.getString(R.string.chat_list_info_joined_chat),
|
||||
pendingOpenChatId = result.data.id,
|
||||
)
|
||||
}
|
||||
@@ -242,7 +273,7 @@ class ChatListViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.leaveChat(chatId = chatId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = "Left chat.") }
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_left_chat)) }
|
||||
refreshCurrentTab(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
@@ -254,7 +285,7 @@ class ChatListViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.archiveChat(chatId = chatId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = "Чат архивирован.") }
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_archived)) }
|
||||
refreshCurrentTab(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
@@ -266,7 +297,7 @@ class ChatListViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.unarchiveChat(chatId = chatId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = "Чат возвращен из архива.") }
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_unarchived)) }
|
||||
refreshCurrentTab(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
@@ -277,7 +308,7 @@ class ChatListViewModel @Inject constructor(
|
||||
fun pinChat(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.pinChat(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Чат закреплен.") }
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_pinned)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
@@ -286,7 +317,7 @@ class ChatListViewModel @Inject constructor(
|
||||
fun unpinChat(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.unpinChat(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Чат откреплен.") }
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_unpinned)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
@@ -295,7 +326,7 @@ class ChatListViewModel @Inject constructor(
|
||||
fun clearChat(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.clearChat(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "История чата очищена.") }
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_history_cleared)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
@@ -304,12 +335,12 @@ class ChatListViewModel @Inject constructor(
|
||||
fun updateChatTitle(chatId: Long, title: String) {
|
||||
val normalized = title.trim()
|
||||
if (normalized.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "Title is required.") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.updateChatTitle(chatId = chatId, title = normalized)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Title updated.") }
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_title_updated)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
@@ -319,7 +350,7 @@ class ChatListViewModel @Inject constructor(
|
||||
val normalizedTitle = title?.trim()?.ifBlank { null }
|
||||
val normalizedDescription = description?.trim()?.ifBlank { null }
|
||||
if (normalizedTitle == null && normalizedDescription == null) {
|
||||
_uiState.update { it.copy(errorMessage = "Provide title or description.") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_or_description_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -331,7 +362,7 @@ class ChatListViewModel @Inject constructor(
|
||||
avatarUrl = null,
|
||||
)
|
||||
) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Profile updated.") }
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_profile_updated)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
@@ -340,7 +371,16 @@ class ChatListViewModel @Inject constructor(
|
||||
fun deleteChatForMe(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.removeChat(chatId = chatId, forAll = false)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Чат удален.") }
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_deleted_for_me)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteChatForAll(chatId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.removeChat(chatId = chatId, forAll = true)) {
|
||||
is AppResult.Success -> _uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_deleted_for_all)) }
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
}
|
||||
@@ -353,7 +393,11 @@ class ChatListViewModel @Inject constructor(
|
||||
when (val updated = chatRepository.updateChatNotifications(chatId = chatId, muted = !current.data.muted)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(
|
||||
managementMessage = if (updated.data.muted) "Уведомления выключены." else "Уведомления включены.",
|
||||
managementMessage = if (updated.data.muted) {
|
||||
context.getString(R.string.chat_list_info_notifications_disabled)
|
||||
} else {
|
||||
context.getString(R.string.chat_list_info_notifications_enabled)
|
||||
},
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = updated.reason.toUiMessage()) }
|
||||
@@ -368,7 +412,12 @@ class ChatListViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.createInviteLink(chatId = chatId)) {
|
||||
is AppResult.Success -> _uiState.update {
|
||||
it.copy(managementMessage = "Invite: ${result.data.inviteUrl}")
|
||||
it.copy(
|
||||
managementMessage = context.getString(
|
||||
R.string.chat_list_info_invite_created,
|
||||
result.data.inviteUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
}
|
||||
@@ -379,7 +428,14 @@ class ChatListViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.addMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = "Added ${result.data.name}") }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
managementMessage = context.getString(
|
||||
R.string.chat_list_info_member_added,
|
||||
result.data.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
@@ -391,7 +447,15 @@ class ChatListViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = "Role updated: ${result.data.name} -> ${result.data.role}") }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
managementMessage = context.getString(
|
||||
R.string.chat_list_info_member_role_updated,
|
||||
result.data.name,
|
||||
result.data.role,
|
||||
),
|
||||
)
|
||||
}
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
@@ -403,7 +467,7 @@ class ChatListViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = "Member removed.") }
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_member_removed)) }
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
@@ -415,7 +479,7 @@ class ChatListViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = "Member banned.") }
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_member_banned)) }
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
@@ -427,7 +491,7 @@ class ChatListViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(managementMessage = "Member unbanned.") }
|
||||
_uiState.update { it.copy(managementMessage = context.getString(R.string.chat_list_info_member_unbanned)) }
|
||||
loadMembersAndBans(chatId)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
@@ -517,11 +581,11 @@ class ChatListViewModel @Inject constructor(
|
||||
handle: String?,
|
||||
description: String?,
|
||||
memberIds: List<Long>,
|
||||
successMessage: String,
|
||||
successMessageResId: Int,
|
||||
) {
|
||||
val normalizedTitle = title.trim()
|
||||
if (normalizedTitle.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "Title is required.") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.chat_list_error_title_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -538,7 +602,7 @@ class ChatListViewModel @Inject constructor(
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
managementMessage = successMessage,
|
||||
managementMessage = context.getString(successMessageResId),
|
||||
pendingOpenChatId = result.data.id,
|
||||
)
|
||||
}
|
||||
@@ -610,16 +674,15 @@ class ChatListViewModel @Inject constructor(
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.Network -> "Network error while syncing chats."
|
||||
AppError.Unauthorized -> "Session expired. Please log in again."
|
||||
AppError.InvalidCredentials -> "Authorization failed."
|
||||
is AppError.Server -> "Server error while loading chats."
|
||||
is AppError.Unknown -> "Unknown error while loading chats."
|
||||
AppError.Network -> context.getString(R.string.chat_list_error_network_sync)
|
||||
AppError.Unauthorized -> context.getString(R.string.chat_list_error_session_expired)
|
||||
AppError.InvalidCredentials -> context.getString(R.string.chat_list_error_authorization_failed)
|
||||
is AppError.Server -> context.getString(R.string.chat_list_error_server_loading)
|
||||
is AppError.Unknown -> context.getString(R.string.chat_list_error_unknown_loading)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
handleRealtimeEventsUseCase.stop()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,14 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import ru.daemonlord.messenger.R
|
||||
|
||||
@Composable
|
||||
fun ContactsRoute(
|
||||
@@ -104,7 +106,7 @@ private fun ContactsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text("Contacts") },
|
||||
title = { Text(stringResource(id = R.string.contacts_title)) },
|
||||
)
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.isRefreshing,
|
||||
@@ -122,7 +124,7 @@ private fun ContactsScreen(
|
||||
onValueChange = onQueryChanged,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Search contacts/users") },
|
||||
label = { Text(stringResource(id = R.string.contacts_search_label)) },
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -134,10 +136,10 @@ private fun ContactsScreen(
|
||||
onValueChange = onAddByEmailChanged,
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
label = { Text("Add by email") },
|
||||
label = { Text(stringResource(id = R.string.contacts_add_by_email_label)) },
|
||||
)
|
||||
Button(onClick = onAddContactByEmail) {
|
||||
Text("Add")
|
||||
Text(stringResource(id = R.string.common_create))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +174,7 @@ private fun ContactsScreen(
|
||||
if (state.isSearchingUsers || state.query.trim().length >= 2) {
|
||||
item(key = "search_header") {
|
||||
Text(
|
||||
text = "Search results",
|
||||
text = stringResource(id = R.string.contacts_search_results),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
@@ -197,7 +199,7 @@ private fun ContactsScreen(
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = { onAddContact(user.id) }) {
|
||||
Text("Add")
|
||||
Text(stringResource(id = R.string.common_create))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +207,7 @@ private fun ContactsScreen(
|
||||
|
||||
item(key = "contacts_header") {
|
||||
Text(
|
||||
text = "My contacts",
|
||||
text = stringResource(id = R.string.contacts_my_contacts),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
@@ -226,13 +228,13 @@ private fun ContactsScreen(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = contact.username?.let { "@$it" } ?: "last seen recently",
|
||||
text = contact.username?.let { "@$it" } ?: stringResource(id = R.string.contacts_last_seen_recently),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = { onRemoveContact(contact.id) }) {
|
||||
Text("Remove")
|
||||
Text(stringResource(id = R.string.contacts_remove))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +242,7 @@ private fun ContactsScreen(
|
||||
if (state.contacts.isEmpty()) {
|
||||
item(key = "empty_contacts") {
|
||||
Text(
|
||||
text = "No contacts yet.",
|
||||
text = stringResource(id = R.string.contacts_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,9 @@ package ru.daemonlord.messenger.ui.contacts
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -10,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
@@ -18,6 +21,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class ContactsViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ContactsUiState())
|
||||
@@ -76,7 +80,7 @@ class ContactsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.addContact(userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(infoMessage = "Contact added.") }
|
||||
_uiState.update { it.copy(infoMessage = context.getString(R.string.contacts_info_added)) }
|
||||
loadContacts(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
@@ -87,7 +91,7 @@ class ContactsViewModel @Inject constructor(
|
||||
fun addContactByEmail() {
|
||||
val email = uiState.value.addByEmail.trim()
|
||||
if (email.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "Email is required.") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.contacts_error_email_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -96,7 +100,7 @@ class ContactsViewModel @Inject constructor(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
addByEmail = "",
|
||||
infoMessage = "Contact added by email.",
|
||||
infoMessage = context.getString(R.string.contacts_info_added_by_email),
|
||||
)
|
||||
}
|
||||
loadContacts(forceRefresh = true)
|
||||
@@ -110,7 +114,7 @@ class ContactsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.removeContact(userId = userId)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update { it.copy(infoMessage = "Contact removed.") }
|
||||
_uiState.update { it.copy(infoMessage = context.getString(R.string.contacts_info_removed)) }
|
||||
loadContacts(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||
@@ -148,11 +152,11 @@ class ContactsViewModel @Inject constructor(
|
||||
|
||||
private fun AppError.toUiMessage(): String {
|
||||
return when (this) {
|
||||
AppError.Network -> "Network error."
|
||||
AppError.Unauthorized -> "Session expired."
|
||||
AppError.InvalidCredentials -> "Authorization error."
|
||||
is AppError.Server -> this.message ?: "Server error."
|
||||
is AppError.Unknown -> this.cause?.message ?: "Unknown error."
|
||||
AppError.Network -> context.getString(R.string.error_network)
|
||||
AppError.Unauthorized -> context.getString(R.string.error_session_expired)
|
||||
AppError.InvalidCredentials -> context.getString(R.string.error_authorization)
|
||||
is AppError.Server -> this.message ?: context.getString(R.string.error_server)
|
||||
is AppError.Unknown -> this.cause?.message ?: context.getString(R.string.error_unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -57,6 +58,7 @@ import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.navigation
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.ui.auth.AuthViewModel
|
||||
import ru.daemonlord.messenger.ui.auth.LoginScreen
|
||||
import ru.daemonlord.messenger.ui.auth.reset.ResetPasswordRoute
|
||||
@@ -68,8 +70,10 @@ import ru.daemonlord.messenger.ui.profile.ProfileRoute
|
||||
import ru.daemonlord.messenger.ui.settings.SettingsRoute
|
||||
|
||||
private object Routes {
|
||||
const val Startup = "startup"
|
||||
const val AuthGraph = "auth_graph"
|
||||
const val Login = "login"
|
||||
const val AddAccountLogin = "add_account_login"
|
||||
const val VerifyEmail = "verify_email"
|
||||
const val ResetPassword = "reset_password"
|
||||
const val Chats = "chats"
|
||||
@@ -167,25 +171,76 @@ fun MessengerNavHost(
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.AuthGraph,
|
||||
startDestination = Routes.Startup,
|
||||
) {
|
||||
composable(route = Routes.Startup) {
|
||||
SessionCheckingScreen()
|
||||
}
|
||||
|
||||
navigation(
|
||||
route = Routes.AuthGraph,
|
||||
startDestination = Routes.Login,
|
||||
) {
|
||||
composable(route = Routes.Login) {
|
||||
if (uiState.isCheckingSession) {
|
||||
SessionCheckingScreen()
|
||||
} else {
|
||||
LoginScreen(
|
||||
state = uiState,
|
||||
onEmailChanged = viewModel::onEmailChanged,
|
||||
onPasswordChanged = viewModel::onPasswordChanged,
|
||||
onLoginClick = viewModel::login,
|
||||
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
|
||||
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
|
||||
)
|
||||
LoginScreen(
|
||||
state = uiState,
|
||||
headerTitle = context.getString(R.string.auth_header_login),
|
||||
onEmailChanged = viewModel::onEmailChanged,
|
||||
onNameChanged = viewModel::onNameChanged,
|
||||
onUsernameChanged = viewModel::onUsernameChanged,
|
||||
onPasswordChanged = viewModel::onPasswordChanged,
|
||||
onOtpCodeChanged = viewModel::onOtpCodeChanged,
|
||||
onRecoveryCodeChanged = viewModel::onRecoveryCodeChanged,
|
||||
onToggleRecoveryCodeMode = viewModel::toggleRecoveryCodeMode,
|
||||
onContinueEmail = viewModel::continueWithEmail,
|
||||
onSubmitStep = viewModel::submitAuthStep,
|
||||
onBackToEmail = viewModel::backToEmailStep,
|
||||
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
|
||||
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
|
||||
)
|
||||
}
|
||||
composable(route = Routes.AddAccountLogin) { entry ->
|
||||
val addAccountViewModel: AuthViewModel = hiltViewModel(entry)
|
||||
val addAccountState by addAccountViewModel.uiState.collectAsState()
|
||||
val lastCompletedNonce = remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(Unit) {
|
||||
addAccountViewModel.startAddAccountFlow()
|
||||
}
|
||||
LaunchedEffect(addAccountState.authCompletedNonce) {
|
||||
val nonce = addAccountState.authCompletedNonce
|
||||
if (nonce == 0L || nonce == lastCompletedNonce.value) return@LaunchedEffect
|
||||
lastCompletedNonce.value = nonce
|
||||
viewModel.recheckSession()
|
||||
navController.navigate(Routes.Chats) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = false
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
LoginScreen(
|
||||
state = addAccountState,
|
||||
headerTitle = context.getString(R.string.settings_add_account),
|
||||
onEmailChanged = addAccountViewModel::onEmailChanged,
|
||||
onNameChanged = addAccountViewModel::onNameChanged,
|
||||
onUsernameChanged = addAccountViewModel::onUsernameChanged,
|
||||
onPasswordChanged = addAccountViewModel::onPasswordChanged,
|
||||
onOtpCodeChanged = addAccountViewModel::onOtpCodeChanged,
|
||||
onRecoveryCodeChanged = addAccountViewModel::onRecoveryCodeChanged,
|
||||
onToggleRecoveryCodeMode = addAccountViewModel::toggleRecoveryCodeMode,
|
||||
onContinueEmail = addAccountViewModel::continueWithEmail,
|
||||
onSubmitStep = addAccountViewModel::submitAuthStep,
|
||||
onBackToEmail = {
|
||||
if (addAccountState.step == ru.daemonlord.messenger.ui.auth.AuthStep.EMAIL) {
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
addAccountViewModel.backToEmailStep()
|
||||
}
|
||||
},
|
||||
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
|
||||
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,8 +294,18 @@ fun MessengerNavHost(
|
||||
|
||||
composable(route = Routes.Settings) {
|
||||
SettingsRoute(
|
||||
onBackToChats = { navController.navigate(Routes.Chats) },
|
||||
onOpenProfile = { navController.navigate(Routes.Profile) },
|
||||
onAddAccount = { navController.navigate(Routes.AddAccountLogin) },
|
||||
onSwitchAccount = {
|
||||
viewModel.recheckSession()
|
||||
navController.navigate(Routes.Chats) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = false
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
},
|
||||
onLogout = viewModel::logout,
|
||||
onMainBarVisibilityChanged = { isMainBarVisible = it },
|
||||
)
|
||||
@@ -248,8 +313,6 @@ fun MessengerNavHost(
|
||||
|
||||
composable(route = Routes.Profile) {
|
||||
ProfileRoute(
|
||||
onBackToChats = { navController.navigate(Routes.Chats) },
|
||||
onOpenSettings = { navController.navigate(Routes.Settings) },
|
||||
onMainBarVisibilityChanged = { isMainBarVisible = it },
|
||||
)
|
||||
}
|
||||
@@ -314,9 +377,9 @@ private fun MainBottomBar(
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == Routes.Chats,
|
||||
onClick = { onNavigate(Routes.Chats) },
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "Chats") },
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = stringResource(id = R.string.nav_chats)) },
|
||||
label = {
|
||||
androidx.compose.material3.Text("Chats", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
androidx.compose.material3.Text(stringResource(id = R.string.nav_chats), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
@@ -330,9 +393,9 @@ private fun MainBottomBar(
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == Routes.Contacts,
|
||||
onClick = { onNavigate(Routes.Contacts) },
|
||||
icon = { Icon(Icons.Filled.Contacts, contentDescription = "Contacts") },
|
||||
icon = { Icon(Icons.Filled.Contacts, contentDescription = stringResource(id = R.string.nav_contacts)) },
|
||||
label = {
|
||||
androidx.compose.material3.Text("Contacts", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
androidx.compose.material3.Text(stringResource(id = R.string.nav_contacts), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
@@ -346,9 +409,9 @@ private fun MainBottomBar(
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == Routes.Settings,
|
||||
onClick = { onNavigate(Routes.Settings) },
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = stringResource(id = R.string.nav_settings)) },
|
||||
label = {
|
||||
androidx.compose.material3.Text("Settings", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
androidx.compose.material3.Text(stringResource(id = R.string.nav_settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
@@ -362,9 +425,9 @@ private fun MainBottomBar(
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == Routes.Profile,
|
||||
onClick = { onNavigate(Routes.Profile) },
|
||||
icon = { Icon(Icons.Filled.Person, contentDescription = "Profile") },
|
||||
icon = { Icon(Icons.Filled.Person, contentDescription = stringResource(id = R.string.nav_profile)) },
|
||||
label = {
|
||||
androidx.compose.material3.Text("Profile", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
androidx.compose.material3.Text(stringResource(id = R.string.nav_profile), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
|
||||
@@ -8,32 +8,40 @@ import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddAPhoto
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -41,38 +49,48 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.ui.account.AccountViewModel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.max
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@Composable
|
||||
fun ProfileRoute(
|
||||
onBackToChats: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onMainBarVisibilityChanged: (Boolean) -> Unit,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
ProfileScreen(
|
||||
onBackToChats = onBackToChats,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun ProfileScreen(
|
||||
onBackToChats: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onMainBarVisibilityChanged: (Boolean) -> Unit,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
@@ -82,6 +100,8 @@ fun ProfileScreen(
|
||||
var username by remember(profile?.username) { mutableStateOf(profile?.username.orEmpty()) }
|
||||
var bio by remember(profile?.bio) { mutableStateOf(profile?.bio.orEmpty()) }
|
||||
var avatarUrl by remember(profile?.avatarUrl) { mutableStateOf(profile?.avatarUrl.orEmpty()) }
|
||||
var editMode by remember { mutableStateOf(false) }
|
||||
var pendingAvatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -101,147 +121,397 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val context = LocalContext.current
|
||||
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
||||
val pickAvatarLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent(),
|
||||
) { uri ->
|
||||
val bytes = uri?.toSquareJpeg(context) ?: return@rememberLauncherForActivityResult
|
||||
viewModel.uploadAvatar(
|
||||
fileName = "avatar.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
bytes = bytes,
|
||||
) { uploadedUrl ->
|
||||
avatarUrl = uploadedUrl
|
||||
}
|
||||
pendingAvatarBitmap = uri?.toBitmap(context)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.navigationBarsPadding(),
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier)
|
||||
.padding(bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text("Profile") },
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
.padding(bottom = 96.dp),
|
||||
) {
|
||||
if (!avatarUrl.isBlank()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = avatarUrl,
|
||||
contentDescription = "Avatar",
|
||||
if (!editMode) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(180.dp)
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = CircleShape,
|
||||
.fillMaxWidth()
|
||||
.height(305.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.colorScheme.secondaryContainer,
|
||||
MaterialTheme.colorScheme.tertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
if (avatarUrl.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = avatarUrl,
|
||||
contentDescription = stringResource(id = R.string.profile_avatar_content_description),
|
||||
modifier = Modifier
|
||||
.size(108.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), CircleShape),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(108.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = name.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (name.isBlank()) stringResource(id = R.string.profile_user_fallback) else name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
stringResource(id = R.string.chat_status_online),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
HeroActionButton(
|
||||
label = stringResource(id = R.string.profile_choose_photo),
|
||||
icon = Icons.Filled.AddAPhoto,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
pickAvatarLauncher.launch("image/*")
|
||||
}
|
||||
HeroActionButton(
|
||||
label = stringResource(id = R.string.profile_edit),
|
||||
icon = Icons.Filled.Edit,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
editMode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (!editMode) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(y = (-22).dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
val notSet = stringResource(id = R.string.profile_not_set)
|
||||
ProfileInfoRow(stringResource(id = R.string.auth_label_email), profile?.email.orEmpty())
|
||||
ProfileInfoRow(stringResource(id = R.string.profile_bio), bio.ifBlank { notSet })
|
||||
ProfileInfoRow(
|
||||
stringResource(id = R.string.auth_label_username),
|
||||
if (username.isBlank()) notSet else "@$username",
|
||||
)
|
||||
ProfileInfoRow(stringResource(id = R.string.auth_label_name), name.ifBlank { notSet })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(stringResource(id = R.string.profile_edit_profile), style = MaterialTheme.typography.titleMedium)
|
||||
HeroActionButton(
|
||||
label = stringResource(id = R.string.profile_choose_photo),
|
||||
icon = Icons.Filled.AddAPhoto,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
pickAvatarLauncher.launch("image/*")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(id = R.string.auth_label_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(stringResource(id = R.string.auth_label_username)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = bio,
|
||||
onValueChange = { bio = it },
|
||||
label = { Text(stringResource(id = R.string.profile_bio)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = avatarUrl,
|
||||
onValueChange = { avatarUrl = it },
|
||||
label = { Text(stringResource(id = R.string.profile_avatar_url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = { editMode = false }, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(id = R.string.common_cancel))
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.updateProfile(
|
||||
name = name,
|
||||
username = username,
|
||||
bio = bio.ifBlank { null },
|
||||
avatarUrl = avatarUrl.ifBlank { null },
|
||||
)
|
||||
editMode = false
|
||||
},
|
||||
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(id = R.string.common_save))
|
||||
}
|
||||
}
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.message.isNullOrBlank()) {
|
||||
Text(
|
||||
text = state.message.orEmpty(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
if (!state.errorMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = state.errorMessage.orEmpty(),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(onClick = { pickAvatarLauncher.launch("image/*") }, enabled = !state.isSaving) {
|
||||
Text("Upload avatar")
|
||||
}
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(4.dp))
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Username") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = bio,
|
||||
onValueChange = { bio = it },
|
||||
label = { Text("Bio") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = avatarUrl,
|
||||
onValueChange = { avatarUrl = it },
|
||||
label = { Text("Avatar URL") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.updateProfile(
|
||||
name = name,
|
||||
username = username,
|
||||
bio = bio.ifBlank { null },
|
||||
avatarUrl = avatarUrl.ifBlank { null },
|
||||
)
|
||||
}
|
||||
pendingAvatarBitmap?.let { bitmap ->
|
||||
AvatarCropDialog(
|
||||
bitmap = bitmap,
|
||||
onDismiss = { pendingAvatarBitmap = null },
|
||||
onConfirm = { croppedBytes ->
|
||||
viewModel.uploadAvatar(
|
||||
fileName = "avatar.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
bytes = croppedBytes,
|
||||
) { uploadedUrl ->
|
||||
avatarUrl = uploadedUrl
|
||||
}
|
||||
pendingAvatarBitmap = null
|
||||
},
|
||||
enabled = !state.isSaving && name.isNotBlank() && username.isNotBlank(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroActionButton(
|
||||
label: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.8f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Save profile")
|
||||
}
|
||||
if (!state.message.isNullOrBlank()) {
|
||||
Text(
|
||||
text = state.message!!,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (!state.errorMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = state.errorMessage!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Open settings")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onBackToChats,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Back to chats")
|
||||
}
|
||||
}
|
||||
Icon(icon, contentDescription = null)
|
||||
Text(label, modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Uri.toSquareJpeg(context: Context): ByteArray? {
|
||||
val bitmap = runCatching {
|
||||
@Composable
|
||||
private fun ProfileInfoRow(label: String, value: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = value, style = MaterialTheme.typography.titleLarge)
|
||||
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarCropDialog(
|
||||
bitmap: Bitmap,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (ByteArray) -> Unit,
|
||||
) {
|
||||
var scale by remember(bitmap) { mutableStateOf(1f) }
|
||||
var offset by remember(bitmap) { mutableStateOf(Offset.Zero) }
|
||||
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
fun clampOffset(raw: Offset, currentScale: Float, viewportPx: Float): Offset {
|
||||
val baseScale = max(viewportPx / bitmap.width.toFloat(), viewportPx / bitmap.height.toFloat())
|
||||
val displayedWidth = bitmap.width * baseScale * currentScale
|
||||
val displayedHeight = bitmap.height * baseScale * currentScale
|
||||
val maxOffsetX = max(0f, (displayedWidth - viewportPx) / 2f)
|
||||
val maxOffsetY = max(0f, (displayedHeight - viewportPx) / 2f)
|
||||
return Offset(
|
||||
x = raw.x.coerceIn(-maxOffsetX, maxOffsetX),
|
||||
y = raw.y.coerceIn(-maxOffsetY, maxOffsetY),
|
||||
)
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(stringResource(id = R.string.profile_crop_avatar), style = MaterialTheme.typography.titleMedium)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.clipToBounds()
|
||||
.onSizeChanged { viewportSize = it }
|
||||
.pointerInput(bitmap, viewportSize, scale, offset) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
val viewport = viewportSize.width.toFloat().coerceAtLeast(1f)
|
||||
val newScale = (scale * zoom).coerceIn(1f, 4f)
|
||||
scale = newScale
|
||||
offset = clampOffset(offset + pan, newScale, viewport)
|
||||
}
|
||||
},
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(id = R.string.profile_avatar_crop_preview),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = offset.x
|
||||
translationY = offset.y
|
||||
},
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(id = R.string.common_cancel))
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val viewportPx = viewportSize.width.toFloat()
|
||||
if (viewportPx <= 1f) return@Button
|
||||
val baseScale = max(viewportPx / bitmap.width.toFloat(), viewportPx / bitmap.height.toFloat())
|
||||
val fullScale = baseScale * scale
|
||||
val centerX = viewportPx / 2f + offset.x
|
||||
val centerY = viewportPx / 2f + offset.y
|
||||
val left = ((0f - centerX) / fullScale + bitmap.width / 2f)
|
||||
val top = ((0f - centerY) / fullScale + bitmap.height / 2f)
|
||||
val side = (viewportPx / fullScale).coerceAtMost(minOf(bitmap.width, bitmap.height).toFloat())
|
||||
|
||||
val safeLeft = left.coerceIn(0f, bitmap.width - side)
|
||||
val safeTop = top.coerceIn(0f, bitmap.height - side)
|
||||
val cropBitmap = Bitmap.createBitmap(
|
||||
bitmap,
|
||||
safeLeft.toInt(),
|
||||
safeTop.toInt(),
|
||||
side.toInt().coerceAtLeast(1),
|
||||
side.toInt().coerceAtLeast(1),
|
||||
)
|
||||
val output = ByteArrayOutputStream()
|
||||
val ok = cropBitmap.compress(Bitmap.CompressFormat.JPEG, 92, output)
|
||||
if (ok) onConfirm(output.toByteArray())
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(id = R.string.profile_use_crop))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.profile_crop_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Uri.toBitmap(context: Context): Bitmap? {
|
||||
return runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val src = ImageDecoder.createSource(context.contentResolver, this)
|
||||
ImageDecoder.decodeBitmap(src)
|
||||
@@ -249,18 +519,5 @@ private fun Uri.toSquareJpeg(context: Context): ByteArray? {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaStore.Images.Media.getBitmap(context.contentResolver, this)
|
||||
}
|
||||
}.getOrNull() ?: return null
|
||||
|
||||
val square = bitmap.centerCropSquare()
|
||||
val output = ByteArrayOutputStream()
|
||||
val compressed = square.compress(Bitmap.CompressFormat.JPEG, 92, output)
|
||||
if (!compressed) return null
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
private fun Bitmap.centerCropSquare(): Bitmap {
|
||||
val side = minOf(width, height)
|
||||
val left = (width - side) / 2
|
||||
val top = (height - side) / 2
|
||||
return Bitmap.createBitmap(this, left, top, side, side)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package ru.daemonlord.messenger.ui.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -10,47 +13,96 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.BatterySaver
|
||||
import androidx.compose.material.icons.filled.Chat
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import coil.compose.AsyncImage
|
||||
import ru.daemonlord.messenger.R
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppLanguage
|
||||
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||
import ru.daemonlord.messenger.ui.account.AccountUiState
|
||||
import ru.daemonlord.messenger.ui.account.AccountViewModel
|
||||
|
||||
private enum class SettingsFolder {
|
||||
Account,
|
||||
Chat,
|
||||
Privacy,
|
||||
Notifications,
|
||||
Data,
|
||||
Folders,
|
||||
Devices,
|
||||
Power,
|
||||
Language,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsRoute(
|
||||
onBackToChats: () -> Unit,
|
||||
onOpenProfile: () -> Unit,
|
||||
onAddAccount: () -> Unit,
|
||||
onSwitchAccount: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onMainBarVisibilityChanged: (Boolean) -> Unit,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
SettingsScreen(
|
||||
onBackToChats = onBackToChats,
|
||||
onOpenProfile = onOpenProfile,
|
||||
onAddAccount = onAddAccount,
|
||||
onSwitchAccount = onSwitchAccount,
|
||||
onLogout = onLogout,
|
||||
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
|
||||
viewModel = viewModel,
|
||||
@@ -58,295 +110,543 @@ fun SettingsRoute(
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun SettingsScreen(
|
||||
onBackToChats: () -> Unit,
|
||||
onOpenProfile: () -> Unit,
|
||||
onAddAccount: () -> Unit,
|
||||
onSwitchAccount: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onMainBarVisibilityChanged: (Boolean) -> Unit,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val profile = state.profile
|
||||
var privacyPm by remember(profile?.privacyPrivateMessages) { mutableStateOf(profile?.privacyPrivateMessages ?: "everyone") }
|
||||
var privacyLastSeen by remember(profile?.privacyLastSeen) { mutableStateOf(profile?.privacyLastSeen ?: "everyone") }
|
||||
var privacyAvatar by remember(profile?.privacyAvatar) { mutableStateOf(profile?.privacyAvatar ?: "everyone") }
|
||||
var privacyGroupInvites by remember(profile?.privacyGroupInvites) { mutableStateOf(profile?.privacyGroupInvites ?: "everyone") }
|
||||
var twoFactorCode by remember { mutableStateOf("") }
|
||||
var recoveryRegenerateCode by remember { mutableStateOf("") }
|
||||
var blockUserIdInput by remember { mutableStateOf("") }
|
||||
var nightMode by remember { mutableIntStateOf(AppCompatDelegate.getDefaultNightMode()) }
|
||||
val scrollState = rememberScrollState()
|
||||
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
||||
val isTablet = LocalConfiguration.current.screenWidthDp >= 840
|
||||
var folder by rememberSaveable { mutableStateOf<SettingsFolder?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.refresh()
|
||||
viewModel.refreshRecoveryStatus()
|
||||
onMainBarVisibilityChanged(true)
|
||||
}
|
||||
LaunchedEffect(scrollState) {
|
||||
var prevOffset = 0
|
||||
snapshotFlow { scrollState.value }
|
||||
.collectLatest { offset ->
|
||||
when {
|
||||
offset == 0 -> onMainBarVisibilityChanged(true)
|
||||
offset > prevOffset -> onMainBarVisibilityChanged(false)
|
||||
offset < prevOffset -> onMainBarVisibilityChanged(true)
|
||||
}
|
||||
prevOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier)
|
||||
.padding(bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier),
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Appearance", style = MaterialTheme.typography.titleMedium)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
nightMode = AppCompatDelegate.MODE_NIGHT_NO
|
||||
},
|
||||
) { Text("Light") }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
nightMode = AppCompatDelegate.MODE_NIGHT_YES
|
||||
},
|
||||
) { Text("Dark") }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
},
|
||||
) { Text("System") }
|
||||
}
|
||||
Text(
|
||||
text = when (nightMode) {
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> "Current theme: Dark"
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> "Current theme: Light"
|
||||
else -> "Current theme: System"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
Text("Sessions", style = MaterialTheme.typography.titleMedium)
|
||||
if (state.sessions.isEmpty()) {
|
||||
Text("No active sessions", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
state.sessions.forEach { session ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(session.userAgent ?: "Unknown device", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(session.ipAddress ?: "Unknown IP", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.revokeSession(session.jti) },
|
||||
enabled = !state.isSaving && session.current != true,
|
||||
) {
|
||||
Text("Revoke")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = viewModel::revokeAllSessions,
|
||||
enabled = !state.isSaving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Revoke all sessions")
|
||||
}
|
||||
|
||||
Text("Privacy", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = privacyPm,
|
||||
onValueChange = { privacyPm = it },
|
||||
label = { Text("PM privacy (everyone/contacts/nobody)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = privacyLastSeen,
|
||||
onValueChange = { privacyLastSeen = it },
|
||||
label = { Text("Last seen privacy") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = privacyAvatar,
|
||||
onValueChange = { privacyAvatar = it },
|
||||
label = { Text("Avatar privacy") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = privacyGroupInvites,
|
||||
onValueChange = { privacyGroupInvites = it },
|
||||
label = { Text("Group invites privacy") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.updatePrivacy(
|
||||
privateMessages = privacyPm,
|
||||
lastSeen = privacyLastSeen,
|
||||
avatar = privacyAvatar,
|
||||
groupInvites = privacyGroupInvites,
|
||||
if (folder == null) {
|
||||
SettingsHome(
|
||||
state = state,
|
||||
name = profile?.name.orEmpty(),
|
||||
email = profile?.email.orEmpty(),
|
||||
username = profile?.username.orEmpty(),
|
||||
avatarUrl = profile?.avatarUrl,
|
||||
onOpenProfile = onOpenProfile,
|
||||
onOpenFolder = { folder = it },
|
||||
)
|
||||
} else {
|
||||
SettingsFolderView(
|
||||
state = state,
|
||||
folder = folder ?: SettingsFolder.Account,
|
||||
onBack = { folder = null },
|
||||
onAddAccount = onAddAccount,
|
||||
onSwitchAccount = onSwitchAccount,
|
||||
onLogout = onLogout,
|
||||
onOpenProfile = onOpenProfile,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
},
|
||||
enabled = !state.isSaving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Save privacy")
|
||||
}
|
||||
|
||||
Text("Blocked users", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = blockUserIdInput,
|
||||
onValueChange = { blockUserIdInput = it.filter { ch -> ch.isDigit() } },
|
||||
label = { Text("User ID to block") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
blockUserIdInput.toLongOrNull()?.let { viewModel.blockUser(it) }
|
||||
blockUserIdInput = ""
|
||||
},
|
||||
enabled = !state.isSaving && blockUserIdInput.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Block user by ID")
|
||||
}
|
||||
if (state.blockedUsers.isEmpty()) {
|
||||
Text("Blocked list is empty", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
state.blockedUsers.forEach { user ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(user.name)
|
||||
if (!user.username.isNullOrBlank()) {
|
||||
Text("@${user.username}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.unblockUser(user.id) }) {
|
||||
Text("Unblock")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("2FA", style = MaterialTheme.typography.titleMedium)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = viewModel::setupTwoFactor, enabled = !state.isSaving) {
|
||||
Text("Setup")
|
||||
}
|
||||
OutlinedButton(onClick = viewModel::refreshRecoveryStatus, enabled = !state.isSaving) {
|
||||
Text("Refresh status")
|
||||
}
|
||||
}
|
||||
if (!state.twoFactorSecret.isNullOrBlank()) {
|
||||
Text("Secret: ${state.twoFactorSecret}")
|
||||
if (!state.twoFactorOtpAuthUrl.isNullOrBlank()) {
|
||||
Text("OTP URI: ${state.twoFactorOtpAuthUrl}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Text("Recovery codes left: ${state.recoveryCodesRemaining ?: "-"}")
|
||||
OutlinedTextField(
|
||||
value = twoFactorCode,
|
||||
onValueChange = { twoFactorCode = it },
|
||||
label = { Text("2FA code") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { viewModel.enableTwoFactor(twoFactorCode) },
|
||||
enabled = !state.isSaving && twoFactorCode.isNotBlank(),
|
||||
) {
|
||||
Text("Enable 2FA")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.disableTwoFactor(twoFactorCode) },
|
||||
enabled = !state.isSaving && twoFactorCode.isNotBlank(),
|
||||
) {
|
||||
Text("Disable 2FA")
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = recoveryRegenerateCode,
|
||||
onValueChange = { recoveryRegenerateCode = it },
|
||||
label = { Text("Code to regenerate recovery codes") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.regenerateRecoveryCodes(recoveryRegenerateCode) },
|
||||
enabled = !state.isSaving && recoveryRegenerateCode.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Regenerate recovery codes")
|
||||
}
|
||||
if (state.recoveryCodes.isNotEmpty()) {
|
||||
Text("New recovery codes:", style = MaterialTheme.typography.bodyMedium)
|
||||
state.recoveryCodes.forEach { code -> Text(code, style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
|
||||
if (!state.message.isNullOrBlank()) {
|
||||
Text(state.message!!, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
if (!state.errorMessage.isNullOrBlank()) {
|
||||
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Spacer(modifier = Modifier.padding(top = 4.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onOpenProfile,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Open profile")
|
||||
}
|
||||
Button(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Logout")
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onBackToChats) {
|
||||
Text("Back to chats")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsHome(
|
||||
state: AccountUiState,
|
||||
name: String,
|
||||
email: String,
|
||||
username: String,
|
||||
avatarUrl: String?,
|
||||
onOpenProfile: () -> Unit,
|
||||
onOpenFolder: (SettingsFolder) -> Unit,
|
||||
) {
|
||||
val scroll = rememberScrollState()
|
||||
val active = state.storedAccounts.firstOrNull { it.isActive }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.padding(bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ProfileHeader(name.ifBlank { stringResource(id = R.string.settings_user_fallback) }, email, username, avatarUrl, onOpenProfile)
|
||||
|
||||
SettingsCard {
|
||||
Text(stringResource(id = R.string.settings_accounts_header), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium)
|
||||
SettingsShortcut(
|
||||
title = active?.title ?: stringResource(id = R.string.settings_no_active_account),
|
||||
subtitle = active?.subtitle ?: stringResource(id = R.string.settings_add_account),
|
||||
onClick = { onOpenFolder(SettingsFolder.Account) },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
SettingsRow(Icons.Filled.AccountCircle, stringResource(id = R.string.settings_folder_account), stringResource(id = R.string.settings_account_subtitle)) { onOpenFolder(SettingsFolder.Account) }
|
||||
SettingsRow(Icons.Filled.Chat, stringResource(id = R.string.settings_folder_chat), stringResource(id = R.string.settings_chat_subtitle)) { onOpenFolder(SettingsFolder.Chat) }
|
||||
SettingsRow(Icons.Filled.Lock, stringResource(id = R.string.settings_folder_privacy), stringResource(id = R.string.settings_privacy_subtitle)) { onOpenFolder(SettingsFolder.Privacy) }
|
||||
SettingsRow(Icons.Filled.Notifications, stringResource(id = R.string.settings_folder_notifications), stringResource(id = R.string.settings_notifications_subtitle)) { onOpenFolder(SettingsFolder.Notifications) }
|
||||
SettingsRow(Icons.Filled.Storage, stringResource(id = R.string.settings_folder_data), stringResource(id = R.string.settings_data_subtitle)) { onOpenFolder(SettingsFolder.Data) }
|
||||
SettingsRow(Icons.Filled.Folder, stringResource(id = R.string.settings_folder_folders), stringResource(id = R.string.settings_folders_subtitle)) { onOpenFolder(SettingsFolder.Folders) }
|
||||
SettingsRow(Icons.Filled.Devices, stringResource(id = R.string.settings_folder_devices), stringResource(id = R.string.settings_devices_subtitle)) { onOpenFolder(SettingsFolder.Devices) }
|
||||
SettingsRow(Icons.Filled.BatterySaver, stringResource(id = R.string.settings_folder_power), stringResource(id = R.string.settings_power_subtitle)) { onOpenFolder(SettingsFolder.Power) }
|
||||
SettingsRow(Icons.Filled.Language, stringResource(id = R.string.settings_folder_language), languageLabel(state.appLanguage), divider = false) { onOpenFolder(SettingsFolder.Language) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsFolderView(
|
||||
state: AccountUiState,
|
||||
folder: SettingsFolder,
|
||||
onBack: () -> Unit,
|
||||
onAddAccount: () -> Unit,
|
||||
onSwitchAccount: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onOpenProfile: () -> Unit,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
val scroll = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.padding(bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
Text(folderTitle(folder), style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (state.isLoading || state.isSaving) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
|
||||
when (folder) {
|
||||
SettingsFolder.Account -> AccountFolder(state, onAddAccount, onSwitchAccount, onOpenProfile, onLogout, viewModel)
|
||||
SettingsFolder.Chat -> ChatFolder(state, viewModel)
|
||||
SettingsFolder.Privacy -> PrivacyFolder(state, viewModel)
|
||||
SettingsFolder.Notifications -> NotificationsFolder(state, viewModel)
|
||||
SettingsFolder.Devices -> DevicesFolder(state, viewModel)
|
||||
SettingsFolder.Data -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_data))
|
||||
SettingsFolder.Folders -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_folders))
|
||||
SettingsFolder.Power -> PlaceholderFolder(stringResource(id = R.string.settings_placeholder_power))
|
||||
SettingsFolder.Language -> LanguageFolder(state, viewModel)
|
||||
}
|
||||
|
||||
if (!state.message.isNullOrBlank()) Text(state.message.orEmpty(), color = MaterialTheme.colorScheme.primary)
|
||||
if (!state.errorMessage.isNullOrBlank()) Text(state.errorMessage.orEmpty(), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageFolder(state: AccountUiState, viewModel: AccountViewModel) {
|
||||
SettingsCard {
|
||||
Text(stringResource(id = R.string.settings_language_title), style = MaterialTheme.typography.titleSmall)
|
||||
LanguageOptionRow(
|
||||
title = stringResource(id = R.string.language_system),
|
||||
subtitle = stringResource(id = R.string.settings_language_system_subtitle),
|
||||
selected = state.appLanguage == AppLanguage.SYSTEM,
|
||||
onClick = { viewModel.setLanguage(AppLanguage.SYSTEM) },
|
||||
)
|
||||
LanguageOptionRow(
|
||||
title = stringResource(id = R.string.language_russian),
|
||||
subtitle = stringResource(id = R.string.language_russian),
|
||||
selected = state.appLanguage == AppLanguage.RUSSIAN,
|
||||
onClick = { viewModel.setLanguage(AppLanguage.RUSSIAN) },
|
||||
)
|
||||
LanguageOptionRow(
|
||||
title = stringResource(id = R.string.language_english),
|
||||
subtitle = stringResource(id = R.string.settings_language_english_subtitle),
|
||||
selected = state.appLanguage == AppLanguage.ENGLISH,
|
||||
onClick = { viewModel.setLanguage(AppLanguage.ENGLISH) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageOptionRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun languageLabel(language: AppLanguage): String = when (language) {
|
||||
AppLanguage.SYSTEM -> stringResource(id = R.string.language_system)
|
||||
AppLanguage.RUSSIAN -> stringResource(id = R.string.language_russian)
|
||||
AppLanguage.ENGLISH -> stringResource(id = R.string.language_english)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun folderTitle(folder: SettingsFolder): String = when (folder) {
|
||||
SettingsFolder.Account -> stringResource(id = R.string.settings_folder_account)
|
||||
SettingsFolder.Chat -> stringResource(id = R.string.settings_folder_chat)
|
||||
SettingsFolder.Privacy -> stringResource(id = R.string.settings_folder_privacy)
|
||||
SettingsFolder.Notifications -> stringResource(id = R.string.settings_folder_notifications)
|
||||
SettingsFolder.Data -> stringResource(id = R.string.settings_folder_data)
|
||||
SettingsFolder.Folders -> stringResource(id = R.string.settings_folder_folders)
|
||||
SettingsFolder.Devices -> stringResource(id = R.string.settings_folder_devices)
|
||||
SettingsFolder.Power -> stringResource(id = R.string.settings_folder_power)
|
||||
SettingsFolder.Language -> stringResource(id = R.string.settings_folder_language)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountFolder(
|
||||
state: AccountUiState,
|
||||
onAddAccount: () -> Unit,
|
||||
onSwitchAccount: () -> Unit,
|
||||
onOpenProfile: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
SettingsCard {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(id = R.string.settings_accounts), style = MaterialTheme.typography.titleSmall)
|
||||
OutlinedButton(onClick = onAddAccount) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Text(stringResource(id = R.string.settings_add_account), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
state.storedAccounts.forEach { account ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = account.title.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(account.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(account.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (account.isActive) {
|
||||
Text(stringResource(id = R.string.settings_active), color = MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
OutlinedButton(onClick = { viewModel.switchStoredAccount(account.userId) { if (it) onSwitchAccount() } }) { Text(stringResource(id = R.string.settings_switch)) }
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) { Icon(Icons.Filled.DeleteOutline, contentDescription = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
OutlinedButton(onClick = onOpenProfile, modifier = Modifier.fillMaxWidth()) { Text(stringResource(id = R.string.settings_open_profile)) }
|
||||
Button(onClick = onLogout, modifier = Modifier.fillMaxWidth()) { Text(stringResource(id = R.string.settings_logout)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatFolder(state: AccountUiState, viewModel: AccountViewModel) {
|
||||
SettingsCard {
|
||||
Text(stringResource(id = R.string.settings_appearance), style = MaterialTheme.typography.titleSmall)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ThemeButton(stringResource(id = R.string.theme_light), state.appThemeMode == AppThemeMode.LIGHT) { viewModel.setThemeMode(AppThemeMode.LIGHT) }
|
||||
ThemeButton(stringResource(id = R.string.theme_dark), state.appThemeMode == AppThemeMode.DARK) { viewModel.setThemeMode(AppThemeMode.DARK) }
|
||||
ThemeButton(stringResource(id = R.string.theme_system), state.appThemeMode == AppThemeMode.SYSTEM) { viewModel.setThemeMode(AppThemeMode.SYSTEM) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationsFolder(state: AccountUiState, viewModel: AccountViewModel) {
|
||||
SettingsCard {
|
||||
SettingsToggle(Icons.Filled.Notifications, stringResource(id = R.string.settings_enable_notifications), state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled)
|
||||
SettingsToggle(Icons.Filled.Visibility, stringResource(id = R.string.settings_show_preview), state.notificationsPreviewEnabled, viewModel::setNotificationPreviewEnabled)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::refresh,
|
||||
enabled = !state.isLoading && !state.isSaving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(id = R.string.settings_refresh_notifications))
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Text(stringResource(id = R.string.settings_recent_notifications), style = MaterialTheme.typography.titleSmall)
|
||||
if (state.notificationsHistory.isEmpty()) {
|
||||
Text(stringResource(id = R.string.settings_no_notifications_yet), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
state.notificationsHistory.take(20).forEach { notification ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = notification.eventType,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = notification.text ?: notification.payloadRaw,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.settings_notification_meta,
|
||||
notification.chatId?.toString() ?: "-",
|
||||
notification.messageId?.toString() ?: "-",
|
||||
notification.createdAt,
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrivacyFolder(state: AccountUiState, viewModel: AccountViewModel) {
|
||||
var pm by remember(state.profile?.privacyPrivateMessages) { mutableStateOf(state.profile?.privacyPrivateMessages ?: "everyone") }
|
||||
var lastSeen by remember(state.profile?.privacyLastSeen) { mutableStateOf(state.profile?.privacyLastSeen ?: "everyone") }
|
||||
var avatar by remember(state.profile?.privacyAvatar) { mutableStateOf(state.profile?.privacyAvatar ?: "everyone") }
|
||||
var invites by remember(state.profile?.privacyGroupInvites) { mutableStateOf(state.profile?.privacyGroupInvites ?: "everyone") }
|
||||
|
||||
SettingsCard {
|
||||
PrivacyDropdown(stringResource(id = R.string.privacy_private_messages), pm) { pm = it }
|
||||
PrivacyDropdown(stringResource(id = R.string.privacy_last_seen), lastSeen) { lastSeen = it }
|
||||
PrivacyDropdown(stringResource(id = R.string.privacy_avatar), avatar) { avatar = it }
|
||||
PrivacyDropdown(stringResource(id = R.string.privacy_group_invites), invites) { invites = it }
|
||||
Button(onClick = { viewModel.updatePrivacy(pm, lastSeen, avatar, invites) }, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Filled.Lock, contentDescription = null)
|
||||
Text(stringResource(id = R.string.privacy_save), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DevicesFolder(state: AccountUiState, viewModel: AccountViewModel) {
|
||||
var twoFactorCode by remember { mutableStateOf("") }
|
||||
var recoveryCode by remember { mutableStateOf("") }
|
||||
|
||||
SettingsCard {
|
||||
Text(stringResource(id = R.string.settings_sessions_security), style = MaterialTheme.typography.titleSmall)
|
||||
state.sessions.forEach { s ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(Icons.Filled.Devices, contentDescription = null)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(s.userAgent ?: stringResource(id = R.string.settings_unknown_device))
|
||||
Text(s.ipAddress ?: stringResource(id = R.string.settings_unknown_ip), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.revokeSession(s.jti) }, enabled = !state.isSaving && s.current != true) { Text(stringResource(id = R.string.settings_revoke)) }
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = viewModel::revokeAllSessions, enabled = !state.isSaving, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Filled.Security, contentDescription = null)
|
||||
Text(stringResource(id = R.string.settings_revoke_all), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
OutlinedTextField(value = twoFactorCode, onValueChange = { twoFactorCode = it }, label = { Text(stringResource(id = R.string.settings_2fa_code)) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.enableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text(stringResource(id = R.string.settings_enable)) }
|
||||
OutlinedButton(onClick = { viewModel.disableTwoFactor(twoFactorCode) }, enabled = twoFactorCode.isNotBlank() && !state.isSaving) { Text(stringResource(id = R.string.settings_disable)) }
|
||||
}
|
||||
OutlinedTextField(value = recoveryCode, onValueChange = { recoveryCode = it }, label = { Text(stringResource(id = R.string.settings_recovery_regen_code)) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedButton(onClick = { viewModel.regenerateRecoveryCodes(recoveryCode) }, enabled = recoveryCode.isNotBlank() && !state.isSaving, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(id = R.string.settings_regenerate_recovery_codes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderFolder(text: String) {
|
||||
SettingsCard { Text(text) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileHeader(name: String, email: String, username: String, avatarUrl: String?, onOpenProfile: () -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (!avatarUrl.isNullOrBlank()) {
|
||||
AsyncImage(model = avatarUrl, contentDescription = null, modifier = Modifier.size(84.dp).clip(CircleShape))
|
||||
} else {
|
||||
Box(modifier = Modifier.size(84.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
|
||||
Text(name.firstOrNull()?.uppercase() ?: "?")
|
||||
}
|
||||
}
|
||||
Text(name, style = MaterialTheme.typography.headlineSmall)
|
||||
Text(listOfNotNull(email.takeIf { it.isNotBlank() }, username.takeIf { it.isNotBlank() }?.let { "@$it" }).joinToString(" • "), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
TextButton(onClick = onOpenProfile) { Text(stringResource(id = R.string.settings_open_profile)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(22.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsShortcut(title: String, subtitle: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).clickable(onClick = onClick).padding(horizontal = 8.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = title.firstOrNull()?.uppercase() ?: stringResource(id = R.string.settings_fallback_avatar_letter),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRow(icon: ImageVector, title: String, subtitle: String, divider: Boolean = true, onClick: () -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
|
||||
}
|
||||
if (divider) {
|
||||
Spacer(modifier = Modifier.fillMaxWidth().padding(start = 50.dp).size(width = 1.dp, height = 0.5.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsToggle(icon: ImageVector, title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest).padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Text(title, modifier = Modifier.weight(1f))
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeButton(text: String, selected: Boolean, onClick: () -> Unit) {
|
||||
if (selected) Button(onClick = onClick) { Text(text) } else OutlinedButton(onClick = onClick) { Text(text) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun PrivacyDropdown(label: String, value: String, onChange: (String) -> Unit) {
|
||||
val options = listOf("everyone", "contacts", "nobody")
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(privacyOptionLabel(option)) },
|
||||
onClick = { onChange(option); expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun privacyOptionLabel(value: String): String = when (value.lowercase()) {
|
||||
"everyone" -> stringResource(id = R.string.privacy_everyone)
|
||||
"contacts" -> stringResource(id = R.string.privacy_contacts)
|
||||
"nobody" -> stringResource(id = R.string.privacy_nobody)
|
||||
else -> value
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package ru.daemonlord.messenger.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColors = lightColorScheme()
|
||||
private val DarkColors = darkColorScheme()
|
||||
@@ -17,8 +22,18 @@ fun MessengerTheme(content: @Composable () -> Unit) {
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
val colorScheme = if (darkTheme) DarkColors else LightColors
|
||||
val view = LocalView.current
|
||||
SideEffect {
|
||||
val window = (view.context as? Activity)?.window ?: return@SideEffect
|
||||
window.statusBarColor = colorScheme.surface.toArgb()
|
||||
window.navigationBarColor = colorScheme.surface.toArgb()
|
||||
val controller = WindowCompat.getInsetsController(window, view)
|
||||
controller.isAppearanceLightStatusBars = !darkTheme
|
||||
controller.isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||
colorScheme = colorScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
335
android/app/src/main/res/values-ru/strings.xml
Normal file
335
android/app/src/main/res/values-ru/strings.xml
Normal file
@@ -0,0 +1,335 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Benya Messenger</string>
|
||||
<string name="nav_chats">Чаты</string>
|
||||
<string name="nav_contacts">Контакты</string>
|
||||
<string name="nav_settings">Настройки</string>
|
||||
<string name="nav_profile">Профиль</string>
|
||||
|
||||
<string name="chats_connecting">Подключение...</string>
|
||||
<string name="chats_archived">Архив</string>
|
||||
<string name="chats_loading">Загрузка чатов...</string>
|
||||
<string name="chats_not_found">Чаты не найдены</string>
|
||||
<string name="chats_contentdesc_archive_selected">Архивировать выбранное</string>
|
||||
<string name="chats_contentdesc_delete_selected">Удалить выбранное</string>
|
||||
<string name="chats_contentdesc_selection_menu">Меню выбора</string>
|
||||
<string name="chats_selection_pin">Закрепить</string>
|
||||
<string name="chats_selection_unpin">Открепить</string>
|
||||
<string name="chats_selection_add_to_folder">Добавить в папку</string>
|
||||
<string name="chats_selection_mark_unread">Пометить непрочитанным</string>
|
||||
<string name="chats_selection_clear_cache">Удалить из кэша</string>
|
||||
<string name="chats_toast_folders_coming_soon">Папки чатов будут добавлены позже.</string>
|
||||
<string name="chats_toast_mark_unread_coming_soon">Отметка непрочитанным будет добавлена позже.</string>
|
||||
<string name="chats_dialog_create_group_title">Создать группу</string>
|
||||
<string name="chats_dialog_group_title_label">Название группы</string>
|
||||
<string name="chats_dialog_create_channel_title">Создать канал</string>
|
||||
<string name="chats_dialog_channel_title_label">Название канала</string>
|
||||
<string name="chats_dialog_channel_handle_label">Хэндл</string>
|
||||
<string name="chats_dialog_delete_selected_title">Удалить выбранные чаты</string>
|
||||
<string name="chats_dialog_delete_selected_body">Вы уверены, что хотите удалить выбранные чаты?</string>
|
||||
<string name="chats_dialog_delete_for_all">Удалить для всех (где доступно)</string>
|
||||
|
||||
<string name="filter_all">Все</string>
|
||||
<string name="filter_people">Люди</string>
|
||||
<string name="filter_groups">Группы</string>
|
||||
<string name="filter_channels">Каналы</string>
|
||||
|
||||
<string name="menu_day_mode">Дневной режим</string>
|
||||
<string name="menu_night_mode">Ночной режим</string>
|
||||
<string name="menu_create_group">Создать группу</string>
|
||||
<string name="menu_saved">Избранное</string>
|
||||
<string name="chat_list_info_group_created">Группа создана.</string>
|
||||
<string name="chat_list_info_channel_created">Канал создан.</string>
|
||||
<string name="chat_list_info_joined_chat">Вы вступили в чат.</string>
|
||||
<string name="chat_list_info_left_chat">Вы вышли из чата.</string>
|
||||
<string name="chat_list_info_archived">Чат архивирован.</string>
|
||||
<string name="chat_list_info_unarchived">Чат возвращен из архива.</string>
|
||||
<string name="chat_list_info_pinned">Чат закреплен.</string>
|
||||
<string name="chat_list_info_unpinned">Чат откреплен.</string>
|
||||
<string name="chat_list_info_history_cleared">История чата очищена.</string>
|
||||
<string name="chat_list_info_title_updated">Название обновлено.</string>
|
||||
<string name="chat_list_info_profile_updated">Профиль обновлен.</string>
|
||||
<string name="chat_list_info_deleted_for_me">Чат удален.</string>
|
||||
<string name="chat_list_info_deleted_for_all">Чат удален для всех.</string>
|
||||
<string name="chat_list_info_notifications_disabled">Уведомления выключены.</string>
|
||||
<string name="chat_list_info_notifications_enabled">Уведомления включены.</string>
|
||||
<string name="chat_list_info_invite_created">Приглашение: %1$s</string>
|
||||
<string name="chat_list_info_member_added">Добавлен %1$s</string>
|
||||
<string name="chat_list_info_member_role_updated">Роль обновлена: %1$s -> %2$s</string>
|
||||
<string name="chat_list_info_member_removed">Участник удален.</string>
|
||||
<string name="chat_list_info_member_banned">Участник заблокирован.</string>
|
||||
<string name="chat_list_info_member_unbanned">Участник разблокирован.</string>
|
||||
<string name="chat_list_error_title_required">Требуется название.</string>
|
||||
<string name="chat_list_error_title_or_description_required">Укажите название или описание.</string>
|
||||
<string name="chat_list_error_network_sync">Ошибка сети при синхронизации чатов.</string>
|
||||
<string name="chat_list_error_session_expired">Сессия истекла. Войдите снова.</string>
|
||||
<string name="chat_list_error_authorization_failed">Ошибка авторизации.</string>
|
||||
<string name="chat_list_error_server_loading">Ошибка сервера при загрузке чатов.</string>
|
||||
<string name="chat_list_error_unknown_loading">Неизвестная ошибка при загрузке чатов.</string>
|
||||
<string name="toast_day_mode_enabled">Включен дневной режим.</string>
|
||||
<string name="toast_night_mode_enabled">Включен ночной режим.</string>
|
||||
|
||||
<string name="common_cancel">Отмена</string>
|
||||
<string name="common_confirm">Подтвердить</string>
|
||||
<string name="common_close">Закрыть</string>
|
||||
<string name="common_delete">Удалить</string>
|
||||
<string name="common_create">Создать</string>
|
||||
<string name="common_send">Отправить</string>
|
||||
<string name="common_save">Сохранить</string>
|
||||
<string name="common_unknown_user">Неизвестный пользователь</string>
|
||||
<string name="profile_avatar_content_description">Аватар</string>
|
||||
<string name="profile_user_fallback">Пользователь</string>
|
||||
<string name="profile_choose_photo">Выбрать фото</string>
|
||||
<string name="profile_edit">Редактировать</string>
|
||||
<string name="profile_bio">О себе</string>
|
||||
<string name="profile_not_set">Не указано</string>
|
||||
<string name="profile_edit_profile">Редактировать профиль</string>
|
||||
<string name="profile_avatar_url">URL аватара</string>
|
||||
<string name="profile_crop_avatar">Обрезать аватар</string>
|
||||
<string name="profile_avatar_crop_preview">Предпросмотр обрезки аватара</string>
|
||||
<string name="profile_use_crop">Использовать</string>
|
||||
<string name="profile_crop_hint">Используйте два пальца для масштабирования и перемещения.</string>
|
||||
|
||||
<string name="chat_menu_notifications">Уведомления</string>
|
||||
<string name="chat_menu_search">Поиск</string>
|
||||
<string name="chat_menu_change_wallpaper">Изменить обои</string>
|
||||
<string name="chat_menu_clear_history">Очистить историю</string>
|
||||
<string name="chat_wallpaper_coming_soon">Смена обоев будет добавлена позже.</string>
|
||||
<string name="chat_delete_dialog">Удалить диалог</string>
|
||||
<string name="chat_leave_delete">Выйти и удалить чат</string>
|
||||
<string name="chat_leave_chat">Выйти из чата</string>
|
||||
<string name="chat_leave">Выйти</string>
|
||||
<string name="chat_search_in_chat">Поиск по чату</string>
|
||||
<string name="chat_search_prev">Назад</string>
|
||||
<string name="chat_search_next">Далее</string>
|
||||
<string name="chat_search_gifs">Поиск GIF</string>
|
||||
<string name="chat_search_matches">Совпадений: %1$d</string>
|
||||
<string name="chat_pinned_message">Закрепленное сообщение</string>
|
||||
<string name="chat_message_placeholder">Сообщение</string>
|
||||
<string name="chat_action_reply">Ответить</string>
|
||||
<string name="chat_action_edit">Изменить</string>
|
||||
<string name="chat_action_forward">Переслать</string>
|
||||
<string name="chat_action_delete">Удалить</string>
|
||||
<string name="chat_delete_message_title">Удалить сообщение</string>
|
||||
<string name="chat_forward_one">Переслать сообщение #%1$d</string>
|
||||
<string name="chat_forward_many">Переслать %1$d сообщений</string>
|
||||
<string name="chat_no_available_chats">Нет доступных чатов</string>
|
||||
<string name="chat_forwarding">Пересылка...</string>
|
||||
<string name="chat_editing_message">Редактирование сообщения #%1$d</string>
|
||||
<string name="chat_reply_to">Ответ</string>
|
||||
<string name="chat_delete_message_for_everyone">Удалить выбранное сообщение для всех?</string>
|
||||
<string name="chat_delete_message_for_me">Удалить выбранные сообщения у вас?</string>
|
||||
<string name="chat_clear_history_confirm">Удалить все сообщения в этом чате? Это действие нельзя отменить.</string>
|
||||
<string name="chat_clear">Очистить</string>
|
||||
<string name="chat_delete_dialog_confirm">Удалить диалог у вас? История будет очищена.</string>
|
||||
<string name="chat_leave_delete_confirm">Выйти из чата и убрать его из списка?</string>
|
||||
<string name="chat_enable_sound">Включить звук</string>
|
||||
<string name="chat_disable_sound">Выключить звук</string>
|
||||
<string name="chat_circle_video">Видеокружок</string>
|
||||
<string name="chat_open_item_failed">Не удалось открыть элемент</string>
|
||||
<string name="chat_status_online">в сети</string>
|
||||
<string name="chat_status_last_seen_recently">был(а) недавно</string>
|
||||
<string name="chat_type_group">группа</string>
|
||||
<string name="chat_type_channel">канал</string>
|
||||
<string name="chat_info_tab_media">Медиа</string>
|
||||
<string name="chat_info_tab_files">Файлы</string>
|
||||
<string name="chat_info_tab_links">Ссылки</string>
|
||||
<string name="chat_info_tab_voice">Голосовые</string>
|
||||
<string name="chat_info_tab_members">Участники</string>
|
||||
<string name="chat_info_empty">Пока нет: %1$s</string>
|
||||
<string name="chat_info_no_members_data">Нет данных об участниках</string>
|
||||
<string name="chat_members_header">Участники (%1$d)</string>
|
||||
<string name="chat_banned_header">Заблокированные (%1$d)</string>
|
||||
<string name="chat_member_action_promote">Повысить</string>
|
||||
<string name="chat_member_action_demote">Понизить</string>
|
||||
<string name="chat_member_action_transfer_owner">Передать owner</string>
|
||||
<string name="chat_member_action_ban">Забанить</string>
|
||||
<string name="chat_member_action_kick">Кикнуть</string>
|
||||
<string name="chat_member_action_unban">Разбанить</string>
|
||||
<string name="chat_media_badge_video">Видео</string>
|
||||
<string name="chat_attachment_title">Вложение</string>
|
||||
<string name="chat_attachment_gallery_file">Галерея / Файл</string>
|
||||
<string name="chat_attachment_take_photo">Сделать фото</string>
|
||||
<string name="chat_attachment_take_video">Снять видео</string>
|
||||
<string name="chat_media_badge_gif">GIF</string>
|
||||
<string name="chat_media_badge_sticker">Стикер</string>
|
||||
<string name="chat_picker_tab_emoji">Эмодзи</string>
|
||||
<string name="chat_picker_tab_gif">GIF</string>
|
||||
<string name="chat_picker_tab_stickers">Стикеры</string>
|
||||
<string name="chat_playback_subtitle_voice">Голосовое сообщение • %1$s</string>
|
||||
<string name="chat_playback_subtitle_audio">Аудио • %1$s</string>
|
||||
<string name="chat_user_fallback_with_id">Пользователь #%1$d</string>
|
||||
<string name="chat_member_role_with_id">%1$s • id %2$d</string>
|
||||
<string name="chat_member_id">id %1$d</string>
|
||||
<string name="chat_member_dialog_demote_title">Понизить админа</string>
|
||||
<string name="chat_member_dialog_demote_body">Понизить %1$s до участника?</string>
|
||||
<string name="chat_member_dialog_transfer_title">Передача owner</string>
|
||||
<string name="chat_member_dialog_transfer_body">Передать owner пользователю %1$s?</string>
|
||||
<string name="chat_member_dialog_ban_title">Блокировка участника</string>
|
||||
<string name="chat_member_dialog_ban_body">Забанить %1$s?</string>
|
||||
<string name="chat_member_dialog_kick_title">Исключить участника</string>
|
||||
<string name="chat_member_dialog_kick_body">Исключить %1$s из чата?</string>
|
||||
<string name="chat_error_voice_too_short">Голосовое сообщение слишком короткое.</string>
|
||||
<string name="chat_error_delete_for_all_single">Удаление для всех доступно только при выборе одного сообщения.</string>
|
||||
<string name="chat_error_delete_for_all_own">Удаление для всех доступно только для ваших сообщений.</string>
|
||||
<string name="chat_info_history_cleared">История чата очищена.</string>
|
||||
<string name="chat_error_edit_expired">Это сообщение уже нельзя редактировать.</string>
|
||||
<string name="chat_error_send_restricted">Отправка сообщений в этом чате ограничена.</string>
|
||||
<string name="chat_restriction_owner_admin">Только owner/admin канала может отправлять сообщения.</string>
|
||||
<string name="chat_error_action_self">Это действие нельзя применить к себе.</string>
|
||||
<string name="chat_error_permissions">Недостаточно прав.</string>
|
||||
<string name="chat_error_owner_only">Только owner может выполнить это действие.</string>
|
||||
<string name="chat_error_manage_owner">Нельзя управлять аккаунтом owner.</string>
|
||||
<string name="chat_error_admin_manage_admin_owner">Админ не может управлять админами и owner.</string>
|
||||
<string name="chat_error_transfer_choose_another">Выберите другого участника для передачи owner.</string>
|
||||
<string name="chat_voice_hint_slide">Проведите вверх, чтобы закрепить, и влево, чтобы отменить</string>
|
||||
<string name="chat_voice_hint_locked">Запись закреплена</string>
|
||||
<string name="chat_title_fallback">Чат #%1$d</string>
|
||||
<string name="chat_day_today">Сегодня</string>
|
||||
<string name="chat_day_yesterday">Вчера</string>
|
||||
<string name="chat_voice_recording_duration">Голос %1$s</string>
|
||||
<string name="chat_unknown_user">Неизвестный пользователь</string>
|
||||
<string name="chat_media_placeholder">[медиа]</string>
|
||||
<string name="chat_forwarded_from">Переслано от %1$s</string>
|
||||
<string name="chat_error_giphy_api_key_missing">Укажите GIPHY_API_KEY в local.properties</string>
|
||||
<string name="chat_error_no_gifs_found">GIF не найдены</string>
|
||||
|
||||
<string name="settings_user_fallback">Пользователь</string>
|
||||
<string name="settings_accounts_header">АККАУНТЫ</string>
|
||||
<string name="settings_no_active_account">Нет активного аккаунта</string>
|
||||
<string name="settings_add_account">Добавить аккаунт</string>
|
||||
<string name="settings_folder_account">Аккаунт</string>
|
||||
<string name="settings_folder_chat">Настройки чатов</string>
|
||||
<string name="settings_folder_privacy">Конфиденциальность</string>
|
||||
<string name="settings_folder_notifications">Уведомления</string>
|
||||
<string name="settings_folder_data">Данные и память</string>
|
||||
<string name="settings_folder_folders">Папки с чатами</string>
|
||||
<string name="settings_folder_devices">Устройства</string>
|
||||
<string name="settings_folder_power">Энергосбережение</string>
|
||||
<string name="settings_folder_language">Язык</string>
|
||||
<string name="settings_account_subtitle">Номер, имя пользователя, «О себе»</string>
|
||||
<string name="settings_chat_subtitle">Обои, ночной режим, анимации</string>
|
||||
<string name="settings_privacy_subtitle">Время захода, устройства, ключи доступа</string>
|
||||
<string name="settings_notifications_subtitle">Звуки, звонки, счетчик сообщений</string>
|
||||
<string name="settings_data_subtitle">Настройки загрузки медиафайлов</string>
|
||||
<string name="settings_folders_subtitle">Сортировка чатов по папкам</string>
|
||||
<string name="settings_devices_subtitle">Управление активными сессиями</string>
|
||||
<string name="settings_power_subtitle">Экономия энергии при низком заряде</string>
|
||||
<string name="settings_placeholder_data">Этот раздел будет расширен на следующем шаге.</string>
|
||||
<string name="settings_placeholder_folders">Управление папками чатов будет добавлено на следующей итерации.</string>
|
||||
<string name="settings_placeholder_power">Настройки энергосбережения будут добавлены отдельным этапом.</string>
|
||||
<string name="settings_language_title">Язык приложения</string>
|
||||
<string name="settings_language_system_subtitle">Использовать язык устройства</string>
|
||||
<string name="settings_language_english_subtitle">Английский</string>
|
||||
<string name="language_system">Системный</string>
|
||||
<string name="language_russian">Русский</string>
|
||||
<string name="language_english">Английский</string>
|
||||
<string name="settings_accounts">Аккаунты</string>
|
||||
<string name="settings_active">Активный</string>
|
||||
<string name="settings_switch">Переключить</string>
|
||||
<string name="settings_open_profile">Открыть профиль</string>
|
||||
<string name="settings_logout">Выйти</string>
|
||||
<string name="settings_appearance">Оформление</string>
|
||||
<string name="theme_light">Светлая</string>
|
||||
<string name="theme_dark">Темная</string>
|
||||
<string name="theme_system">Системная</string>
|
||||
<string name="settings_enable_notifications">Включить уведомления</string>
|
||||
<string name="settings_show_preview">Показывать превью сообщений</string>
|
||||
<string name="settings_refresh_notifications">Обновить историю уведомлений</string>
|
||||
<string name="settings_recent_notifications">Последние уведомления</string>
|
||||
<string name="settings_no_notifications_yet">Пока нет серверных уведомлений.</string>
|
||||
<string name="settings_notification_meta">chat=%1$s • msg=%2$s • %3$s</string>
|
||||
<string name="settings_fallback_avatar_letter">А</string>
|
||||
<string name="privacy_private_messages">Личные сообщения</string>
|
||||
<string name="privacy_last_seen">Время захода</string>
|
||||
<string name="privacy_avatar">Аватар</string>
|
||||
<string name="privacy_group_invites">Приглашения в группы</string>
|
||||
<string name="privacy_save">Сохранить приватность</string>
|
||||
<string name="settings_sessions_security">Сессии и безопасность</string>
|
||||
<string name="settings_unknown_device">Неизвестное устройство</string>
|
||||
<string name="settings_unknown_ip">Неизвестный IP</string>
|
||||
<string name="settings_revoke">Отозвать</string>
|
||||
<string name="settings_revoke_all">Отозвать все сессии</string>
|
||||
<string name="settings_2fa_code">Код 2FA</string>
|
||||
<string name="settings_enable">Включить</string>
|
||||
<string name="settings_disable">Выключить</string>
|
||||
<string name="settings_recovery_regen_code">Код для регенерации recovery</string>
|
||||
<string name="settings_regenerate_recovery_codes">Сгенерировать recovery-коды заново</string>
|
||||
<string name="privacy_everyone">все</string>
|
||||
<string name="privacy_contacts">контакты</string>
|
||||
<string name="privacy_nobody">никто</string>
|
||||
|
||||
<string name="auth_header_login">Вход в Messenger</string>
|
||||
<string name="auth_subtitle_enter_email">Введите email для продолжения</string>
|
||||
<string name="auth_subtitle_enter_password">Введите пароль для %1$s</string>
|
||||
<string name="auth_subtitle_create_account">Создайте аккаунт для %1$s</string>
|
||||
<string name="auth_subtitle_2fa_enabled">Включена двухфакторная аутентификация</string>
|
||||
<string name="auth_label_email">Email</string>
|
||||
<string name="auth_label_name">Имя</string>
|
||||
<string name="auth_label_username">Имя пользователя</string>
|
||||
<string name="auth_label_password">Пароль</string>
|
||||
<string name="auth_label_recovery_code">Код восстановления</string>
|
||||
<string name="auth_label_2fa_code">Код 2FA</string>
|
||||
<string name="auth_continue">Продолжить</string>
|
||||
<string name="auth_change_email">Изменить email</string>
|
||||
<string name="auth_use_otp_code">Использовать OTP-код</string>
|
||||
<string name="auth_use_recovery_code">Использовать код восстановления</string>
|
||||
<string name="auth_sign_in">Войти</string>
|
||||
<string name="auth_create_account">Создать аккаунт</string>
|
||||
<string name="auth_confirm_2fa">Подтвердить 2FA</string>
|
||||
<string name="auth_verify_email_by_token">Подтвердить email по токену</string>
|
||||
<string name="auth_forgot_password">Забыли пароль</string>
|
||||
<string name="auth_verify_email_title">Подтверждение email</string>
|
||||
<string name="auth_verification_token">Токен подтверждения</string>
|
||||
<string name="auth_verify">Подтвердить</string>
|
||||
<string name="auth_email_for_resend">Email для повторной отправки</string>
|
||||
<string name="auth_resend_verification_link">Отправить ссылку повторно</string>
|
||||
<string name="auth_back_to_login">Назад ко входу</string>
|
||||
<string name="auth_password_reset_title">Сброс пароля</string>
|
||||
<string name="auth_send_reset_link">Отправить ссылку сброса</string>
|
||||
<string name="auth_new_password">Новый пароль</string>
|
||||
<string name="auth_reset_with_token">Сбросить по токену</string>
|
||||
<string name="auth_error_enter_email">Введите email.</string>
|
||||
<string name="auth_info_email_not_registered">Этот email не зарегистрирован. Завершите регистрацию.</string>
|
||||
<string name="auth_error_register_fields_required">Нужны имя, имя пользователя и пароль.</string>
|
||||
<string name="auth_info_account_created">Аккаунт создан. Используйте пароль для входа.</string>
|
||||
<string name="auth_error_password_required">Требуется пароль.</string>
|
||||
<string name="auth_info_enter_2fa_or_recovery">Введите код 2FA или код восстановления.</string>
|
||||
<string name="auth_error_enter_2fa_code">Введите код 2FA.</string>
|
||||
<string name="auth_error_enter_recovery_code">Введите код восстановления.</string>
|
||||
<string name="auth_error_invalid_credentials">Неверный email или пароль.</string>
|
||||
<string name="auth_error_network">Ошибка сети. Проверьте подключение.</string>
|
||||
<string name="auth_error_session_expired">Сессия истекла. Войдите снова.</string>
|
||||
<string name="auth_error_server">Ошибка сервера. Попробуйте снова.</string>
|
||||
<string name="auth_error_unknown">Неизвестная ошибка. Попробуйте снова.</string>
|
||||
|
||||
<string name="contacts_title">Контакты</string>
|
||||
<string name="contacts_search_label">Поиск контактов/пользователей</string>
|
||||
<string name="contacts_add_by_email_label">Добавить по email</string>
|
||||
<string name="contacts_search_results">Результаты поиска</string>
|
||||
<string name="contacts_my_contacts">Мои контакты</string>
|
||||
<string name="contacts_last_seen_recently">был(а) недавно</string>
|
||||
<string name="contacts_remove">Удалить</string>
|
||||
<string name="contacts_empty">Контактов пока нет.</string>
|
||||
<string name="contacts_info_added">Контакт добавлен.</string>
|
||||
<string name="contacts_info_added_by_email">Контакт добавлен по email.</string>
|
||||
<string name="contacts_info_removed">Контакт удален.</string>
|
||||
<string name="contacts_error_email_required">Требуется email.</string>
|
||||
<string name="error_network">Ошибка сети.</string>
|
||||
<string name="error_session_expired">Сессия истекла.</string>
|
||||
<string name="error_authorization">Ошибка авторизации.</string>
|
||||
<string name="error_server">Ошибка сервера.</string>
|
||||
<string name="error_unknown">Неизвестная ошибка.</string>
|
||||
<string name="account_user_fallback">Пользователь #%1$d</string>
|
||||
<string name="account_error_email_password_required">Нужны email и пароль.</string>
|
||||
<string name="account_info_added">Аккаунт добавлен.</string>
|
||||
<string name="account_error_no_saved_session">Для этого аккаунта нет сохраненной сессии.</string>
|
||||
<string name="account_error_switch_sync_failed">Аккаунт переключен, но синхронизация чатов не удалась. Потяните вниз для обновления.</string>
|
||||
<string name="account_info_profile_updated">Профиль обновлен.</string>
|
||||
<string name="account_info_avatar_uploaded">Аватар загружен.</string>
|
||||
<string name="account_info_privacy_updated">Настройки приватности обновлены.</string>
|
||||
<string name="account_info_2fa_secret_generated">Секрет 2FA сгенерирован. Введите код для включения.</string>
|
||||
<string name="account_info_recovery_codes_regenerated">Коды восстановления перегенерированы.</string>
|
||||
<string name="account_error_invalid_credentials">Неверные учетные данные.</string>
|
||||
<string name="account_error_unauthorized">Не авторизовано.</string>
|
||||
</resources>
|
||||
@@ -1,4 +1,335 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Benya Messenger</string>
|
||||
<string name="nav_chats">Chats</string>
|
||||
<string name="nav_contacts">Contacts</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="nav_profile">Profile</string>
|
||||
|
||||
<string name="chats_connecting">Connecting...</string>
|
||||
<string name="chats_archived">Archived</string>
|
||||
<string name="chats_loading">Loading chats...</string>
|
||||
|
||||
<string name="filter_all">All</string>
|
||||
<string name="filter_people">People</string>
|
||||
<string name="filter_groups">Groups</string>
|
||||
<string name="filter_channels">Channels</string>
|
||||
|
||||
<string name="menu_day_mode">Day mode</string>
|
||||
<string name="menu_night_mode">Night mode</string>
|
||||
<string name="menu_create_group">Create group</string>
|
||||
<string name="menu_saved">Saved</string>
|
||||
<string name="chat_list_info_group_created">Group created.</string>
|
||||
<string name="chat_list_info_channel_created">Channel created.</string>
|
||||
<string name="chat_list_info_joined_chat">Joined chat.</string>
|
||||
<string name="chat_list_info_left_chat">Left chat.</string>
|
||||
<string name="chat_list_info_archived">Chat archived.</string>
|
||||
<string name="chat_list_info_unarchived">Chat restored from archive.</string>
|
||||
<string name="chat_list_info_pinned">Chat pinned.</string>
|
||||
<string name="chat_list_info_unpinned">Chat unpinned.</string>
|
||||
<string name="chat_list_info_history_cleared">Chat history cleared.</string>
|
||||
<string name="chat_list_info_title_updated">Title updated.</string>
|
||||
<string name="chat_list_info_profile_updated">Profile updated.</string>
|
||||
<string name="chat_list_info_deleted_for_me">Chat deleted.</string>
|
||||
<string name="chat_list_info_deleted_for_all">Chat deleted for everyone.</string>
|
||||
<string name="chat_list_info_notifications_disabled">Notifications disabled.</string>
|
||||
<string name="chat_list_info_notifications_enabled">Notifications enabled.</string>
|
||||
<string name="chat_list_info_invite_created">Invite: %1$s</string>
|
||||
<string name="chat_list_info_member_added">Added %1$s</string>
|
||||
<string name="chat_list_info_member_role_updated">Role updated: %1$s -> %2$s</string>
|
||||
<string name="chat_list_info_member_removed">Member removed.</string>
|
||||
<string name="chat_list_info_member_banned">Member banned.</string>
|
||||
<string name="chat_list_info_member_unbanned">Member unbanned.</string>
|
||||
<string name="chat_list_error_title_required">Title is required.</string>
|
||||
<string name="chat_list_error_title_or_description_required">Provide title or description.</string>
|
||||
<string name="chat_list_error_network_sync">Network error while syncing chats.</string>
|
||||
<string name="chat_list_error_session_expired">Session expired. Please log in again.</string>
|
||||
<string name="chat_list_error_authorization_failed">Authorization failed.</string>
|
||||
<string name="chat_list_error_server_loading">Server error while loading chats.</string>
|
||||
<string name="chat_list_error_unknown_loading">Unknown error while loading chats.</string>
|
||||
<string name="toast_day_mode_enabled">Day mode enabled.</string>
|
||||
<string name="toast_night_mode_enabled">Night mode enabled.</string>
|
||||
<string name="chats_not_found">No chats found</string>
|
||||
<string name="chats_contentdesc_archive_selected">Archive selected</string>
|
||||
<string name="chats_contentdesc_delete_selected">Delete selected</string>
|
||||
<string name="chats_contentdesc_selection_menu">Selection menu</string>
|
||||
<string name="chats_selection_pin">Pin</string>
|
||||
<string name="chats_selection_unpin">Unpin</string>
|
||||
<string name="chats_selection_add_to_folder">Add to folder</string>
|
||||
<string name="chats_selection_mark_unread">Mark as unread</string>
|
||||
<string name="chats_selection_clear_cache">Clear cache</string>
|
||||
<string name="chats_toast_folders_coming_soon">Chat folders will be added later.</string>
|
||||
<string name="chats_toast_mark_unread_coming_soon">Mark as unread will be added later.</string>
|
||||
<string name="chats_dialog_create_group_title">Create group</string>
|
||||
<string name="chats_dialog_group_title_label">Group title</string>
|
||||
<string name="chats_dialog_create_channel_title">Create channel</string>
|
||||
<string name="chats_dialog_channel_title_label">Channel title</string>
|
||||
<string name="chats_dialog_channel_handle_label">Handle</string>
|
||||
<string name="chats_dialog_delete_selected_title">Delete selected chats</string>
|
||||
<string name="chats_dialog_delete_selected_body">Are you sure you want to delete selected chats?</string>
|
||||
<string name="chats_dialog_delete_for_all">Delete for all (where allowed)</string>
|
||||
|
||||
<string name="common_cancel">Cancel</string>
|
||||
<string name="common_confirm">Confirm</string>
|
||||
<string name="common_close">Close</string>
|
||||
<string name="common_delete">Delete</string>
|
||||
<string name="common_create">Create</string>
|
||||
<string name="common_send">Send</string>
|
||||
<string name="common_save">Save</string>
|
||||
<string name="common_unknown_user">Unknown user</string>
|
||||
<string name="profile_avatar_content_description">Avatar</string>
|
||||
<string name="profile_user_fallback">User</string>
|
||||
<string name="profile_choose_photo">Choose photo</string>
|
||||
<string name="profile_edit">Edit</string>
|
||||
<string name="profile_bio">Bio</string>
|
||||
<string name="profile_not_set">Not set</string>
|
||||
<string name="profile_edit_profile">Edit profile</string>
|
||||
<string name="profile_avatar_url">Avatar URL</string>
|
||||
<string name="profile_crop_avatar">Crop avatar</string>
|
||||
<string name="profile_avatar_crop_preview">Avatar crop preview</string>
|
||||
<string name="profile_use_crop">Use</string>
|
||||
<string name="profile_crop_hint">Use two fingers to zoom and move.</string>
|
||||
|
||||
<string name="chat_menu_notifications">Notifications</string>
|
||||
<string name="chat_menu_search">Search</string>
|
||||
<string name="chat_menu_change_wallpaper">Change wallpaper</string>
|
||||
<string name="chat_menu_clear_history">Clear history</string>
|
||||
<string name="chat_wallpaper_coming_soon">Wallpaper change will be added later.</string>
|
||||
<string name="chat_delete_dialog">Delete dialog</string>
|
||||
<string name="chat_leave_delete">Leave and delete chat</string>
|
||||
<string name="chat_leave_chat">Leave chat</string>
|
||||
<string name="chat_leave">Leave</string>
|
||||
<string name="chat_search_in_chat">Search in chat</string>
|
||||
<string name="chat_search_prev">Prev</string>
|
||||
<string name="chat_search_next">Next</string>
|
||||
<string name="chat_search_gifs">Search GIFs</string>
|
||||
<string name="chat_search_matches">Matches: %1$d</string>
|
||||
<string name="chat_pinned_message">Pinned message</string>
|
||||
<string name="chat_message_placeholder">Message</string>
|
||||
<string name="chat_action_reply">Reply</string>
|
||||
<string name="chat_action_edit">Edit</string>
|
||||
<string name="chat_action_forward">Forward</string>
|
||||
<string name="chat_action_delete">Delete</string>
|
||||
<string name="chat_delete_message_title">Delete message</string>
|
||||
<string name="chat_forward_one">Forward message #%1$d</string>
|
||||
<string name="chat_forward_many">Forward %1$d messages</string>
|
||||
<string name="chat_no_available_chats">No available chats</string>
|
||||
<string name="chat_forwarding">Forwarding...</string>
|
||||
<string name="chat_editing_message">Editing message #%1$d</string>
|
||||
<string name="chat_reply_to">Reply to</string>
|
||||
<string name="chat_delete_message_for_everyone">Delete selected message for everyone?</string>
|
||||
<string name="chat_delete_message_for_me">Delete selected message(s) for you?</string>
|
||||
<string name="chat_clear_history_confirm">Delete all messages in this chat? This action cannot be undone.</string>
|
||||
<string name="chat_clear">Clear</string>
|
||||
<string name="chat_delete_dialog_confirm">Delete dialog for you? History will be cleared.</string>
|
||||
<string name="chat_leave_delete_confirm">Leave the chat and remove it from your list?</string>
|
||||
<string name="chat_enable_sound">Enable sound</string>
|
||||
<string name="chat_disable_sound">Disable sound</string>
|
||||
<string name="chat_circle_video">Circle video</string>
|
||||
<string name="chat_open_item_failed">Unable to open item</string>
|
||||
<string name="chat_status_online">online</string>
|
||||
<string name="chat_status_last_seen_recently">last seen recently</string>
|
||||
<string name="chat_type_group">group</string>
|
||||
<string name="chat_type_channel">channel</string>
|
||||
<string name="chat_info_tab_media">Media</string>
|
||||
<string name="chat_info_tab_files">Files</string>
|
||||
<string name="chat_info_tab_links">Links</string>
|
||||
<string name="chat_info_tab_voice">Voice</string>
|
||||
<string name="chat_info_tab_members">Members</string>
|
||||
<string name="chat_info_empty">No %1$s yet</string>
|
||||
<string name="chat_info_no_members_data">No members data</string>
|
||||
<string name="chat_members_header">Members (%1$d)</string>
|
||||
<string name="chat_banned_header">Banned (%1$d)</string>
|
||||
<string name="chat_member_action_promote">Promote</string>
|
||||
<string name="chat_member_action_demote">Demote</string>
|
||||
<string name="chat_member_action_transfer_owner">Transfer owner</string>
|
||||
<string name="chat_member_action_ban">Ban</string>
|
||||
<string name="chat_member_action_kick">Kick</string>
|
||||
<string name="chat_member_action_unban">Unban</string>
|
||||
<string name="chat_media_badge_video">Video</string>
|
||||
<string name="chat_attachment_title">Attachment</string>
|
||||
<string name="chat_attachment_gallery_file">Gallery / File</string>
|
||||
<string name="chat_attachment_take_photo">Take photo</string>
|
||||
<string name="chat_attachment_take_video">Record video</string>
|
||||
<string name="chat_media_badge_gif">GIF</string>
|
||||
<string name="chat_media_badge_sticker">Sticker</string>
|
||||
<string name="chat_picker_tab_emoji">Emoji</string>
|
||||
<string name="chat_picker_tab_gif">GIF</string>
|
||||
<string name="chat_picker_tab_stickers">Stickers</string>
|
||||
<string name="chat_playback_subtitle_voice">Voice message • %1$s</string>
|
||||
<string name="chat_playback_subtitle_audio">Audio • %1$s</string>
|
||||
<string name="chat_user_fallback_with_id">User #%1$d</string>
|
||||
<string name="chat_member_role_with_id">%1$s • id %2$d</string>
|
||||
<string name="chat_member_id">id %1$d</string>
|
||||
<string name="chat_member_dialog_demote_title">Demote admin</string>
|
||||
<string name="chat_member_dialog_demote_body">Demote %1$s to member?</string>
|
||||
<string name="chat_member_dialog_transfer_title">Transfer ownership</string>
|
||||
<string name="chat_member_dialog_transfer_body">Transfer ownership to %1$s?</string>
|
||||
<string name="chat_member_dialog_ban_title">Ban member</string>
|
||||
<string name="chat_member_dialog_ban_body">Ban %1$s?</string>
|
||||
<string name="chat_member_dialog_kick_title">Kick member</string>
|
||||
<string name="chat_member_dialog_kick_body">Kick %1$s from chat?</string>
|
||||
<string name="chat_error_voice_too_short">Voice message is too short.</string>
|
||||
<string name="chat_error_delete_for_all_single">Delete for all is available only for single message selection.</string>
|
||||
<string name="chat_error_delete_for_all_own">Delete for all is available only for your own messages.</string>
|
||||
<string name="chat_info_history_cleared">Chat history cleared.</string>
|
||||
<string name="chat_error_edit_expired">This message can no longer be edited.</string>
|
||||
<string name="chat_error_send_restricted">Sending is restricted in this chat.</string>
|
||||
<string name="chat_restriction_owner_admin">Only channel owner/admin can send messages.</string>
|
||||
<string name="chat_error_action_self">You cannot apply this action to yourself.</string>
|
||||
<string name="chat_error_permissions">You don\'t have enough permissions.</string>
|
||||
<string name="chat_error_owner_only">Only owner can perform this action.</string>
|
||||
<string name="chat_error_manage_owner">You cannot manage owner account.</string>
|
||||
<string name="chat_error_admin_manage_admin_owner">Admin cannot manage admins or owner.</string>
|
||||
<string name="chat_error_transfer_choose_another">Choose another member for ownership transfer.</string>
|
||||
<string name="chat_voice_hint_slide">Slide up to lock, slide left to cancel</string>
|
||||
<string name="chat_voice_hint_locked">Recording locked</string>
|
||||
<string name="chat_title_fallback">Chat #%1$d</string>
|
||||
<string name="chat_day_today">Today</string>
|
||||
<string name="chat_day_yesterday">Yesterday</string>
|
||||
<string name="chat_voice_recording_duration">Voice %1$s</string>
|
||||
<string name="chat_unknown_user">Unknown user</string>
|
||||
<string name="chat_media_placeholder">[media]</string>
|
||||
<string name="chat_forwarded_from">Forwarded from %1$s</string>
|
||||
<string name="chat_error_giphy_api_key_missing">Set GIPHY_API_KEY in local.properties</string>
|
||||
<string name="chat_error_no_gifs_found">No GIFs found</string>
|
||||
|
||||
<string name="settings_user_fallback">User</string>
|
||||
<string name="settings_accounts_header">ACCOUNTS</string>
|
||||
<string name="settings_no_active_account">No active account</string>
|
||||
<string name="settings_add_account">Add account</string>
|
||||
<string name="settings_folder_account">Account</string>
|
||||
<string name="settings_folder_chat">Chat settings</string>
|
||||
<string name="settings_folder_privacy">Privacy</string>
|
||||
<string name="settings_folder_notifications">Notifications</string>
|
||||
<string name="settings_folder_data">Data and storage</string>
|
||||
<string name="settings_folder_folders">Chat folders</string>
|
||||
<string name="settings_folder_devices">Devices</string>
|
||||
<string name="settings_folder_power">Power saving</string>
|
||||
<string name="settings_folder_language">Language</string>
|
||||
<string name="settings_account_subtitle">Phone number, username, bio</string>
|
||||
<string name="settings_chat_subtitle">Wallpaper, night mode, animations</string>
|
||||
<string name="settings_privacy_subtitle">Last seen, devices, passkeys</string>
|
||||
<string name="settings_notifications_subtitle">Sounds, message counter</string>
|
||||
<string name="settings_data_subtitle">Media download settings</string>
|
||||
<string name="settings_folders_subtitle">Sort chats by folders</string>
|
||||
<string name="settings_devices_subtitle">Manage active sessions</string>
|
||||
<string name="settings_power_subtitle">Save power on low battery</string>
|
||||
<string name="settings_placeholder_data">This section will be expanded in the next step.</string>
|
||||
<string name="settings_placeholder_folders">Chat folders management will be added in next iteration.</string>
|
||||
<string name="settings_placeholder_power">Power saving settings will be added in a separate step.</string>
|
||||
<string name="settings_language_title">App language</string>
|
||||
<string name="settings_language_system_subtitle">Use device language</string>
|
||||
<string name="settings_language_english_subtitle">English</string>
|
||||
<string name="language_system">System</string>
|
||||
<string name="language_russian">Russian</string>
|
||||
<string name="language_english">English</string>
|
||||
<string name="settings_accounts">Accounts</string>
|
||||
<string name="settings_active">Active</string>
|
||||
<string name="settings_switch">Switch</string>
|
||||
<string name="settings_open_profile">Open profile</string>
|
||||
<string name="settings_logout">Logout</string>
|
||||
<string name="settings_appearance">Appearance</string>
|
||||
<string name="theme_light">Light</string>
|
||||
<string name="theme_dark">Dark</string>
|
||||
<string name="theme_system">System</string>
|
||||
<string name="settings_enable_notifications">Enable notifications</string>
|
||||
<string name="settings_show_preview">Show message preview</string>
|
||||
<string name="settings_refresh_notifications">Refresh notification history</string>
|
||||
<string name="settings_recent_notifications">Recent notifications</string>
|
||||
<string name="settings_no_notifications_yet">No server notifications yet.</string>
|
||||
<string name="settings_notification_meta">chat=%1$s • msg=%2$s • %3$s</string>
|
||||
<string name="settings_fallback_avatar_letter">A</string>
|
||||
<string name="privacy_private_messages">Private messages</string>
|
||||
<string name="privacy_last_seen">Last seen</string>
|
||||
<string name="privacy_avatar">Avatar</string>
|
||||
<string name="privacy_group_invites">Group invites</string>
|
||||
<string name="privacy_save">Save privacy</string>
|
||||
<string name="settings_sessions_security">Sessions & Security</string>
|
||||
<string name="settings_unknown_device">Unknown device</string>
|
||||
<string name="settings_unknown_ip">Unknown IP</string>
|
||||
<string name="settings_revoke">Revoke</string>
|
||||
<string name="settings_revoke_all">Revoke all sessions</string>
|
||||
<string name="settings_2fa_code">2FA code</string>
|
||||
<string name="settings_enable">Enable</string>
|
||||
<string name="settings_disable">Disable</string>
|
||||
<string name="settings_recovery_regen_code">Code for recovery regeneration</string>
|
||||
<string name="settings_regenerate_recovery_codes">Regenerate recovery codes</string>
|
||||
<string name="privacy_everyone">everyone</string>
|
||||
<string name="privacy_contacts">contacts</string>
|
||||
<string name="privacy_nobody">nobody</string>
|
||||
|
||||
<string name="auth_header_login">Messenger Login</string>
|
||||
<string name="auth_subtitle_enter_email">Enter your email to continue</string>
|
||||
<string name="auth_subtitle_enter_password">Enter password for %1$s</string>
|
||||
<string name="auth_subtitle_create_account">Create account for %1$s</string>
|
||||
<string name="auth_subtitle_2fa_enabled">Two-factor authentication is enabled</string>
|
||||
<string name="auth_label_email">Email</string>
|
||||
<string name="auth_label_name">Name</string>
|
||||
<string name="auth_label_username">Username</string>
|
||||
<string name="auth_label_password">Password</string>
|
||||
<string name="auth_label_recovery_code">Recovery code</string>
|
||||
<string name="auth_label_2fa_code">2FA code</string>
|
||||
<string name="auth_continue">Continue</string>
|
||||
<string name="auth_change_email">Change email</string>
|
||||
<string name="auth_use_otp_code">Use OTP code</string>
|
||||
<string name="auth_use_recovery_code">Use recovery code</string>
|
||||
<string name="auth_sign_in">Sign in</string>
|
||||
<string name="auth_create_account">Create account</string>
|
||||
<string name="auth_confirm_2fa">Confirm 2FA</string>
|
||||
<string name="auth_verify_email_by_token">Verify email by token</string>
|
||||
<string name="auth_forgot_password">Forgot password</string>
|
||||
<string name="auth_verify_email_title">Verify email</string>
|
||||
<string name="auth_verification_token">Verification token</string>
|
||||
<string name="auth_verify">Verify</string>
|
||||
<string name="auth_email_for_resend">Email for resend</string>
|
||||
<string name="auth_resend_verification_link">Resend verification link</string>
|
||||
<string name="auth_back_to_login">Back to login</string>
|
||||
<string name="auth_password_reset_title">Password reset</string>
|
||||
<string name="auth_send_reset_link">Send reset link</string>
|
||||
<string name="auth_new_password">New password</string>
|
||||
<string name="auth_reset_with_token">Reset with token</string>
|
||||
<string name="auth_error_enter_email">Enter email.</string>
|
||||
<string name="auth_info_email_not_registered">This email is not registered. Complete sign up.</string>
|
||||
<string name="auth_error_register_fields_required">Name, username and password are required.</string>
|
||||
<string name="auth_info_account_created">Account created. Use password to sign in.</string>
|
||||
<string name="auth_error_password_required">Password is required.</string>
|
||||
<string name="auth_info_enter_2fa_or_recovery">Enter 2FA code or recovery code.</string>
|
||||
<string name="auth_error_enter_2fa_code">Enter 2FA code.</string>
|
||||
<string name="auth_error_enter_recovery_code">Enter recovery code.</string>
|
||||
<string name="auth_error_invalid_credentials">Invalid email or password.</string>
|
||||
<string name="auth_error_network">Network error. Check your connection.</string>
|
||||
<string name="auth_error_session_expired">Session expired. Please sign in again.</string>
|
||||
<string name="auth_error_server">Server error. Please try again.</string>
|
||||
<string name="auth_error_unknown">Unknown error. Please try again.</string>
|
||||
|
||||
<string name="contacts_title">Contacts</string>
|
||||
<string name="contacts_search_label">Search contacts/users</string>
|
||||
<string name="contacts_add_by_email_label">Add by email</string>
|
||||
<string name="contacts_search_results">Search results</string>
|
||||
<string name="contacts_my_contacts">My contacts</string>
|
||||
<string name="contacts_last_seen_recently">last seen recently</string>
|
||||
<string name="contacts_remove">Remove</string>
|
||||
<string name="contacts_empty">No contacts yet.</string>
|
||||
<string name="contacts_info_added">Contact added.</string>
|
||||
<string name="contacts_info_added_by_email">Contact added by email.</string>
|
||||
<string name="contacts_info_removed">Contact removed.</string>
|
||||
<string name="contacts_error_email_required">Email is required.</string>
|
||||
<string name="error_network">Network error.</string>
|
||||
<string name="error_session_expired">Session expired.</string>
|
||||
<string name="error_authorization">Authorization error.</string>
|
||||
<string name="error_server">Server error.</string>
|
||||
<string name="error_unknown">Unknown error.</string>
|
||||
<string name="account_user_fallback">User #%1$d</string>
|
||||
<string name="account_error_email_password_required">Email and password are required.</string>
|
||||
<string name="account_info_added">Account added.</string>
|
||||
<string name="account_error_no_saved_session">No saved session for this account.</string>
|
||||
<string name="account_error_switch_sync_failed">Account switched, but chats sync failed. Pull to refresh.</string>
|
||||
<string name="account_info_profile_updated">Profile updated.</string>
|
||||
<string name="account_info_avatar_uploaded">Avatar uploaded.</string>
|
||||
<string name="account_info_privacy_updated">Privacy settings updated.</string>
|
||||
<string name="account_info_2fa_secret_generated">2FA secret generated. Enter code to enable.</string>
|
||||
<string name="account_info_recovery_codes_regenerated">Recovery codes regenerated.</string>
|
||||
<string name="account_error_invalid_credentials">Invalid credentials.</string>
|
||||
<string name="account_error_unauthorized">Unauthorized.</string>
|
||||
</resources>
|
||||
|
||||
5
android/app/src/main/res/values/themes.xml
Normal file
5
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Messenger" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||
<integer name="google_play_services_version">12451000</integer>
|
||||
</resources>
|
||||
6
android/app/src/main/res/xml/file_paths.xml
Normal file
6
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path
|
||||
name="captures"
|
||||
path="captures/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,157 @@
|
||||
package ru.daemonlord.messenger.integration
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
|
||||
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import ru.daemonlord.messenger.domain.notifications.model.ChatNotificationOverride
|
||||
import ru.daemonlord.messenger.domain.notifications.model.NotificationSettings
|
||||
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
|
||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
|
||||
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
|
||||
import java.time.Instant
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RealtimePipelineIntegrationTest {
|
||||
|
||||
private lateinit var db: MessengerDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
MessengerDatabase::class.java,
|
||||
).allowMainThreadQueries().build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiveMessageEvent_updatesRoomState() = runTest {
|
||||
db.chatDao().upsertChats(
|
||||
listOf(
|
||||
ChatEntity(
|
||||
id = 77L,
|
||||
publicId = "chat-77",
|
||||
type = "private",
|
||||
title = null,
|
||||
displayTitle = "Integration chat",
|
||||
handle = null,
|
||||
avatarUrl = null,
|
||||
archived = false,
|
||||
pinned = false,
|
||||
muted = false,
|
||||
unreadCount = 0,
|
||||
unreadMentionsCount = 0,
|
||||
counterpartUserId = null,
|
||||
counterpartName = null,
|
||||
counterpartUsername = null,
|
||||
counterpartAvatarUrl = null,
|
||||
counterpartIsOnline = false,
|
||||
counterpartLastSeenAt = null,
|
||||
lastMessageText = null,
|
||||
lastMessageType = null,
|
||||
lastMessageCreatedAt = null,
|
||||
pinnedMessageId = null,
|
||||
myRole = "member",
|
||||
updatedSortAt = Instant.now().toString(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val realtimeManager = FakeRealtimeManager()
|
||||
val useCase = HandleRealtimeEventsUseCase(
|
||||
realtimeManager = realtimeManager,
|
||||
chatRepository = NoOpChatRepository(),
|
||||
chatDao = db.chatDao(),
|
||||
messageDao = db.messageDao(),
|
||||
notificationDispatcher = NotificationDispatcher(ApplicationProvider.getApplicationContext()),
|
||||
activeChatTracker = ActiveChatTracker(),
|
||||
shouldShowMessageNotificationUseCase = ShouldShowMessageNotificationUseCase(
|
||||
notificationSettingsRepository = AllowAllNotificationSettingsRepository(),
|
||||
),
|
||||
)
|
||||
|
||||
useCase.start()
|
||||
realtimeManager.emit(
|
||||
RealtimeEvent.ReceiveMessage(
|
||||
chatId = 77L,
|
||||
messageId = 9001L,
|
||||
senderId = 5L,
|
||||
replyToMessageId = null,
|
||||
text = "integration hello",
|
||||
type = "text",
|
||||
createdAt = Instant.now().toString(),
|
||||
isMention = false,
|
||||
)
|
||||
)
|
||||
|
||||
val chat = db.chatDao().observeChatById(77L).first()
|
||||
assertEquals(1, chat?.unreadCount)
|
||||
assertEquals("integration hello", chat?.lastMessageText)
|
||||
|
||||
useCase.stop()
|
||||
}
|
||||
|
||||
private class FakeRealtimeManager : RealtimeManager {
|
||||
private val stream = MutableSharedFlow<RealtimeEvent>(extraBufferCapacity = 8)
|
||||
override val events: Flow<RealtimeEvent> = stream
|
||||
override fun connect() = Unit
|
||||
override fun disconnect() = Unit
|
||||
fun emit(event: RealtimeEvent) {
|
||||
stream.tryEmit(event)
|
||||
}
|
||||
}
|
||||
|
||||
private class NoOpChatRepository : ChatRepository {
|
||||
override fun observeChats(archived: Boolean): Flow<List<ChatItem>> = kotlinx.coroutines.flow.flowOf(emptyList())
|
||||
override fun observeChat(chatId: Long): Flow<ChatItem?> = kotlinx.coroutines.flow.flowOf(null)
|
||||
override suspend fun refreshChats(archived: Boolean): AppResult<Unit> = AppResult.Success(Unit)
|
||||
override suspend fun refreshChat(chatId: Long): AppResult<Unit> = AppResult.Success(Unit)
|
||||
override suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> {
|
||||
return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null))
|
||||
}
|
||||
override suspend fun joinByInvite(token: String): AppResult<ChatItem> {
|
||||
return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null))
|
||||
}
|
||||
override suspend fun deleteChat(chatId: Long) = Unit
|
||||
}
|
||||
|
||||
private class AllowAllNotificationSettingsRepository : NotificationSettingsRepository {
|
||||
override fun observeSettings(): Flow<NotificationSettings> = kotlinx.coroutines.flow.flowOf(NotificationSettings())
|
||||
override suspend fun getSettings(): NotificationSettings = NotificationSettings()
|
||||
override suspend fun setGlobalEnabled(enabled: Boolean) = Unit
|
||||
override suspend fun setPreviewEnabled(enabled: Boolean) = Unit
|
||||
override fun observeChatOverride(chatId: Long): Flow<ChatNotificationOverride> {
|
||||
return kotlinx.coroutines.flow.flowOf(ChatNotificationOverride.DEFAULT)
|
||||
}
|
||||
override suspend fun getChatOverride(chatId: Long): ChatNotificationOverride = ChatNotificationOverride.DEFAULT
|
||||
override suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride) = Unit
|
||||
override suspend fun clearChatOverride(chatId: Long) = Unit
|
||||
override suspend fun clearChatOverrides() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ x-app-env: &app-env
|
||||
SMTP_USE_SSL: ${SMTP_USE_SSL:-false}
|
||||
SMTP_TIMEOUT_SECONDS: ${SMTP_TIMEOUT_SECONDS:-10}
|
||||
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-no-reply@benyamessenger.local}
|
||||
FIREBASE_ENABLED: ${FIREBASE_ENABLED:-true}
|
||||
FIREBASE_CREDENTIALS_PATH: ${FIREBASE_CREDENTIALS_PATH:-/run/secrets/firebase-service-account.json}
|
||||
FIREBASE_WEBPUSH_LINK: ${FIREBASE_WEBPUSH_LINK:-https://chat.daemonlord.ru/}
|
||||
LOGIN_RATE_LIMIT_PER_MINUTE: ${LOGIN_RATE_LIMIT_PER_MINUTE:-10}
|
||||
REGISTER_RATE_LIMIT_PER_MINUTE: ${REGISTER_RATE_LIMIT_PER_MINUTE:-5}
|
||||
RESET_RATE_LIMIT_PER_MINUTE: ${RESET_RATE_LIMIT_PER_MINUTE:-5}
|
||||
@@ -113,6 +116,8 @@ services:
|
||||
RUN_MIGRATIONS_ON_STARTUP: ${RUN_MIGRATIONS_ON_STARTUP:-true}
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ${FIREBASE_CREDENTIALS_HOST_PATH:-./secrets/firebase-service-account.json}:${FIREBASE_CREDENTIALS_PATH:-/run/secrets/firebase-service-account.json}:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready').read()\""]
|
||||
interval: 10s
|
||||
@@ -134,6 +139,8 @@ services:
|
||||
<<: *app-env
|
||||
AUTO_CREATE_TABLES: false
|
||||
RUN_MIGRATIONS_ON_STARTUP: false
|
||||
volumes:
|
||||
- ${FIREBASE_CREDENTIALS_HOST_PATH:-./secrets/firebase-service-account.json}:${FIREBASE_CREDENTIALS_PATH:-/run/secrets/firebase-service-account.json}:ro
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
|
||||
@@ -56,11 +56,17 @@
|
||||
- [x] Forward в 1+ чатов
|
||||
- [x] Reactions
|
||||
- [x] Delivery/read states
|
||||
- [x] Text formatting parity with web:
|
||||
- bold / italic / underline / strikethrough
|
||||
- spoiler / monospace / code block / links
|
||||
- composer toolbar behavior (mobile-first)
|
||||
|
||||
## 8. Медиа и вложения
|
||||
- [x] Upload image/video/file/audio
|
||||
- [x] Upload/send GIF and sticker attachments
|
||||
- [x] Галерея в сообщении (multi media)
|
||||
- [x] Media viewer (zoom/swipe/download)
|
||||
- [x] Fullscreen video viewer from chat bubbles
|
||||
- [x] Единое контекстное меню для медиа
|
||||
- [x] Voice playback waveform + speed
|
||||
- [x] Audio player UI (не как voice)
|
||||
@@ -81,6 +87,10 @@
|
||||
- [x] Admin actions: add/remove/ban/unban/promote/demote
|
||||
- [x] Ограничения канала: писать только owner/admin
|
||||
- [x] Member visibility rules (скрытие списков/действий)
|
||||
- [x] Channel chat visual pass (Telegram-like):
|
||||
- post-style bubbles in channel timeline,
|
||||
- read-only bottom bar for non-admin members (`Включить звук` style),
|
||||
- cleaner channel feed density and spacing.
|
||||
|
||||
## 11. Поиск
|
||||
- [x] Глобальный поиск: users/chats/messages
|
||||
@@ -94,6 +104,8 @@
|
||||
- [x] Deep links: open chat/message
|
||||
- [x] Mention override для muted чатов
|
||||
- [x] DataStore настройки уведомлений (global + per-chat override)
|
||||
- [x] Server notifications inbox in settings (`GET /notifications`)
|
||||
- [x] Push-token lifecycle sync (`POST/DELETE /notifications/push-token`) with dedupe per user/token
|
||||
|
||||
## 13. UI/UX и темы
|
||||
- [x] Светлая/темная тема (читаемая)
|
||||
@@ -101,6 +113,7 @@
|
||||
- [x] Контекстные меню без конфликтов жестов
|
||||
- [x] Bottom sheets/dialog behavior consistency
|
||||
- [x] Accessibility (TalkBack, dynamic type)
|
||||
- [x] Day separators in chat timeline (Сегодня/Вчера/дата)
|
||||
|
||||
## 14. Безопасность
|
||||
- [x] Secure token storage (EncryptedSharedPrefs/Keystore)
|
||||
|
||||
@@ -18,20 +18,21 @@ Backend покрывает web-функционал почти полность
|
||||
## 2) Web endpoints not yet fully used on Android
|
||||
|
||||
- `GET /api/v1/messages/{message_id}/thread` (data layer wired, UI thread screen/jump usage pending)
|
||||
- `GET /api/v1/notifications`
|
||||
- `POST /api/v1/notifications/push-token`
|
||||
- `DELETE /api/v1/notifications/push-token`
|
||||
- `POST /api/v1/auth/resend-verification`
|
||||
|
||||
## 2.1) Web feature parity gaps not yet covered on Android
|
||||
|
||||
- Notification delivery polish is still partial:
|
||||
- chat-level grouping/snooze parity with web prefs (full)
|
||||
- richer per-chat override UX alignment in Android settings
|
||||
|
||||
## 3) Practical status
|
||||
|
||||
- Backend readiness vs Web: `high`
|
||||
- Android parity vs Web (feature-level): `~82-87%`
|
||||
- Android parity vs Web (feature-level): `~87-91%`
|
||||
|
||||
## 4) Highest-priority Android parity step
|
||||
|
||||
Завершить следующий parity-блок:
|
||||
|
||||
- `GET /api/v1/messages/{message_id}/thread` (UI usage)
|
||||
- notifications API + UI inbox flow
|
||||
- notifications delivery polish (channels/grouping/snooze/per-chat overrides parity with web prefs)
|
||||
- notification delivery polish (channels/grouping/snooze/per-chat overrides parity with web prefs)
|
||||
|
||||
@@ -9,7 +9,7 @@ let foregroundListenerAttached = false;
|
||||
|
||||
export async function ensureWebPushRegistration(): Promise<void> {
|
||||
const config = getFirebaseConfig();
|
||||
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY?.trim();
|
||||
const vapidKey = normalizeVapidKey(import.meta.env.VITE_FIREBASE_VAPID_KEY);
|
||||
if (!config || !vapidKey) {
|
||||
return;
|
||||
}
|
||||
@@ -33,10 +33,21 @@ export async function ensureWebPushRegistration(): Promise<void> {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const app = getApps()[0] ?? initializeApp(config);
|
||||
const messaging = getMessaging(app);
|
||||
const token = await getToken(messaging, {
|
||||
vapidKey,
|
||||
serviceWorkerRegistration: registration,
|
||||
});
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = await getToken(messaging, {
|
||||
vapidKey,
|
||||
serviceWorkerRegistration: registration,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "InvalidAccessError") {
|
||||
console.error(
|
||||
"[web-push] Invalid VAPID key format. Check VITE_FIREBASE_VAPID_KEY in web env.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
@@ -107,3 +118,34 @@ function getFirebaseConfig():
|
||||
appId,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVapidKey(raw: string | undefined): string | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
let key = raw.trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Accept accidental JSON payloads copied from docs/panels.
|
||||
if (key.startsWith("{") && key.endsWith("}")) {
|
||||
try {
|
||||
const parsed = JSON.parse(key) as { vapidKey?: string; publicKey?: string; key?: string };
|
||||
key = (parsed.vapidKey ?? parsed.publicKey ?? parsed.key ?? "").trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Strip wrapping quotes and whitespace/newlines.
|
||||
key = key.replace(/^['"]|['"]$/g, "").replace(/\s+/g, "");
|
||||
|
||||
// Convert classic base64 chars to URL-safe format expected by PushManager/Firebase.
|
||||
key = key.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
|
||||
if (!/^[A-Za-z0-9\-_]+$/.test(key)) {
|
||||
return null;
|
||||
}
|
||||
return key.length >= 80 ? key : null;
|
||||
}
|
||||
|
||||
@@ -26,15 +26,34 @@ export async function showNotificationViaServiceWorker(params: {
|
||||
if (!registration) {
|
||||
return false;
|
||||
}
|
||||
const tag = `chat-${params.chatId}`;
|
||||
const active = await registration.getNotifications({ tag });
|
||||
const prevData = active[0]?.data as
|
||||
| {
|
||||
unreadCount?: number;
|
||||
previews?: string[];
|
||||
}
|
||||
| undefined;
|
||||
const unreadCount = Math.max(0, Number(prevData?.unreadCount ?? 0)) + 1;
|
||||
const previews = [params.body, ...(prevData?.previews ?? [])]
|
||||
.filter((item) => item && item.trim().length > 0)
|
||||
.slice(0, 3);
|
||||
const extraCount = Math.max(0, unreadCount - previews.length);
|
||||
const groupedBody =
|
||||
previews.length <= 1
|
||||
? previews[0] ?? params.body
|
||||
: `${previews.join("\n")}${extraCount > 0 ? `\n+${extraCount} more` : ""}`;
|
||||
const url = `/?chat=${params.chatId}&message=${params.messageId}`;
|
||||
await registration.showNotification(params.title, {
|
||||
body: params.body,
|
||||
body: groupedBody,
|
||||
icon: params.image,
|
||||
tag: `chat-${params.chatId}`,
|
||||
tag,
|
||||
data: {
|
||||
chatId: params.chatId,
|
||||
messageId: params.messageId,
|
||||
url,
|
||||
unreadCount,
|
||||
previews,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/webnotifications.ts","./src/utils/ws.ts"],"version":"5.9.2"}
|
||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/avatarcropmodal.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/mediaviewer.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/components/settingspanel.tsx","./src/components/toastviewport.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/audioplayerstore.ts","./src/store/authstore.ts","./src/store/chatstore.ts","./src/store/uistore.ts","./src/utils/firebasepush.ts","./src/utils/format.ts","./src/utils/formatmessage.tsx","./src/utils/preferences.ts","./src/utils/webnotifications.ts","./src/utils/ws.ts"],"version":"5.9.2"}
|
||||
Reference in New Issue
Block a user