From 5892faaf2a9986cd53d9d02057114b4fe1873046 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 10 Mar 2026 14:54:26 +0900 Subject: [PATCH 1/4] add draft box logic --- .../flare/ui/screen/compose/ComposeScreen.kt | 91 +- .../flare/ui/screen/compose/ComposeDialog.kt | 105 ++- iosApp/flare/UI/Screen/ComposeScreen.swift | 33 +- .../6.json | 453 ++++++++++ .../flare/common/FileItem.android.kt | 62 +- .../data/io/AndroidPlatformPathProducer.kt | 22 + .../data/io/PlatformPathProducer.android.kt | 12 - .../repository/DraftMediaStore.android.kt | 15 + .../flare/di/PlatformModule.android.kt | 3 +- ....apple.kt => ApplePlatformPathProducer.kt} | 9 +- .../data/repository/DraftMediaStore.apple.kt | 21 + .../flare/di/PlatformModule.apple.kt | 3 +- .../dimension/flare/TestFileHelper.apple.kt | 36 + .../flare/data/database/app/AppDatabase.kt | 13 +- .../flare/data/database/app/dao/DraftDao.kt | 167 ++++ .../flare/data/database/app/model/DbDraft.kt | 186 ++++ .../datasource/bluesky/BlueskyDataSource.kt | 8 +- .../datasource/mastodon/MastodonDataSource.kt | 6 +- .../AuthenticatedMicroblogDataSource.kt | 2 +- .../data/datasource/microblog/ComposeData.kt | 2 - .../datasource/microblog/ComposeProgress.kt | 9 - .../datasource/misskey/MisskeyDataSource.kt | 6 +- .../data/datasource/vvo/VVODataSource.kt | 6 +- .../data/datasource/xqt/XQTDataSource.kt | 6 +- .../flare/data/io/PlatformPathProducer.kt | 7 +- .../data/repository/AccountRepository.kt | 8 + .../flare/data/repository/DraftMediaStore.kt | 112 +++ .../flare/data/repository/DraftRepository.kt | 268 ++++++ .../dev/dimension/flare/di/CommonModule.kt | 14 + .../ui/presenter/compose/ComposePresenter.kt | 23 +- .../ui/presenter/compose/ComposeUseCase.kt | 37 +- .../ui/presenter/compose/SaveDraftUseCase.kt | 62 ++ .../ui/presenter/compose/SendDraftUseCase.kt | 215 +++++ .../dev/dimension/flare/TestFileHelper.kt | 16 + .../data/repository/DraftMediaStoreTest.kt | 365 ++++++++ .../data/repository/DraftRepositoryTest.kt | 215 +++++ .../presenter/compose/SaveDraftUseCaseTest.kt | 550 ++++++++++++ .../presenter/compose/SendDraftUseCaseTest.kt | 834 ++++++++++++++++++ .../dimension/flare/common/FileItem.jvm.kt | 24 +- .../flare/data/io/JvmPlatformPathProducer.kt | 20 + .../flare/data/io/PlatformPathProducer.jvm.kt | 9 - .../data/repository/DraftMediaStore.jvm.kt | 16 + .../dimension/flare/di/PlatformModule.jvm.kt | 3 +- .../dev/dimension/flare/TestFileHelper.jvm.kt | 27 + 44 files changed, 3879 insertions(+), 222 deletions(-) create mode 100644 shared/schemas/dev.dimension.flare.data.database.app.AppDatabase/6.json create mode 100644 shared/src/androidMain/kotlin/dev/dimension/flare/data/io/AndroidPlatformPathProducer.kt delete mode 100644 shared/src/androidMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.android.kt create mode 100644 shared/src/androidMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.android.kt rename shared/src/appleMain/kotlin/dev/dimension/flare/data/io/{PlatformPathProducer.apple.kt => ApplePlatformPathProducer.kt} (70%) create mode 100644 shared/src/appleMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.apple.kt create mode 100644 shared/src/appleTest/kotlin/dev/dimension/flare/TestFileHelper.apple.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/dao/DraftDao.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/model/DbDraft.kt delete mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeProgress.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/DraftRepository.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SaveDraftUseCase.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCase.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/TestFileHelper.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftMediaStoreTest.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftRepositoryTest.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/SaveDraftUseCaseTest.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCaseTest.kt create mode 100644 shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/JvmPlatformPathProducer.kt delete mode 100644 shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.jvm.kt create mode 100644 shared/src/jvmMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.jvm.kt create mode 100644 shared/src/jvmTest/kotlin/dev/dimension/flare/TestFileHelper.jvm.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt index aa5ec5c46..05b53b697 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt @@ -1043,53 +1043,50 @@ private fun composePresenter( } fun send() { - state.selectedAccounts.forEach { - val data = - ComposeData( - content = textFieldState.text.toString(), - medias = - mediaState.takeSuccess()?.medias.orEmpty().map { - ComposeData.Media( - file = FileItem(context, it.uri), - altText = - it.textState.text - .toString() - .takeIf { it.isNotEmpty() }, - ) - }, - poll = - pollState.takeSuccess()?.takeIf { it.enabled }?.let { - ComposeData.Poll( - multiple = !it.pollSingleChoice, - expiredAfter = it.expiredAt.duration.inWholeMilliseconds, - options = - it.options.map { option -> - option.text.toString() - }, - ) - }, - sensitive = mediaState.takeSuccess()?.isMediaSensitive ?: false, - spoilerText = - contentWarningState - .takeSuccess() - ?.textFieldState - ?.text - ?.toString(), - visibility = - state.visibilityState.takeSuccess()?.visibility - ?: UiTimelineV2.Post.Visibility.Public, - account = it, - referenceStatus = - status?.let { - ComposeData.ReferenceStatus( - data = state.replyState?.takeSuccess(), - composeStatus = status, - ) - }, - language = languageState.takeSuccess()?.selectedLanguage.orEmpty(), - ) - state.send(data) - } + val data = + ComposeData( + content = textFieldState.text.toString(), + medias = + mediaState.takeSuccess()?.medias.orEmpty().map { + ComposeData.Media( + file = FileItem(context, it.uri), + altText = + it.textState.text + .toString() + .takeIf { it.isNotEmpty() }, + ) + }, + poll = + pollState.takeSuccess()?.takeIf { it.enabled }?.let { + ComposeData.Poll( + multiple = !it.pollSingleChoice, + expiredAfter = it.expiredAt.duration.inWholeMilliseconds, + options = + it.options.map { option -> + option.text.toString() + }, + ) + }, + sensitive = mediaState.takeSuccess()?.isMediaSensitive ?: false, + spoilerText = + contentWarningState + .takeSuccess() + ?.textFieldState + ?.text + ?.toString(), + visibility = + state.visibilityState.takeSuccess()?.visibility + ?: UiTimelineV2.Post.Visibility.Public, + referenceStatus = + status?.let { + ComposeData.ReferenceStatus( + data = state.replyState?.takeSuccess(), + composeStatus = status, + ) + }, + language = languageState.takeSuccess()?.selectedLanguage.orEmpty(), + ) + state.send(data, state.draftGroupId) } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt index 2df8b9fc9..32648145d 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt @@ -989,62 +989,59 @@ private fun composePresenter( } fun send() { - state.selectedAccounts.forEach { - val data = - ComposeData( - content = textFieldState.text.toString(), - medias = - mediaState.takeSuccess()?.medias.orEmpty().map { - ComposeData.Media( - file = FileItem(it.file), - altText = - it.textState.text - .toString() - .takeIf { it.isNotEmpty() }, - ) - }, - poll = - pollState.takeSuccess()?.takeIf { it.enabled }?.let { - ComposeData.Poll( - multiple = !it.pollSingleChoice, - expiredAfter = it.expiredAt.duration.inWholeMilliseconds, - options = - it.options.map { option -> - option.text.toString() - }, - ) - }, - sensitive = mediaState.takeSuccess()?.isMediaSensitive ?: false, - spoilerText = - contentWarningState - .takeSuccess() - ?.textFieldState - ?.text - ?.toString(), - visibility = - state.visibilityState.takeSuccess()?.visibility - ?: UiTimelineV2.Post.Visibility.Public, - account = it, - referenceStatus = - status?.let { - ComposeData.ReferenceStatus( - data = state.replyState?.takeSuccess(), - composeStatus = status, - ) - }, - language = languageState.takeSuccess()?.selectedLanguage.orEmpty(), - ) - state.send(data) - // cleanup + val data = + ComposeData( + content = textFieldState.text.toString(), + medias = + mediaState.takeSuccess()?.medias.orEmpty().map { + ComposeData.Media( + file = FileItem(it.file), + altText = + it.textState.text + .toString() + .takeIf { it.isNotEmpty() }, + ) + }, + poll = + pollState.takeSuccess()?.takeIf { it.enabled }?.let { + ComposeData.Poll( + multiple = !it.pollSingleChoice, + expiredAfter = it.expiredAt.duration.inWholeMilliseconds, + options = + it.options.map { option -> + option.text.toString() + }, + ) + }, + sensitive = mediaState.takeSuccess()?.isMediaSensitive ?: false, + spoilerText = + contentWarningState + .takeSuccess() + ?.textFieldState + ?.text + ?.toString(), + visibility = + state.visibilityState.takeSuccess()?.visibility + ?: UiTimelineV2.Post.Visibility.Public, + referenceStatus = + status?.let { + ComposeData.ReferenceStatus( + data = state.replyState?.takeSuccess(), + composeStatus = status, + ) + }, + language = languageState.takeSuccess()?.selectedLanguage.orEmpty(), + ) + state.send(data, state.draftGroupId) + // cleanup - textFieldState.edit { - this.delete(0, this.length) - } - mediaState.takeSuccess()?.clear() - pollState.takeSuccess()?.clear() - contentWarningState.takeSuccess()?.clear() - state.visibilityState.takeSuccess()?.clear() + textFieldState.edit { + this.delete(0, this.length) } + mediaState.takeSuccess()?.clear() + pollState.takeSuccess()?.clear() + contentWarningState.takeSuccess()?.clear() + state.visibilityState.takeSuccess()?.clear() } } } diff --git a/iosApp/flare/UI/Screen/ComposeScreen.swift b/iosApp/flare/UI/Screen/ComposeScreen.swift index e6f44935d..5bd7855eb 100644 --- a/iosApp/flare/UI/Screen/ComposeScreen.swift +++ b/iosApp/flare/UI/Screen/ComposeScreen.swift @@ -437,28 +437,23 @@ struct ComposeScreen: View { textView.scrollRangeToVisible(NSRange(location: max(0, newLocation - 1), length: 1)) } - private func getComposeData() -> [ComposeData] { - return presenter.state.selectedAccounts.map { account in - ComposeData( - account: account, - content: viewModel.text, - visibility: getVisibility(), - language: viewModel.languages, - medias: getMedia(), - sensitive: viewModel.mediaViewModel.sensitive, - spoilerText: viewModel.contentWarning, - poll: getPoll(), - localOnly: false, - referenceStatus: getReferenceStatus() - ) - } + private func getComposeData() -> ComposeData { + ComposeData( + content: viewModel.text, + visibility: getVisibility(), + language: viewModel.languages, + medias: getMedia(), + sensitive: viewModel.mediaViewModel.sensitive, + spoilerText: viewModel.contentWarning, + poll: getPoll(), + localOnly: false, + referenceStatus: getReferenceStatus() + ) } private func send() { - let datas = getComposeData() - for data in datas { - presenter.state.send(data: data) - } + let data = getComposeData() + presenter.state.send(data: data, groupId: presenter.state.draftGroupId) dismiss() } diff --git a/shared/schemas/dev.dimension.flare.data.database.app.AppDatabase/6.json b/shared/schemas/dev.dimension.flare.data.database.app.AppDatabase/6.json new file mode 100644 index 000000000..c010c9077 --- /dev/null +++ b/shared/schemas/dev.dimension.flare.data.database.app.AppDatabase/6.json @@ -0,0 +1,453 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "0e07df702518b252811d0f97101f9699", + "entities": [ + { + "tableName": "DbAccount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_key` TEXT NOT NULL, `credential_json` TEXT NOT NULL, `platform_type` TEXT NOT NULL, `last_active` INTEGER NOT NULL, PRIMARY KEY(`account_key`))", + "fields": [ + { + "fieldPath": "account_key", + "columnName": "account_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credential_json", + "columnName": "credential_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform_type", + "columnName": "platform_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "last_active", + "columnName": "last_active", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_key" + ] + } + }, + { + "tableName": "DbApplication", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `credential_json` TEXT NOT NULL, `platform_type` TEXT NOT NULL, `has_pending_oauth_request` INTEGER NOT NULL, PRIMARY KEY(`host`))", + "fields": [ + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "credential_json", + "columnName": "credential_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "platform_type", + "columnName": "platform_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "has_pending_oauth_request", + "columnName": "has_pending_oauth_request", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "host" + ] + } + }, + { + "tableName": "DbDraftGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` TEXT NOT NULL, `content` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`group_id`))", + "fields": [ + { + "fieldPath": "group_id", + "columnName": "group_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created_at", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updated_at", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "group_id" + ] + }, + "indices": [ + { + "name": "index_DbDraftGroup_updated_at", + "unique": false, + "columnNames": [ + "updated_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbDraftGroup_updated_at` ON `${TABLE_NAME}` (`updated_at`)" + } + ] + }, + { + "tableName": "DbDraftTarget", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` TEXT NOT NULL, `account_key` TEXT NOT NULL, `status` TEXT NOT NULL, `error_message` TEXT, `attempt_count` INTEGER NOT NULL, `last_attempt_at` INTEGER, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `target_id` TEXT NOT NULL, PRIMARY KEY(`target_id`), FOREIGN KEY(`group_id`) REFERENCES `DbDraftGroup`(`group_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "group_id", + "columnName": "group_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "account_key", + "columnName": "account_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error_message", + "columnName": "error_message", + "affinity": "TEXT" + }, + { + "fieldPath": "attempt_count", + "columnName": "attempt_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "last_attempt_at", + "columnName": "last_attempt_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "created_at", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updated_at", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "target_id", + "columnName": "target_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "target_id" + ] + }, + "indices": [ + { + "name": "index_DbDraftTarget_group_id", + "unique": false, + "columnNames": [ + "group_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbDraftTarget_group_id` ON `${TABLE_NAME}` (`group_id`)" + }, + { + "name": "index_DbDraftTarget_account_key", + "unique": false, + "columnNames": [ + "account_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbDraftTarget_account_key` ON `${TABLE_NAME}` (`account_key`)" + }, + { + "name": "index_DbDraftTarget_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbDraftTarget_status` ON `${TABLE_NAME}` (`status`)" + }, + { + "name": "index_DbDraftTarget_group_id_account_key", + "unique": true, + "columnNames": [ + "group_id", + "account_key" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DbDraftTarget_group_id_account_key` ON `${TABLE_NAME}` (`group_id`, `account_key`)" + } + ], + "foreignKeys": [ + { + "table": "DbDraftGroup", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group_id" + ], + "referencedColumns": [ + "group_id" + ] + } + ] + }, + { + "tableName": "DbDraftMedia", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` TEXT NOT NULL, `cache_path` TEXT NOT NULL, `file_name` TEXT, `media_type` TEXT NOT NULL, `alt_text` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `media_id` TEXT NOT NULL, PRIMARY KEY(`media_id`), FOREIGN KEY(`group_id`) REFERENCES `DbDraftGroup`(`group_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "group_id", + "columnName": "group_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cache_path", + "columnName": "cache_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "file_name", + "columnName": "file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "media_type", + "columnName": "media_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alt_text", + "columnName": "alt_text", + "affinity": "TEXT" + }, + { + "fieldPath": "sort_order", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created_at", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media_id", + "columnName": "media_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "media_id" + ] + }, + "indices": [ + { + "name": "index_DbDraftMedia_group_id", + "unique": false, + "columnNames": [ + "group_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbDraftMedia_group_id` ON `${TABLE_NAME}` (`group_id`)" + }, + { + "name": "index_DbDraftMedia_group_id_sort_order", + "unique": false, + "columnNames": [ + "group_id", + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DbDraftMedia_group_id_sort_order` ON `${TABLE_NAME}` (`group_id`, `sort_order`)" + } + ], + "foreignKeys": [ + { + "table": "DbDraftGroup", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group_id" + ], + "referencedColumns": [ + "group_id" + ] + } + ] + }, + { + "tableName": "DbKeywordFilter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`keyword` TEXT NOT NULL, `for_timeline` INTEGER NOT NULL, `for_notification` INTEGER NOT NULL, `for_search` INTEGER NOT NULL, `expired_at` INTEGER NOT NULL, PRIMARY KEY(`keyword`))", + "fields": [ + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "for_timeline", + "columnName": "for_timeline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "for_notification", + "columnName": "for_notification", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "for_search", + "columnName": "for_search", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expired_at", + "columnName": "expired_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "keyword" + ] + } + }, + { + "tableName": "DbSearchHistory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`search` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`search`))", + "fields": [ + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created_at", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "search" + ] + } + }, + { + "tableName": "DbRssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `title` TEXT, `icon` TEXT, `openInBrowser` INTEGER NOT NULL DEFAULT 0, `lastUpdate` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "openInBrowser", + "columnName": "openInBrowser", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastUpdate", + "columnName": "lastUpdate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0e07df702518b252811d0f97101f9699')" + ] + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/common/FileItem.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/common/FileItem.android.kt index 11b13c77c..8b9e9d200 100644 --- a/shared/src/androidMain/kotlin/dev/dimension/flare/common/FileItem.android.kt +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/common/FileItem.android.kt @@ -2,20 +2,59 @@ package dev.dimension.flare.common import android.content.Context import android.net.Uri +import java.io.File -public actual class FileItem( - private val context: Context, - private val uri: Uri, -) { - internal actual val name: String? = uri.lastPathSegment +public actual class FileItem { + private val source: Source + internal actual val name: String? + internal actual val type: FileType - internal actual suspend fun readBytes(): ByteArray = - context.contentResolver.openInputStream(uri)?.use { - it.readBytes() - } ?: throw IllegalStateException("Cannot read file: $uri") + public constructor( + context: Context, + uri: Uri, + ) { + this.name = uri.lastPathSegment + this.type = resolveType(context = context, uri = uri) + this.source = Source.UriSource(context, uri) + } - internal actual val type: FileType - get() { + internal constructor( + name: String?, + type: FileType, + source: Source, + ) { + this.name = name + this.type = type + this.source = source + } + + internal actual suspend fun readBytes(): ByteArray = source.readBytes() + + internal sealed interface Source { + suspend fun readBytes(): ByteArray + + data class UriSource( + private val context: Context, + private val uri: Uri, + ) : Source { + override suspend fun readBytes(): ByteArray = + context.contentResolver.openInputStream(uri)?.use { + it.readBytes() + } ?: throw IllegalStateException("Cannot read file: $uri") + } + + data class PathSource( + private val path: String, + ) : Source { + override suspend fun readBytes(): ByteArray = File(path).readBytes() + } + } + + private companion object { + fun resolveType( + context: Context, + uri: Uri, + ): FileType { val mimeType = context.contentResolver.getType(uri) return when { mimeType?.startsWith("image/") == true -> FileType.Image @@ -34,4 +73,5 @@ public actual class FileItem( } } } + } } diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/data/io/AndroidPlatformPathProducer.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/data/io/AndroidPlatformPathProducer.kt new file mode 100644 index 000000000..d5905cfd6 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/data/io/AndroidPlatformPathProducer.kt @@ -0,0 +1,22 @@ +package dev.dimension.flare.data.io + +import android.content.Context +import androidx.datastore.dataStoreFile +import okio.Path +import okio.Path.Companion.toOkioPath + +internal class AndroidPlatformPathProducer( + private val context: Context, +) : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = context.dataStoreFile(fileName).toOkioPath() + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = + context + .dataStoreFile("draft_media") + .toOkioPath() + .resolve(groupId) + .resolve(fileName) +} diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.android.kt deleted file mode 100644 index 9ff6ea15e..000000000 --- a/shared/src/androidMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.android.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.dimension.flare.data.io - -import android.content.Context -import androidx.datastore.dataStoreFile -import okio.Path -import okio.Path.Companion.toOkioPath - -public actual class PlatformPathProducer( - private val context: Context, -) { - public actual fun dataStoreFile(fileName: String): Path = context.dataStoreFile(fileName).toOkioPath() -} diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.android.kt new file mode 100644 index 000000000..5df81ab25 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.android.kt @@ -0,0 +1,15 @@ +package dev.dimension.flare.data.repository + +import dev.dimension.flare.common.FileItem +import dev.dimension.flare.common.FileType + +internal actual fun draftFileItem( + path: String, + name: String?, + type: FileType, +): FileItem = + FileItem( + name = name, + type = type, + source = FileItem.Source.PathSource(path), + ) diff --git a/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt b/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt index da0cb7929..7993f125e 100644 --- a/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt +++ b/shared/src/androidMain/kotlin/dev/dimension/flare/di/PlatformModule.android.kt @@ -3,6 +3,7 @@ package dev.dimension.flare.di import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.data.database.DriverFactory import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.io.AndroidPlatformPathProducer import dev.dimension.flare.data.io.PlatformPathProducer import dev.dimension.flare.data.network.rss.NativeWebScraper import dev.dimension.flare.shared.image.AndroidImageCompressor @@ -19,7 +20,7 @@ internal actual val platformModule: Module = singleOf(::AppDataStore) singleOf(::DriverFactory) singleOf(::NativeWebScraper) - singleOf(::PlatformPathProducer) + singleOf(::AndroidPlatformPathProducer) bind PlatformPathProducer::class singleOf(::AndroidFormatter) bind PlatformFormatter::class singleOf(::AndroidImageCompressor) bind ImageCompressor::class singleOf(::OnDeviceAI) diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/ApplePlatformPathProducer.kt similarity index 70% rename from shared/src/appleMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.apple.kt rename to shared/src/appleMain/kotlin/dev/dimension/flare/data/io/ApplePlatformPathProducer.kt index 0e3192b20..1dbb70125 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.apple.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/ApplePlatformPathProducer.kt @@ -8,8 +8,13 @@ import platform.Foundation.NSFileManager import platform.Foundation.NSURL import platform.Foundation.NSUserDomainMask -public actual class PlatformPathProducer { - public actual fun dataStoreFile(fileName: String): Path = "${fileDirectory()}/$fileName".toPath() +internal class ApplePlatformPathProducer : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = "${fileDirectory()}/$fileName".toPath() + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = "${fileDirectory()}/draft_media/$groupId/$fileName".toPath() @OptIn(ExperimentalForeignApi::class) private fun fileDirectory(): String { diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.apple.kt new file mode 100644 index 000000000..3e2836d6e --- /dev/null +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.apple.kt @@ -0,0 +1,21 @@ +package dev.dimension.flare.data.repository + +import dev.dimension.flare.common.FileItem +import dev.dimension.flare.common.FileType +import okio.FileSystem +import okio.Path.Companion.toPath +import okio.SYSTEM + +internal actual fun draftFileItem( + path: String, + name: String?, + type: FileType, +): FileItem { + val filePath = path.toPath() + val bytes = FileSystem.SYSTEM.read(filePath) { readByteArray() } + return FileItem( + name = name ?: filePath.name, + data = bytes, + type = type, + ) +} diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt index 1d24f28be..aaab818b5 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/di/PlatformModule.apple.kt @@ -3,6 +3,7 @@ package dev.dimension.flare.di import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.data.database.DriverFactory import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.io.ApplePlatformPathProducer import dev.dimension.flare.data.io.PlatformPathProducer import dev.dimension.flare.data.network.rss.NativeWebScraper import dev.dimension.flare.shared.image.ImageCompressor @@ -20,7 +21,7 @@ internal actual val platformModule: Module = module { singleOf(::AppDataStore) singleOf(::DriverFactory) - singleOf(::PlatformPathProducer) + singleOf(::ApplePlatformPathProducer) bind PlatformPathProducer::class singleOf(::NativeWebScraper) singleOf(::AppleFormatter) bind PlatformFormatter::class singleOf(::ApplePlatformTextRenderer) bind PlatformTextRendering::class diff --git a/shared/src/appleTest/kotlin/dev/dimension/flare/TestFileHelper.apple.kt b/shared/src/appleTest/kotlin/dev/dimension/flare/TestFileHelper.apple.kt new file mode 100644 index 000000000..7fcfaf14a --- /dev/null +++ b/shared/src/appleTest/kotlin/dev/dimension/flare/TestFileHelper.apple.kt @@ -0,0 +1,36 @@ +package dev.dimension.flare + +import dev.dimension.flare.common.FileItem +import dev.dimension.flare.common.FileType +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import platform.Foundation.NSTemporaryDirectory +import kotlin.uuid.Uuid + +internal actual fun createTestRootPath(): Path = "${NSTemporaryDirectory()}draft-media-store-test-${Uuid.random()}".toPath() + +internal actual fun deleteTestRootPath(path: Path) { + if (FileSystem.SYSTEM.exists(path)) { + FileSystem.SYSTEM.deleteRecursively(path) + } +} + +internal actual fun createTestFileItem( + root: Path, + name: String?, + bytes: ByteArray, + type: FileType, +): FileItem { + val path = root.resolve("source_${Uuid.random()}.bin") + FileSystem.SYSTEM.createDirectories(path.parent!!) + FileSystem.SYSTEM.write(path) { + write(bytes) + } + return FileItem( + name = name, + data = bytes, + type = type, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/AppDatabase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/AppDatabase.kt index 42f8a5e3f..a92ab01b3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/AppDatabase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/AppDatabase.kt @@ -8,6 +8,7 @@ import androidx.room.RoomDatabaseConstructor import androidx.room.TypeConverters import dev.dimension.flare.data.database.app.dao.AccountDao import dev.dimension.flare.data.database.app.dao.ApplicationDao +import dev.dimension.flare.data.database.app.dao.DraftDao import dev.dimension.flare.data.database.app.dao.KeywordFilterDao import dev.dimension.flare.data.database.app.dao.RssSourceDao import dev.dimension.flare.data.database.app.dao.SearchHistoryDao @@ -16,11 +17,14 @@ import dev.dimension.flare.data.database.app.dao.SearchHistoryDao entities = [ dev.dimension.flare.data.database.app.model.DbAccount::class, dev.dimension.flare.data.database.app.model.DbApplication::class, + dev.dimension.flare.data.database.app.model.DbDraftGroup::class, + dev.dimension.flare.data.database.app.model.DbDraftTarget::class, + dev.dimension.flare.data.database.app.model.DbDraftMedia::class, dev.dimension.flare.data.database.app.model.DbKeywordFilter::class, dev.dimension.flare.data.database.app.model.DbSearchHistory::class, dev.dimension.flare.data.database.app.model.DbRssSources::class, ], - version = 5, + version = 6, autoMigrations = [ AutoMigration( from = 3, @@ -30,12 +34,17 @@ import dev.dimension.flare.data.database.app.dao.SearchHistoryDao from = 4, to = 5, ), + AutoMigration( + from = 5, + to = 6, + ), ], exportSchema = true, ) @TypeConverters( dev.dimension.flare.data.database.adapter.MicroBlogKeyConverter::class, dev.dimension.flare.data.database.adapter.PlatformTypeConverter::class, + dev.dimension.flare.data.database.app.model.DraftConverters::class, ) @ConstructedBy(AppDatabaseConstructor::class) internal abstract class AppDatabase : RoomDatabase() { @@ -43,6 +52,8 @@ internal abstract class AppDatabase : RoomDatabase() { abstract fun applicationDao(): ApplicationDao + abstract fun draftDao(): DraftDao + abstract fun keywordFilterDao(): KeywordFilterDao abstract fun searchHistoryDao(): SearchHistoryDao diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/dao/DraftDao.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/dao/DraftDao.kt new file mode 100644 index 000000000..b501ae20e --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/dao/DraftDao.kt @@ -0,0 +1,167 @@ +package dev.dimension.flare.data.database.app.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import dev.dimension.flare.data.database.app.model.DbDraftGroup +import dev.dimension.flare.data.database.app.model.DbDraftGroupWithRelations +import dev.dimension.flare.data.database.app.model.DbDraftMedia +import dev.dimension.flare.data.database.app.model.DbDraftTarget +import dev.dimension.flare.data.database.app.model.DraftTargetStatus +import kotlinx.coroutines.flow.Flow + +@Dao +internal interface DraftDao { + @Upsert + suspend fun upsertGroup(group: DbDraftGroup) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTargets(targets: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMedias(medias: List) + + @Query("DELETE FROM DbDraftMedia WHERE group_id = :groupId") + suspend fun deleteMediasByGroup(groupId: String) + + @Query("DELETE FROM DbDraftTarget WHERE group_id = :groupId") + suspend fun deleteTargetsByGroup(groupId: String) + + @Query("DELETE FROM DbDraftTarget WHERE target_id = :targetId") + suspend fun deleteTarget(targetId: String) + + @Query("DELETE FROM DbDraftGroup WHERE group_id = :groupId") + suspend fun deleteGroup(groupId: String) + + @Query("SELECT COUNT(*) FROM DbDraftTarget WHERE group_id = :groupId") + suspend fun countTargets(groupId: String): Int + + @Transaction + @Query( + """ + SELECT * FROM DbDraftGroup + WHERE EXISTS ( + SELECT 1 FROM DbDraftTarget + WHERE DbDraftTarget.group_id = DbDraftGroup.group_id + AND DbDraftTarget.status IN ('DRAFT', 'FAILED') + ) + ORDER BY updated_at DESC + """, + ) + fun visibleDraftGroups(): Flow> + + @Transaction + @Query( + """ + SELECT * FROM DbDraftGroup + WHERE group_id = :groupId + LIMIT 1 + """, + ) + fun draftGroup(groupId: String): Flow + + @Query( + """ + SELECT * FROM DbDraftGroup + WHERE group_id = :groupId + LIMIT 1 + """, + ) + suspend fun getGroup(groupId: String): DbDraftGroup? + + @Transaction + @Query( + """ + SELECT * FROM DbDraftGroup + WHERE EXISTS ( + SELECT 1 FROM DbDraftTarget + WHERE DbDraftTarget.group_id = DbDraftGroup.group_id + AND DbDraftTarget.status = 'SENDING' + ) + ORDER BY updated_at DESC + """, + ) + fun sendingDraftGroups(): Flow> + + @Query( + """ + UPDATE DbDraftTarget + SET status = :status, + error_message = :errorMessage, + attempt_count = :attemptCount, + last_attempt_at = :lastAttemptAt, + updated_at = :updatedAt + WHERE target_id = :targetId + """, + ) + suspend fun updateTargetStatus( + targetId: String, + status: DraftTargetStatus, + errorMessage: String?, + attemptCount: Int, + lastAttemptAt: Long?, + updatedAt: Long, + ) + + @Query( + """ + UPDATE DbDraftGroup + SET updated_at = :updatedAt + WHERE group_id = :groupId + """, + ) + suspend fun touchGroup( + groupId: String, + updatedAt: Long, + ) + + @Query( + """ + SELECT COUNT(*) FROM DbDraftTarget + WHERE group_id = :groupId + AND status = :status + """, + ) + suspend fun countTargetsByStatus( + groupId: String, + status: DraftTargetStatus, + ): Int + + @Query( + """ + UPDATE DbDraftTarget + SET status = :toStatus, + error_message = :errorMessage, + updated_at = :updatedAt + WHERE status = :fromStatus + AND (last_attempt_at IS NULL OR last_attempt_at < :expiredBefore) + """, + ) + suspend fun resetSendingTargets( + fromStatus: DraftTargetStatus, + toStatus: DraftTargetStatus, + expiredBefore: Long, + errorMessage: String?, + updatedAt: Long, + ) + + @Query( + """ + UPDATE DbDraftGroup + SET updated_at = :updatedAt + WHERE group_id IN ( + SELECT DISTINCT group_id FROM DbDraftTarget + WHERE status = :fromStatus + AND (last_attempt_at IS NULL OR last_attempt_at < :expiredBefore) + ) + """, + ) + suspend fun touchExpiredSendingGroups( + fromStatus: DraftTargetStatus, + expiredBefore: Long, + updatedAt: Long, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/model/DbDraft.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/model/DbDraft.kt new file mode 100644 index 000000000..a90d39abf --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/app/model/DbDraft.kt @@ -0,0 +1,186 @@ +package dev.dimension.flare.data.database.app.model + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation +import androidx.room.TypeConverter +import dev.dimension.flare.common.decodeJson +import dev.dimension.flare.common.encodeJson +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.uuid.Uuid + +@Serializable +internal data class DraftContent( + val text: String, + val visibility: UiTimelineV2.Post.Visibility, + val language: List, + val sensitive: Boolean, + val spoilerText: String? = null, + val localOnly: Boolean = false, + val poll: DraftPoll? = null, + val reference: DraftReference? = null, +) { + @Serializable + data class DraftPoll( + val options: List, + val expiredAfter: Long, + val multiple: Boolean, + ) + + @Serializable + data class DraftReference( + val type: DraftReferenceType, + val statusKey: MicroBlogKey, + val rootId: String? = null, + ) +} + +@Serializable +internal enum class DraftReferenceType { + @SerialName("reply") + REPLY, + + @SerialName("quote") + QUOTE, + + @SerialName("vvo_comment") + VVO_COMMENT, +} + +@Serializable +internal enum class DraftTargetStatus { + @SerialName("draft") + DRAFT, + + @SerialName("sending") + SENDING, + + @SerialName("failed") + FAILED, +} + +@Serializable +internal enum class DraftMediaType { + @SerialName("image") + IMAGE, + + @SerialName("video") + VIDEO, + + @SerialName("other") + OTHER, +} + +@Entity( + indices = [ + Index(value = ["updated_at"]), + ], +) +@Serializable +internal data class DbDraftGroup( + @PrimaryKey + val group_id: String = Uuid.random().toString(), + val content: DraftContent, + val created_at: Long, + val updated_at: Long, +) + +@Entity( + foreignKeys = [ + ForeignKey( + entity = DbDraftGroup::class, + parentColumns = ["group_id"], + childColumns = ["group_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["group_id"]), + Index(value = ["account_key"]), + Index(value = ["status"]), + Index(value = ["group_id", "account_key"], unique = true), + ], +) +@Serializable +internal data class DbDraftTarget( + val group_id: String, + val account_key: MicroBlogKey, + val status: DraftTargetStatus, + val error_message: String? = null, + val attempt_count: Int = 0, + val last_attempt_at: Long? = null, + val created_at: Long, + val updated_at: Long, + @PrimaryKey + val target_id: String = "${group_id}_${account_key}", +) + +@Entity( + foreignKeys = [ + ForeignKey( + entity = DbDraftGroup::class, + parentColumns = ["group_id"], + childColumns = ["group_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["group_id"]), + Index(value = ["group_id", "sort_order"]), + ], +) +@Serializable +internal data class DbDraftMedia( + val group_id: String, + val cache_path: String, + val file_name: String? = null, + val media_type: DraftMediaType, + val alt_text: String? = null, + val sort_order: Int, + val created_at: Long, + @PrimaryKey + val media_id: String = Uuid.random().toString(), +) + +internal data class DbDraftGroupWithRelations( + @Embedded + val group: DbDraftGroup, + @Relation( + parentColumn = "group_id", + entityColumn = "group_id", + entity = DbDraftTarget::class, + ) + val targets: List, + @Relation( + parentColumn = "group_id", + entityColumn = "group_id", + entity = DbDraftMedia::class, + ) + val medias: List, +) + +internal class DraftConverters { + @TypeConverter + fun fromDraftContent(value: DraftContent): String = value.encodeJson() + + @TypeConverter + fun toDraftContent(value: String): DraftContent = value.decodeJson() + + @TypeConverter + fun fromDraftTargetStatus(value: DraftTargetStatus): String = value.name + + @TypeConverter + fun toDraftTargetStatus(value: String): DraftTargetStatus = DraftTargetStatus.valueOf(value) + + @TypeConverter + fun fromDraftMediaType(value: DraftMediaType): String = value.name + + @TypeConverter + fun toDraftMediaType(value: String): DraftMediaType = DraftMediaType.valueOf(value) +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt index 63717a25b..185e20d41 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt @@ -51,7 +51,6 @@ import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData -import dev.dimension.flare.data.datasource.microblog.ComposeProgress import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.DirectMessageDataSource @@ -279,7 +278,7 @@ internal class BlueskyDataSource( override suspend fun compose( data: ComposeData, - progress: (ComposeProgress) -> Unit, + progress: () -> Unit, ) { val quoteId = data.referenceStatus @@ -295,7 +294,6 @@ internal class BlueskyDataSource( it as? ComposeStatus.Reply }?.statusKey ?.id - val maxProgress = data.medias.size + 1 val mediaBlob = data.medias .mapIndexedNotNull { index, (item, altText) -> @@ -315,7 +313,7 @@ internal class BlueskyDataSource( service .uploadBlob(finalBytes) .also { - progress(ComposeProgress(index + 1, maxProgress)) + progress() }.maybeResponse() ?.let { it.blob to altText @@ -400,7 +398,7 @@ internal class BlueskyDataSource( service .createRecord( CreateRecordRequest( - repo = Did(did = data.account.accountKey.id), + repo = Did(did = accountKey.id), collection = Nsid("app.bsky.feed.post"), record = post.bskyJson(), ), diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt index ae5da7d58..d1c44ccb1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt @@ -5,7 +5,6 @@ import dev.dimension.flare.common.FileType import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData -import dev.dimension.flare.data.datasource.microblog.ComposeProgress import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter @@ -228,7 +227,7 @@ internal open class MastodonDataSource( override suspend fun compose( data: ComposeData, - progress: (ComposeProgress) -> Unit, + progress: () -> Unit, ) { val inReplyToID = data.referenceStatus @@ -244,7 +243,6 @@ internal open class MastodonDataSource( it as? ComposeStatus.Quote }?.statusKey ?.id - val maxProgress = data.medias.size + 1 val mediaIds = data.medias .mapIndexed { index, (file, altText) -> @@ -267,7 +265,7 @@ internal open class MastodonDataSource( name = file.name ?: "unknown", description = altText, ).also { - progress(ComposeProgress(index + 1, maxProgress)) + progress() } }.mapNotNull { it.id diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/AuthenticatedMicroblogDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/AuthenticatedMicroblogDataSource.kt index 6eebbbf10..0380352a8 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/AuthenticatedMicroblogDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/AuthenticatedMicroblogDataSource.kt @@ -13,7 +13,7 @@ internal interface AuthenticatedMicroblogDataSource : MicroblogDataSource { suspend fun compose( data: ComposeData, - progress: (ComposeProgress) -> Unit, + progress: () -> Unit, ) fun composeConfig(type: ComposeType): ComposeConfig diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeData.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeData.kt index c11a9e6cb..1412cffe0 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeData.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeData.kt @@ -1,12 +1,10 @@ package dev.dimension.flare.data.datasource.microblog import dev.dimension.flare.common.FileItem -import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.compose.ComposeStatus public data class ComposeData( - val account: UiAccount, val content: String, val visibility: UiTimelineV2.Post.Visibility = UiTimelineV2.Post.Visibility.Public, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeProgress.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeProgress.kt deleted file mode 100644 index 9f0319217..000000000 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeProgress.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.dimension.flare.data.datasource.microblog - -internal data class ComposeProgress( - val progress: Int, - val total: Int, -) { - val percent: Double - get() = progress.toDouble() / total.toDouble() -} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt index 56ababa9d..430b4d763 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt @@ -9,7 +9,6 @@ import dev.dimension.flare.common.FileType import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData -import dev.dimension.flare.data.datasource.microblog.ComposeProgress import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter @@ -403,7 +402,7 @@ internal class MisskeyDataSource( override suspend fun compose( data: ComposeData, - progress: (ComposeProgress) -> Unit, + progress: () -> Unit, ) { val renoteId = data.referenceStatus @@ -419,7 +418,6 @@ internal class MisskeyDataSource( it as? ComposeStatus.Reply }?.statusKey ?.id - val maxProgress = data.medias.size + 1 val mediaIds = data.medias .mapIndexed { index, (item, altText) -> @@ -443,7 +441,7 @@ internal class MisskeyDataSource( sensitive = data.sensitive, comment = altText, ).also { - progress(ComposeProgress(index + 1, maxProgress)) + progress() } }.mapNotNull { it?.id diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt index 8a7f28078..78a6395f6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt @@ -10,7 +10,6 @@ import dev.dimension.flare.common.decodeJson import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData -import dev.dimension.flare.data.datasource.microblog.ComposeProgress import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter @@ -292,15 +291,14 @@ internal class VVODataSource( override suspend fun compose( data: ComposeData, - progress: (ComposeProgress) -> Unit, + progress: () -> Unit, ) { - val maxProgress = data.medias.size + 1 val st = ensureLogin() val mediaIds = data.medias.mapIndexed { index, (it, _) -> uploadMedia(it, st).also { - progress(ComposeProgress(index + 1, maxProgress)) + progress() } } val mediaId = mediaIds.joinToString(",") diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt index a8168ef01..8c5c32776 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt @@ -18,7 +18,6 @@ import dev.dimension.flare.data.database.cache.model.MessageContent import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData -import dev.dimension.flare.data.datasource.microblog.ComposeProgress import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.DirectMessageDataSource @@ -363,7 +362,7 @@ internal class XQTDataSource( override suspend fun compose( data: ComposeData, - progress: (ComposeProgress) -> Unit, + progress: () -> Unit, ) { val inReplyToID = data.referenceStatus @@ -389,7 +388,6 @@ internal class XQTDataSource( }?.user ?.handle ?.normalizedRaw - val maxProgress = data.medias.size + 1 val mediaIds = data.medias.mapIndexed { index, (item, altText) -> val bytes = item.readBytes() @@ -432,7 +430,7 @@ internal class XQTDataSource( ), ) } - progress(ComposeProgress(index + 1, maxProgress)) + progress() } } service.postCreateTweet( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.kt index d9e92b773..9feb230ac 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.kt @@ -2,6 +2,11 @@ package dev.dimension.flare.data.io import okio.Path -public expect class PlatformPathProducer { +public interface PlatformPathProducer { public fun dataStoreFile(fileName: String): Path + + public fun draftMediaFile( + groupId: String, + fileName: String, + ): Path } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt index da8cdec34..62dc4c93a 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/AccountRepository.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -136,6 +137,13 @@ public class AccountRepository internal constructor( } } + internal suspend fun find(accountKey: MicroBlogKey): UiAccount? = + appDatabase + .accountDao() + .get(accountKey) + .firstOrNull() + ?.toUi() + internal inline fun credentialFlow(accountKey: MicroBlogKey): Flow = appDatabase .accountDao() diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.kt new file mode 100644 index 000000000..959952930 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.kt @@ -0,0 +1,112 @@ +package dev.dimension.flare.data.repository + +import dev.dimension.flare.common.FileItem +import dev.dimension.flare.common.FileType +import dev.dimension.flare.data.database.app.model.DraftMediaType +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.data.io.PlatformPathProducer +import okio.FileSystem +import okio.Path.Companion.toPath +import okio.SYSTEM +import kotlin.uuid.Uuid + +internal class DraftMediaStore( + private val platformPathProducer: PlatformPathProducer, + private val fileSystem: FileSystem = FileSystem.SYSTEM, +) { + suspend fun persist( + groupId: String, + medias: List, + ): List { + val persisted = + medias.mapIndexed { index, media -> + val fileName = + media.file.name + ?.sanitizeFileName() + .orEmpty() + .ifBlank { "${Uuid.random()}.bin" } + val path = platformPathProducer.draftMediaFile(groupId, "${index}_$fileName") + fileSystem.createDirectories(checkNotNull(path.parent)) + fileSystem.write(path) { + write(media.file.readBytes()) + } + SaveDraftMedia( + cachePath = path.toString(), + fileName = media.file.name, + mediaType = media.file.type.toDraftMediaType(), + altText = media.altText, + sortOrder = index, + ) + } + cleanupStaleFiles( + groupId = groupId, + keepPaths = persisted.map { it.cachePath.toPath() }.toSet(), + ) + return persisted + } + + suspend fun restore(medias: List): List = + medias.map { media -> + ComposeData.Media( + file = + draftFileItem( + path = media.cachePath, + name = media.fileName, + type = media.mediaType.toFileType(), + ), + altText = media.altText, + ) + } + + fun delete(medias: List) { + medias.forEach { media -> + val path = media.cachePath.toPath() + if (fileSystem.exists(path)) { + fileSystem.delete(path) + } + } + } + + private fun cleanupStaleFiles( + groupId: String, + keepPaths: Set, + ) { + val groupDirectory = + platformPathProducer + .draftMediaFile(groupId, "__placeholder__") + .parent ?: return + if (!fileSystem.exists(groupDirectory)) { + return + } + fileSystem + .list(groupDirectory) + .filterNot { keepPaths.contains(it) } + .forEach { stalePath -> + if (fileSystem.exists(stalePath)) { + fileSystem.delete(stalePath) + } + } + } +} + +internal expect fun draftFileItem( + path: String, + name: String?, + type: FileType, +): FileItem + +private fun FileType.toDraftMediaType(): DraftMediaType = + when (this) { + FileType.Image -> DraftMediaType.IMAGE + FileType.Video -> DraftMediaType.VIDEO + FileType.Other -> DraftMediaType.OTHER + } + +private fun DraftMediaType.toFileType(): FileType = + when (this) { + DraftMediaType.IMAGE -> FileType.Image + DraftMediaType.VIDEO -> FileType.Video + DraftMediaType.OTHER -> FileType.Other + } + +private fun String.sanitizeFileName(): String = replace(Regex("[^A-Za-z0-9._-]"), "_") diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/DraftRepository.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/DraftRepository.kt new file mode 100644 index 000000000..de12fe6fd --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/DraftRepository.kt @@ -0,0 +1,268 @@ +package dev.dimension.flare.data.repository + +import dev.dimension.flare.data.database.app.AppDatabase +import dev.dimension.flare.data.database.app.model.DbDraftGroup +import dev.dimension.flare.data.database.app.model.DbDraftGroupWithRelations +import dev.dimension.flare.data.database.app.model.DbDraftMedia +import dev.dimension.flare.data.database.app.model.DbDraftTarget +import dev.dimension.flare.data.database.app.model.DraftContent +import dev.dimension.flare.data.database.app.model.DraftMediaType +import dev.dimension.flare.data.database.app.model.DraftTargetStatus +import dev.dimension.flare.data.database.cache.connect +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiAccount +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.time.Clock +import kotlin.uuid.Uuid + +internal class DraftRepository( + private val database: AppDatabase, +) { + val visibleDrafts: Flow> = + database + .draftDao() + .visibleDraftGroups() + .map { drafts -> drafts.map { it.toModel() } } + + val sendingDrafts: Flow> = + database + .draftDao() + .sendingDraftGroups() + .map { drafts -> drafts.map { it.toModel() } } + + fun draft(groupId: String): Flow = + database + .draftDao() + .draftGroup(groupId) + .map { it?.toModel() } + + suspend fun saveDraft(input: SaveDraftInput): String { + val now = Clock.System.now().toEpochMilliseconds() + val groupId = input.groupId + val createdAt = input.createdAt ?: database.draftDao().getGroup(groupId)?.created_at ?: now + + database.connect { + database.draftDao().upsertGroup( + DbDraftGroup( + group_id = groupId, + content = input.content, + created_at = createdAt, + updated_at = now, + ), + ) + database.draftDao().deleteTargetsByGroup(groupId) + if (input.targets.isNotEmpty()) { + database.draftDao().insertTargets( + input.targets.map { target -> + DbDraftTarget( + group_id = groupId, + account_key = target.accountKey, + status = target.status, + error_message = target.errorMessage, + attempt_count = target.attemptCount, + last_attempt_at = target.lastAttemptAt, + created_at = target.createdAt ?: now, + updated_at = now, + ) + }, + ) + } + database.draftDao().deleteMediasByGroup(groupId) + if (input.medias.isNotEmpty()) { + database.draftDao().insertMedias( + input.medias.mapIndexed { index, media -> + DbDraftMedia( + group_id = groupId, + cache_path = media.cachePath, + file_name = media.fileName, + media_type = media.mediaType, + alt_text = media.altText, + sort_order = media.sortOrder ?: index, + created_at = media.createdAt ?: now, + ) + }, + ) + } + } + return groupId + } + + suspend fun updateTargetStatus( + groupId: String, + accountKey: MicroBlogKey, + status: DraftTargetStatus, + errorMessage: String? = null, + attemptCount: Int = 0, + lastAttemptAt: Long? = null, + ) { + val now = Clock.System.now().toEpochMilliseconds() + database.connect { + database.draftDao().updateTargetStatus( + targetId = targetId(groupId, accountKey), + status = status, + errorMessage = errorMessage, + attemptCount = attemptCount, + lastAttemptAt = lastAttemptAt, + updatedAt = now, + ) + database.draftDao().touchGroup(groupId = groupId, updatedAt = now) + } + } + + suspend fun deleteTarget( + groupId: String, + accountKey: MicroBlogKey, + ) { + database.connect { + database.draftDao().deleteTarget(targetId(groupId, accountKey)) + if (database.draftDao().countTargets(groupId) == 0) { + database.draftDao().deleteGroup(groupId) + } else { + database.draftDao().touchGroup( + groupId = groupId, + updatedAt = Clock.System.now().toEpochMilliseconds(), + ) + } + } + } + + suspend fun deleteGroup(groupId: String) { + database.draftDao().deleteGroup(groupId) + } + + suspend fun markSendingAsDraftIfExpired( + expiredBefore: Long, + errorMessage: String? = null, + ) { + val now = Clock.System.now().toEpochMilliseconds() + database.connect { + database.draftDao().resetSendingTargets( + fromStatus = DraftTargetStatus.SENDING, + toStatus = DraftTargetStatus.DRAFT, + expiredBefore = expiredBefore, + errorMessage = errorMessage, + updatedAt = now, + ) + database.draftDao().touchExpiredSendingGroups( + fromStatus = DraftTargetStatus.SENDING, + expiredBefore = expiredBefore, + updatedAt = now, + ) + } + } + + private fun targetId( + groupId: String, + accountKey: MicroBlogKey, + ) = "${groupId}_$accountKey" +} + +internal data class SaveDraftInput( + val groupId: String, + val content: DraftContent, + val targets: List, + val medias: List, + val createdAt: Long? = null, +) + +internal data class SaveDraftTarget( + val accountKey: MicroBlogKey, + val status: DraftTargetStatus = DraftTargetStatus.DRAFT, + val errorMessage: String? = null, + val attemptCount: Int = 0, + val lastAttemptAt: Long? = null, + val createdAt: Long? = null, +) + +internal data class SaveDraftMedia( + val cachePath: String, + val fileName: String? = null, + val mediaType: DraftMediaType, + val altText: String? = null, + val sortOrder: Int? = null, + val createdAt: Long? = null, +) + +internal data class DraftGroup( + val groupId: String, + val content: DraftContent, + val createdAt: Long, + val updatedAt: Long, + val targets: List, + val medias: List, +) + +internal data class DraftTarget( + val groupId: String, + val accountKey: MicroBlogKey, + val status: DraftTargetStatus, + val errorMessage: String?, + val attemptCount: Int, + val lastAttemptAt: Long?, + val createdAt: Long, + val updatedAt: Long, +) + +internal data class DraftMedia( + val mediaId: String, + val groupId: String, + val cachePath: String, + val fileName: String?, + val mediaType: DraftMediaType, + val altText: String?, + val sortOrder: Int, + val createdAt: Long, +) + +internal data class ComposeDraftBundle( + val accounts: List, + val template: ComposeData, + val groupId: String = newDraftGroupId(), +) + +internal fun ComposeData.toComposeDraftBundle( + accounts: List, + groupId: String = newDraftGroupId(), +): ComposeDraftBundle = ComposeDraftBundle(accounts = accounts, template = this, groupId = groupId) + +internal fun newDraftGroupId(): String = Uuid.random().toString() + +private fun DbDraftGroupWithRelations.toModel(): DraftGroup = + DraftGroup( + groupId = group.group_id, + content = group.content, + createdAt = group.created_at, + updatedAt = group.updated_at, + targets = + targets + .sortedBy { it.account_key.toString() } + .map { + DraftTarget( + groupId = it.group_id, + accountKey = it.account_key, + status = it.status, + errorMessage = it.error_message, + attemptCount = it.attempt_count, + lastAttemptAt = it.last_attempt_at, + createdAt = it.created_at, + updatedAt = it.updated_at, + ) + }, + medias = + medias + .sortedBy { it.sort_order } + .map { + DraftMedia( + mediaId = it.media_id, + groupId = it.group_id, + cachePath = it.cache_path, + fileName = it.file_name, + mediaType = it.media_type, + altText = it.alt_text, + sortOrder = it.sort_order, + createdAt = it.created_at, + ) + }, + ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt index 79b614b37..6c953eea3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt @@ -6,9 +6,13 @@ import dev.dimension.flare.data.network.ai.OpenAIService import dev.dimension.flare.data.network.rss.Readability import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.ApplicationRepository +import dev.dimension.flare.data.repository.DraftMediaStore +import dev.dimension.flare.data.repository.DraftRepository import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.SearchHistoryRepository import dev.dimension.flare.ui.presenter.compose.ComposeUseCase +import dev.dimension.flare.ui.presenter.compose.SaveDraftUseCase +import dev.dimension.flare.ui.presenter.compose.SendDraftUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -21,8 +25,18 @@ internal val commonModule = singleOf(::provideAppDatabase) singleOf(::provideCacheDatabase) singleOf(::ApplicationRepository) + singleOf(::DraftRepository) + singleOf(::DraftMediaStore) singleOf(::LocalFilterRepository) single { CoroutineScope(Dispatchers.IO) } + singleOf(::SaveDraftUseCase) + single { + SendDraftUseCase( + draftRepository = get(), + accountRepository = get(), + draftMediaStore = get(), + ) + } singleOf(::ComposeUseCase) singleOf(::SearchHistoryRepository) singleOf(::Readability) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt index 5dccd480c..89f73c8e2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt @@ -21,6 +21,7 @@ import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.accountProvider import dev.dimension.flare.data.repository.accountServiceProvider import dev.dimension.flare.data.repository.allAccountsPresenter +import dev.dimension.flare.data.repository.newDraftGroupId import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType @@ -262,6 +263,10 @@ public class ComposePresenter( var mediaSize by remember { mutableStateOf(0) } + val draftGroupId = + remember { + newDraftGroupId() + } val remainingLength = composeConfig .mapNotNull { @@ -288,6 +293,7 @@ public class ComposePresenter( return object : ComposeState( canSend = canSend, + draftGroupId = draftGroupId, visibilityState = visibilityState, replyState = replyState, emojiState = emojiState, @@ -298,8 +304,15 @@ public class ComposePresenter( otherAccounts = remainingAccounts, initialTextState = initialTextState, ) { - override fun send(data: ComposeData) { - composeUseCase.invoke(data) + override fun send( + data: ComposeData, + groupId: String, + ) { + composeUseCase.invoke( + accounts = selectedAccounts.toList(), + data = data, + groupId = groupId, + ) } override fun selectAccount(account: UiAccount) { @@ -406,6 +419,7 @@ public sealed class ComposeStatus { @Immutable public abstract class ComposeState( public val canSend: Boolean, + public val draftGroupId: String, public val visibilityState: UiState, public val replyState: UiState?, public val initialTextState: UiState?, @@ -416,7 +430,10 @@ public abstract class ComposeState( public val otherAccounts: UiState, UiAccount>>>, public val selectedUsers: UiState, UiAccount>>>, ) { - public abstract fun send(data: ComposeData) + public abstract fun send( + data: ComposeData, + groupId: String, + ) public abstract fun selectAccount(account: UiAccount) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt index baa64252e..751ad95d5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposeUseCase.kt @@ -5,7 +5,10 @@ import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.Message import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.repository.newDraftGroupId +import dev.dimension.flare.data.repository.toComposeDraftBundle import dev.dimension.flare.data.repository.tryRun +import dev.dimension.flare.ui.model.UiAccount import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -15,9 +18,15 @@ internal class ComposeUseCase( private val scope: CoroutineScope, private val inAppNotification: InAppNotification, private val appDataStore: AppDataStore, + private val saveDraftUseCase: SaveDraftUseCase, + private val sendDraftUseCase: SendDraftUseCase, ) { - operator fun invoke(data: ComposeData) { - invoke(data) { + operator fun invoke( + accounts: List, + data: ComposeData, + groupId: String, + ) { + invoke(accounts = accounts, data = data, groupId = groupId) { withContext(Dispatchers.Main) { when (it) { is ComposeProgressState.Error -> @@ -32,7 +41,9 @@ internal class ComposeUseCase( } operator fun invoke( + accounts: List, data: ComposeData, + groupId: String, progress: suspend (ComposeProgressState) -> Unit, ) { scope.launch { @@ -43,13 +54,9 @@ internal class ComposeUseCase( visibility = data.visibility, ) } - data.account.dataSource.compose( - data = data, - progress = { - scope.launch { - progress.invoke(ComposeProgressState.Progress(it.progress, it.total)) - } - }, + sendDraftUseCase( + bundle = data.toComposeDraftBundle(accounts = accounts, groupId = groupId), + progress = progress, ) }.onSuccess { scope.launch { @@ -62,6 +69,18 @@ internal class ComposeUseCase( } } } + + fun saveDraft( + accounts: List, + data: ComposeData, + groupId: String = newDraftGroupId(), + ) { + scope.launch { + tryRun { + saveDraftUseCase(data.toComposeDraftBundle(accounts = accounts, groupId = groupId)) + } + } + } } @Immutable diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SaveDraftUseCase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SaveDraftUseCase.kt new file mode 100644 index 000000000..373e0ca93 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SaveDraftUseCase.kt @@ -0,0 +1,62 @@ +package dev.dimension.flare.ui.presenter.compose + +import dev.dimension.flare.data.database.app.model.DraftContent +import dev.dimension.flare.data.database.app.model.DraftReferenceType +import dev.dimension.flare.data.repository.ComposeDraftBundle +import dev.dimension.flare.data.repository.DraftMediaStore +import dev.dimension.flare.data.repository.DraftRepository +import dev.dimension.flare.data.repository.SaveDraftInput +import dev.dimension.flare.data.repository.SaveDraftTarget + +internal class SaveDraftUseCase( + private val draftRepository: DraftRepository, + private val draftMediaStore: DraftMediaStore, +) { + suspend operator fun invoke(bundle: ComposeDraftBundle): String { + val persistedMedia = draftMediaStore.persist(groupId = bundle.groupId, medias = bundle.template.medias) + return draftRepository.saveDraft( + SaveDraftInput( + groupId = bundle.groupId, + content = bundle.template.toDraftContent(), + targets = + bundle.accounts.map { + SaveDraftTarget( + accountKey = it.accountKey, + ) + }, + medias = persistedMedia, + ), + ) + } +} + +internal fun dev.dimension.flare.data.datasource.microblog.ComposeData.toDraftContent(): DraftContent = + DraftContent( + text = content, + visibility = visibility, + language = language, + sensitive = sensitive, + spoilerText = spoilerText, + localOnly = localOnly, + poll = + poll?.let { + DraftContent.DraftPoll( + options = it.options, + expiredAfter = it.expiredAfter, + multiple = it.multiple, + ) + }, + reference = + referenceStatus?.let { + DraftContent.DraftReference( + type = + when (it.composeStatus) { + is ComposeStatus.Quote -> DraftReferenceType.QUOTE + is ComposeStatus.VVOComment -> DraftReferenceType.VVO_COMMENT + is ComposeStatus.Reply -> DraftReferenceType.REPLY + }, + statusKey = it.composeStatus.statusKey, + rootId = (it.composeStatus as? ComposeStatus.VVOComment)?.rootId, + ) + }, + ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCase.kt new file mode 100644 index 000000000..92c74cc8d --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCase.kt @@ -0,0 +1,215 @@ +package dev.dimension.flare.ui.presenter.compose + +import dev.dimension.flare.data.database.app.model.DraftReferenceType +import dev.dimension.flare.data.database.app.model.DraftTargetStatus +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.data.repository.AccountRepository +import dev.dimension.flare.data.repository.ComposeDraftBundle +import dev.dimension.flare.data.repository.DraftMediaStore +import dev.dimension.flare.data.repository.DraftRepository +import dev.dimension.flare.data.repository.SaveDraftInput +import dev.dimension.flare.data.repository.SaveDraftTarget +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiAccount +import kotlinx.coroutines.flow.firstOrNull +import kotlin.time.Clock + +internal class SendDraftUseCase( + private val draftRepository: DraftRepository, + private val draftMediaStore: DraftMediaStore, + private val findAccount: suspend (MicroBlogKey) -> UiAccount?, + private val composeDraft: suspend (UiAccount, ComposeData, () -> Unit) -> Unit, +) { + constructor( + draftRepository: DraftRepository, + accountRepository: AccountRepository, + draftMediaStore: DraftMediaStore, + ) : this( + draftRepository = draftRepository, + draftMediaStore = draftMediaStore, + findAccount = { accountRepository.find(it) }, + composeDraft = { account, data, progress -> account.dataSource.compose(data = data, progress = progress) }, + ) + + suspend operator fun invoke( + bundle: ComposeDraftBundle, + progress: suspend (ComposeProgressState) -> Unit, + ) { + val persistedMedia = draftMediaStore.persist(bundle.groupId, bundle.template.medias) + val savedGroupId = + draftRepository.saveDraft( + input = + SaveDraftInput( + groupId = bundle.groupId, + content = bundle.template.toDraftContent(), + targets = + bundle.accounts.map { + SaveDraftTarget( + accountKey = it.accountKey, + status = DraftTargetStatus.SENDING, + attemptCount = 1, + lastAttemptAt = Clock.System.now().toEpochMilliseconds(), + ) + }, + medias = persistedMedia, + ), + ) + sendDatas( + targets = bundle.accounts.map { ComposeTargetData(account = it, data = bundle.template) }, + groupId = savedGroupId, + progress = progress, + ) + } + + suspend operator fun invoke( + groupId: String, + progress: suspend (ComposeProgressState) -> Unit, + ) { + val draft = draftRepository.draft(groupId).firstOrNull() ?: return + val medias = draftMediaStore.restore(draft.medias) + val datas = + draft.targets + .filter { it.status != DraftTargetStatus.SENDING } + .mapNotNull { target -> + findAccount(target.accountKey)?.let { account -> + ComposeTargetData( + account = account, + data = + ComposeData( + content = draft.content.text, + visibility = draft.content.visibility, + language = draft.content.language, + medias = medias, + sensitive = draft.content.sensitive, + spoilerText = draft.content.spoilerText, + poll = + draft.content.poll?.let { + ComposeData.Poll( + options = it.options, + expiredAfter = it.expiredAfter, + multiple = it.multiple, + ) + }, + localOnly = draft.content.localOnly, + referenceStatus = + draft.content.reference?.let { reference -> + ComposeData.ReferenceStatus( + data = null, + composeStatus = + when (reference.type) { + DraftReferenceType.QUOTE -> ComposeStatus.Quote(reference.statusKey) + DraftReferenceType.REPLY -> ComposeStatus.Reply(reference.statusKey) + DraftReferenceType.VVO_COMMENT -> + ComposeStatus.VVOComment( + statusKey = reference.statusKey, + rootId = requireNotNull(reference.rootId), + ) + }, + ) + }, + ), + ) + } + } + sendDatas( + targets = datas, + groupId = groupId, + progress = progress, + ) + } + + private suspend fun sendDatas( + targets: List, + groupId: String, + progress: suspend (ComposeProgressState) -> Unit, + ) { + val progressTracker = ComposeProgressTracker(targets) + progress(progressTracker.state()) + val failures = mutableListOf() + targets.forEach { target -> + draftRepository.updateTargetStatus( + groupId = groupId, + accountKey = target.account.accountKey, + status = DraftTargetStatus.SENDING, + attemptCount = 1, + lastAttemptAt = Clock.System.now().toEpochMilliseconds(), + ) + var pendingProgressTicks = 0 + try { + composeDraft(target.account, target.data) { + pendingProgressTicks++ + } + repeat(pendingProgressTicks) { + progressTracker.onComposeProgress(target.account.accountKey) + progress(progressTracker.state()) + } + progressTracker.onComposeSuccess(target.account.accountKey) + progress(progressTracker.state()) + draftRepository.deleteTarget(groupId, target.account.accountKey) + } catch (throwable: Throwable) { + repeat(pendingProgressTicks) { + progressTracker.onComposeProgress(target.account.accountKey) + progress(progressTracker.state()) + } + draftRepository.updateTargetStatus( + groupId = groupId, + accountKey = target.account.accountKey, + status = DraftTargetStatus.FAILED, + errorMessage = throwable.message, + attemptCount = 1, + lastAttemptAt = Clock.System.now().toEpochMilliseconds(), + ) + failures += throwable + } + } + if (failures.isEmpty()) { + progress(ComposeProgressState.Success) + } else { + progress(ComposeProgressState.Error(ComposeDraftFailedException(failures))) + } + } +} + +private class ComposeProgressTracker( + targets: List, +) { + private val mediaStepLimitsByAccount = + targets.associate { it.account.accountKey to it.data.medias.size } + private val completedMediaStepsByAccount = mutableMapOf() + private val completedSendAccounts = mutableSetOf() + private val maxSteps = targets.sumOf { it.data.medias.size + 1 } + private var completedSteps = 0 + + fun onComposeProgress(accountKey: MicroBlogKey) { + val mediaLimit = mediaStepLimitsByAccount.getValue(accountKey) + val currentMediaSteps = completedMediaStepsByAccount[accountKey] ?: 0 + if (currentMediaSteps >= mediaLimit) { + return + } + completedMediaStepsByAccount[accountKey] = currentMediaSteps + 1 + completedSteps++ + } + + fun onComposeSuccess(accountKey: MicroBlogKey) { + if (completedSendAccounts.add(accountKey)) { + completedSteps++ + } + } + + fun state(): ComposeProgressState.Progress = + ComposeProgressState.Progress( + current = completedSteps, + max = maxSteps, + ) +} + +private data class ComposeTargetData( + val account: UiAccount, + val data: ComposeData, +) + +internal class ComposeDraftFailedException( + val failures: List, +) : Exception( + failures.firstOrNull()?.message ?: "Compose failed.", + ) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/TestFileHelper.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/TestFileHelper.kt new file mode 100644 index 000000000..44f46e1b7 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/TestFileHelper.kt @@ -0,0 +1,16 @@ +package dev.dimension.flare + +import dev.dimension.flare.common.FileItem +import dev.dimension.flare.common.FileType +import okio.Path + +internal expect fun createTestRootPath(): Path + +internal expect fun deleteTestRootPath(path: Path) + +internal expect fun createTestFileItem( + root: Path, + name: String?, + bytes: ByteArray, + type: FileType, +): FileItem diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftMediaStoreTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftMediaStoreTest.kt new file mode 100644 index 000000000..61cfa329e --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftMediaStoreTest.kt @@ -0,0 +1,365 @@ +package dev.dimension.flare.data.repository + +import dev.dimension.flare.common.FileType +import dev.dimension.flare.createTestFileItem +import dev.dimension.flare.createTestRootPath +import dev.dimension.flare.data.database.app.model.DraftMediaType +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.data.io.PlatformPathProducer +import dev.dimension.flare.deleteTestRootPath +import kotlinx.coroutines.test.runTest +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DraftMediaStoreTest { + private val root = createTestRootPath() + private val fileSystem = FileSystem.SYSTEM + private val pathProducer = + object : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = root.resolve(fileName) + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = root.resolve("draft_media").resolve(groupId).resolve(fileName) + } + + @AfterTest + fun tearDown() { + deleteTestRootPath(root) + } + + @Test + fun persistRestoreDeleteFlow() = + runTest { + val store = DraftMediaStore(pathProducer, fileSystem) + val medias = + listOf( + media(name = "a.png", bytes = byteArrayOf(1, 2, 3), type = FileType.Image, altText = "a"), + media(name = "b.mov", bytes = byteArrayOf(4, 5, 6), type = FileType.Video, altText = "b"), + ) + + val persisted = store.persist("group-1", medias) + + assertEquals(2, persisted.size) + persisted.forEach { + assertTrue(fileSystem.exists(it.cachePath.toPath())) + } + + val restored = store.restore(persisted.mapIndexed { index, media -> media.toDraftMedia("group-1", index) }) + + assertEquals(2, restored.size) + assertEquals(listOf("a", "b"), restored.map { it.altText }) + assertContentEquals(byteArrayOf(1, 2, 3), restored[0].file.readBytes()) + assertContentEquals(byteArrayOf(4, 5, 6), restored[1].file.readBytes()) + + store.delete(persisted.mapIndexed { index, media -> media.toDraftMedia("group-1", index) }) + + persisted.forEach { + assertFalse(fileSystem.exists(it.cachePath.toPath())) + } + } + + @Test + fun persistRestorePersistDoesNotCreateNewFiles() = + runTest { + val store = DraftMediaStore(pathProducer, fileSystem) + val firstPersist = + store.persist( + "group-2", + listOf( + media(name = "a.png", bytes = byteArrayOf(1), type = FileType.Image, altText = "a"), + media(name = "b.mov", bytes = byteArrayOf(2), type = FileType.Video, altText = "b"), + ), + ) + val restored = store.restore(firstPersist.mapIndexed { index, media -> media.toDraftMedia("group-2", index) }) + + val secondPersist = store.persist("group-2", restored) + + assertEquals(firstPersist.map { it.cachePath }, secondPersist.map { it.cachePath }) + assertEquals( + 2, + fileSystem + .list(root.resolve("draft_media").resolve("group-2")) + .size, + ) + secondPersist.forEach { + assertTrue(fileSystem.exists(it.cachePath.toPath())) + } + } + + @Test + fun persistRestorePersistWithSameFileNameOverwritesContent() = + runTest { + val store = DraftMediaStore(pathProducer, fileSystem) + val firstPersist = + store.persist( + "group-same-name", + listOf( + media(name = "a.png", bytes = byteArrayOf(1, 2, 3), type = FileType.Image, altText = "first"), + ), + ) + val restored = store.restore(firstPersist.mapIndexed { index, media -> media.toDraftMedia("group-same-name", index) }) + val updatedMedia = + listOf( + restored.single().copy( + file = createTestFileItem(root = root, name = "a.png", bytes = byteArrayOf(9, 8, 7), type = FileType.Image), + ), + ) + + val secondPersist = store.persist("group-same-name", updatedMedia) + + assertEquals(firstPersist.single().cachePath, secondPersist.single().cachePath) + assertContentEquals( + byteArrayOf(9, 8, 7), + fileSystem.read(secondPersist.single().cachePath.toPath()) { readByteArray() }, + ) + } + + @Test + fun persistRestoreModifyPersistDeletesRemovedAndAddsNewFiles() = + runTest { + val store = DraftMediaStore(pathProducer, fileSystem) + val firstPersist = + store.persist( + "group-3", + listOf( + media(name = "a.png", bytes = byteArrayOf(1, 1), type = FileType.Image, altText = "a"), + media(name = "b.mov", bytes = byteArrayOf(2, 2), type = FileType.Video, altText = "b"), + ), + ) + val restored = store.restore(firstPersist.mapIndexed { index, media -> media.toDraftMedia("group-3", index) }) + val originalPaths = firstPersist.map { it.cachePath }.toSet() + + val modified = + listOf( + restored.first(), + media(name = "c.png", bytes = byteArrayOf(3, 3), type = FileType.Image, altText = "c"), + ) + + val secondPersist = store.persist("group-3", modified) + val newPaths = secondPersist.map { it.cachePath }.toSet() + val removedPath = firstPersist[1].cachePath + + assertTrue(firstPersist[0].cachePath in newPaths) + assertTrue(secondPersist.any { it.fileName == "c.png" }) + assertFalse(fileSystem.exists(removedPath.toPath())) + assertEquals(2, newPaths.size) + assertTrue(newPaths.any { it !in originalPaths }) + secondPersist.forEach { + assertTrue(fileSystem.exists(it.cachePath.toPath())) + } + } + + @Test + fun persistHandlesNullAndBlankFileNames() = + runTest { + val store = DraftMediaStore(pathProducer, fileSystem) + val persisted = + store.persist( + "group-4", + listOf( + media(name = null, bytes = byteArrayOf(7), type = FileType.Image, altText = null), + media(name = "", bytes = byteArrayOf(8), type = FileType.Video, altText = "blank"), + ), + ) + + assertEquals(2, persisted.size) + assertNull(persisted[0].fileName) + assertEquals("", persisted[1].fileName) + persisted.forEach { + assertTrue(fileSystem.exists(it.cachePath.toPath())) + assertTrue(it.cachePath.substringAfterLast('/').matches(Regex("\\d+_.+\\.bin"))) + } + } + + @Test + fun persistSanitizesIllegalCharactersInFileName() = + runTest { + val store = DraftMediaStore(pathProducer, fileSystem) + val persisted = + store.persist( + "group-5", + listOf( + media(name = "a:/b*?c<>|.png", bytes = byteArrayOf(9), type = FileType.Image, altText = null), + ), + ) + + assertEquals(1, persisted.size) + assertTrue(fileSystem.exists(persisted.single().cachePath.toPath())) + assertEquals("0_a__b__c___.png", persisted.single().cachePath.substringAfterLast('/')) + } + + @Test + fun persistSanitizesWhitespaceAndUnicodeFileName() = + runTest { + val store = DraftMediaStore(pathProducer, fileSystem) + + val persisted = + store.persist( + "group-5b", + listOf( + media(name = " 测试 file?.png ", bytes = byteArrayOf(3), type = FileType.Image, altText = null), + ), + ) + + assertEquals("0______file_.png__", persisted.single().cachePath.substringAfterLast('/')) + assertTrue(fileSystem.exists(persisted.single().cachePath.toPath())) + } + + @Test + fun persistEmptyListClearsDraftGroupFiles() = + runTest { + val store = DraftMediaStore(pathProducer, fileSystem) + val firstPersist = + store.persist( + "group-6", + listOf( + media(name = "a.png", bytes = byteArrayOf(1), type = FileType.Image, altText = null), + media(name = "b.png", bytes = byteArrayOf(2), type = FileType.Image, altText = null), + ), + ) + + val secondPersist = store.persist("group-6", emptyList()) + + assertTrue(secondPersist.isEmpty()) + firstPersist.forEach { + assertFalse(fileSystem.exists(it.cachePath.toPath())) + } + val groupDir = root.resolve("draft_media").resolve("group-6") + assertTrue(!fileSystem.exists(groupDir) || fileSystem.list(groupDir).isEmpty()) + } + + @Test + fun deleteIgnoresMissingFilesAndRepeatedDeletes() = + runTest { + val store = DraftMediaStore(pathProducer, fileSystem) + val missing = + DraftMedia( + mediaId = "missing", + groupId = "group-7", + cachePath = + root + .resolve("draft_media") + .resolve("group-7") + .resolve("missing.png") + .toString(), + fileName = "missing.png", + mediaType = DraftMediaType.IMAGE, + altText = null, + sortOrder = 0, + createdAt = 0L, + ) + val persisted = + store + .persist( + "group-7", + listOf( + media(name = "exists.png", bytes = byteArrayOf(1, 2), type = FileType.Image, altText = null), + ), + ).single() + .toDraftMedia("group-7", 0) + + store.delete(listOf(missing, persisted)) + store.delete(listOf(missing, persisted)) + + assertFalse(fileSystem.exists(persisted.cachePath.toPath())) + assertFalse(fileSystem.exists(missing.cachePath.toPath())) + } + + @Test + fun restoreFailsWhenCachedFileIsMissing() = + runTest { + val store = DraftMediaStore(pathProducer, fileSystem) + val missingMedia = + DraftMedia( + mediaId = "missing-restore", + groupId = "group-restore-fail", + cachePath = + root + .resolve("draft_media") + .resolve("group-restore-fail") + .resolve("missing.png") + .toString(), + fileName = "missing.png", + mediaType = DraftMediaType.IMAGE, + altText = null, + sortOrder = 0, + createdAt = 0L, + ) + + assertFailsWith { + store + .restore(listOf(missingMedia)) + .single() + .file + .readBytes() + } + } + + @Test + fun persistFailsWhenDraftDirectoryCannotBeCreated() = + runTest { + val blockedParent = root.resolve("blocked") + fileSystem.write(blockedParent) { + writeUtf8("not a directory") + } + val blockedStore = + DraftMediaStore( + platformPathProducer = + object : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = root.resolve(fileName) + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = blockedParent.resolve(groupId).resolve(fileName) + }, + fileSystem = fileSystem, + ) + + assertFailsWith { + blockedStore.persist( + "group-write-fail", + listOf( + media(name = "a.png", bytes = byteArrayOf(1), type = FileType.Image, altText = null), + ), + ) + } + } + + private fun media( + name: String?, + bytes: ByteArray, + type: FileType, + altText: String?, + ): ComposeData.Media = + ComposeData.Media( + file = createTestFileItem(root = root, name = name, bytes = bytes, type = type), + altText = altText, + ) + + private fun SaveDraftMedia.toDraftMedia( + groupId: String, + index: Int, + ) = DraftMedia( + mediaId = "media-$index", + groupId = groupId, + cachePath = cachePath, + fileName = fileName, + mediaType = mediaType, + altText = altText, + sortOrder = sortOrder ?: index, + createdAt = createdAt ?: 0L, + ) +} diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftRepositoryTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftRepositoryTest.kt new file mode 100644 index 000000000..732b3d6b8 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftRepositoryTest.kt @@ -0,0 +1,215 @@ +package dev.dimension.flare.data.repository + +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import dev.dimension.flare.RobolectricTest +import dev.dimension.flare.data.database.app.AppDatabase +import dev.dimension.flare.data.database.app.model.DraftContent +import dev.dimension.flare.data.database.app.model.DraftMediaType +import dev.dimension.flare.data.database.app.model.DraftReferenceType +import dev.dimension.flare.data.database.app.model.DraftTargetStatus +import dev.dimension.flare.memoryDatabaseBuilder +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class DraftRepositoryTest : RobolectricTest() { + private lateinit var db: AppDatabase + private lateinit var repository: DraftRepository + + @BeforeTest + fun setup() { + db = + Room + .memoryDatabaseBuilder() + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.Unconfined) + .build() + repository = DraftRepository(db) + } + + @AfterTest + fun tearDown() { + db.close() + } + + @Test + fun saveDraftAggregatesTargetsAndMedias() = + runTest { + val accountA = MicroBlogKey("alice", "mastodon.social") + val accountB = MicroBlogKey("bob", "misskey.io") + + val groupId = + repository.saveDraft( + SaveDraftInput( + groupId = "group-aggregate", + content = + DraftContent( + text = "hello draft", + visibility = UiTimelineV2.Post.Visibility.Public, + language = listOf("zh", "en"), + sensitive = true, + spoilerText = "cw", + poll = + DraftContent.DraftPoll( + options = listOf("a", "b"), + expiredAfter = 300000, + multiple = false, + ), + reference = + DraftContent.DraftReference( + type = DraftReferenceType.REPLY, + statusKey = MicroBlogKey("123", "mastodon.social"), + ), + ), + targets = + listOf( + SaveDraftTarget(accountKey = accountA), + SaveDraftTarget(accountKey = accountB, status = DraftTargetStatus.FAILED, errorMessage = "network"), + ), + medias = + listOf( + SaveDraftMedia( + cachePath = "/tmp/a.png", + fileName = "a.png", + mediaType = DraftMediaType.IMAGE, + altText = "image a", + ), + SaveDraftMedia( + cachePath = "/tmp/b.mov", + fileName = "b.mov", + mediaType = DraftMediaType.VIDEO, + altText = "video b", + ), + ), + ), + ) + + val draft = repository.draft(groupId).first() + + assertNotNull(draft) + assertEquals(groupId, draft.groupId) + assertEquals("hello draft", draft.content.text) + assertEquals(2, draft.targets.size) + assertEquals(setOf(accountA, accountB), draft.targets.map { it.accountKey }.toSet()) + assertEquals(2, draft.medias.size) + assertEquals(listOf("/tmp/a.png", "/tmp/b.mov"), draft.medias.map { it.cachePath }) + assertEquals(listOf(0, 1), draft.medias.map { it.sortOrder }) + } + + @Test + fun visibleDraftsHideSendingOnlyGroups() = + runTest { + repository.saveDraft( + SaveDraftInput( + groupId = "group-visible", + content = sampleContent("visible"), + targets = listOf(SaveDraftTarget(accountKey = MicroBlogKey("a", "host"), status = DraftTargetStatus.DRAFT)), + medias = emptyList(), + ), + ) + val sendingGroupId = + repository.saveDraft( + SaveDraftInput( + groupId = "group-sending", + content = sampleContent("sending"), + targets = listOf(SaveDraftTarget(accountKey = MicroBlogKey("b", "host"), status = DraftTargetStatus.SENDING)), + medias = emptyList(), + ), + ) + + val visible = repository.visibleDrafts.first() + val sending = repository.sendingDrafts.first() + + assertEquals(1, visible.size) + assertEquals("visible", visible.first().content.text) + assertEquals(1, sending.size) + assertEquals(sendingGroupId, sending.first().groupId) + } + + @Test + fun deleteTargetRemovesGroupWhenLastTargetDeleted() = + runTest { + val account = MicroBlogKey("alice", "example.com") + val groupId = + repository.saveDraft( + SaveDraftInput( + groupId = "group-delete", + content = sampleContent("to delete"), + targets = listOf(SaveDraftTarget(accountKey = account)), + medias = + listOf( + SaveDraftMedia( + cachePath = "/tmp/a.png", + fileName = "a.png", + mediaType = DraftMediaType.IMAGE, + ), + ), + ), + ) + + repository.deleteTarget(groupId, account) + + assertNull(repository.draft(groupId).first()) + assertEquals(emptyList(), repository.visibleDrafts.first()) + } + + @Test + fun markSendingAsDraftIfExpiredResetsStatus() = + runTest { + val account = MicroBlogKey("alice", "example.com") + val now = 2_000L + val groupId = + repository.saveDraft( + SaveDraftInput( + groupId = "group-expired", + content = sampleContent("retry me"), + targets = + listOf( + SaveDraftTarget( + accountKey = account, + status = DraftTargetStatus.SENDING, + lastAttemptAt = 1_000L, + attemptCount = 2, + ), + ), + medias = emptyList(), + createdAt = now, + ), + ) + + repository.markSendingAsDraftIfExpired( + expiredBefore = 1_500L, + errorMessage = "timeout", + ) + + val draft = repository.draft(groupId).first() + + assertNotNull(draft) + assertEquals(DraftTargetStatus.DRAFT, draft.targets.single().status) + assertEquals("timeout", draft.targets.single().errorMessage) + assertEquals( + groupId, + repository.visibleDrafts + .first() + .single() + .groupId, + ) + } + + private fun sampleContent(text: String) = + DraftContent( + text = text, + visibility = UiTimelineV2.Post.Visibility.Public, + language = listOf("en"), + sensitive = false, + ) +} diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/SaveDraftUseCaseTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/SaveDraftUseCaseTest.kt new file mode 100644 index 000000000..ca884f3c0 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/SaveDraftUseCaseTest.kt @@ -0,0 +1,550 @@ +package dev.dimension.flare.ui.presenter.compose + +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import dev.dimension.flare.RobolectricTest +import dev.dimension.flare.common.FileType +import dev.dimension.flare.createTestFileItem +import dev.dimension.flare.createTestRootPath +import dev.dimension.flare.data.database.app.AppDatabase +import dev.dimension.flare.data.database.app.model.DraftMediaType +import dev.dimension.flare.data.database.app.model.DraftReferenceType +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.data.io.PlatformPathProducer +import dev.dimension.flare.data.repository.ComposeDraftBundle +import dev.dimension.flare.data.repository.DraftMediaStore +import dev.dimension.flare.data.repository.DraftRepository +import dev.dimension.flare.deleteTestRootPath +import dev.dimension.flare.memoryDatabaseBuilder +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.SYSTEM +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.uuid.Uuid + +class SaveDraftUseCaseTest : RobolectricTest() { + private val root = createTestRootPath() + private val fileSystem = FileSystem.SYSTEM + private val pathProducer = + object : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = root.resolve(fileName) + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = root.resolve("draft_media").resolve(groupId).resolve(fileName) + } + + private lateinit var db: AppDatabase + private lateinit var repository: DraftRepository + private lateinit var mediaStore: DraftMediaStore + private lateinit var useCase: SaveDraftUseCase + + @BeforeTest + fun setup() { + db = + Room + .memoryDatabaseBuilder() + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.Unconfined) + .build() + repository = DraftRepository(db) + mediaStore = DraftMediaStore(pathProducer, fileSystem) + useCase = SaveDraftUseCase(repository, mediaStore) + } + + @AfterTest + fun tearDown() { + db.close() + deleteTestRootPath(root) + } + + @Test + fun saveDraftPersistsContentTargetsAndMedias() = + runTest { + val accountA = mastodonAccount("alice", "mastodon.social") + val accountB = mastodonAccount("bob", "misskey.io") + val replyKey = MicroBlogKey("status-1", "weibo.com") + val firstBytes = byteArrayOf(1, 2, 3) + val secondBytes = byteArrayOf(4, 5, 6) + val bundle = + ComposeDraftBundle( + accounts = listOf(accountA, accountB), + groupId = "group-1", + template = + ComposeData( + content = "hello draft", + visibility = UiTimelineV2.Post.Visibility.Followers, + language = listOf("zh", "en"), + medias = + listOf( + media(name = "a.png", bytes = firstBytes, altText = "cover"), + media( + name = "b.mov", + bytes = secondBytes, + type = dev.dimension.flare.common.FileType.Video, + altText = "clip", + ), + ), + sensitive = true, + spoilerText = "cw", + poll = + ComposeData.Poll( + options = listOf("a", "b"), + expiredAfter = 300_000L, + multiple = true, + ), + localOnly = true, + referenceStatus = + ComposeData.ReferenceStatus( + data = null, + composeStatus = ComposeStatus.VVOComment(statusKey = replyKey, rootId = "root-1"), + ), + ), + ) + + val groupId = useCase(bundle) + + assertEquals("group-1", groupId) + val draft = repository.draft(groupId).first() + + assertNotNull(draft) + assertEquals("hello draft", draft.content.text) + assertEquals(UiTimelineV2.Post.Visibility.Followers, draft.content.visibility) + assertEquals(listOf("zh", "en"), draft.content.language) + assertTrue(draft.content.sensitive) + assertEquals("cw", draft.content.spoilerText) + assertTrue(draft.content.localOnly) + val poll = assertNotNull(draft.content.poll) + assertEquals(listOf("a", "b"), poll.options) + assertEquals(300_000L, poll.expiredAfter) + assertEquals(true, poll.multiple) + val reference = assertNotNull(draft.content.reference) + assertEquals(DraftReferenceType.VVO_COMMENT, reference.type) + assertEquals(replyKey, reference.statusKey) + assertEquals("root-1", reference.rootId) + + assertEquals(setOf(accountA.accountKey, accountB.accountKey), draft.targets.map { it.accountKey }.toSet()) + assertEquals(2, draft.medias.size) + assertEquals(listOf("a.png", "b.mov"), draft.medias.map { it.fileName }) + assertEquals(listOf(DraftMediaType.IMAGE, DraftMediaType.VIDEO), draft.medias.map { it.mediaType }) + assertEquals(listOf("cover", "clip"), draft.medias.map { it.altText }) + + draft.medias.forEach { media -> + assertTrue(fileSystem.exists(media.cachePath.toPath())) + } + assertContentEquals(firstBytes, fileSystem.read(draft.medias[0].cachePath.toPath()) { readByteArray() }) + assertContentEquals(secondBytes, fileSystem.read(draft.medias[1].cachePath.toPath()) { readByteArray() }) + } + + @Test + fun saveDraftUsesBundleGroupId() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val bundle = + ComposeDraftBundle( + accounts = listOf(account), + groupId = "explicit-group", + template = + ComposeData( + content = "override me", + ), + ) + + val groupId = useCase(bundle) + + assertEquals("explicit-group", groupId) + assertEquals( + "override me", + repository + .draft("explicit-group") + .first() + ?.content + ?.text, + ) + } + + @Test + fun saveDraftGeneratesGroupIdWhenMissing() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + + val groupId = + useCase( + ComposeDraftBundle( + accounts = listOf(account), + template = + ComposeData( + content = "generated id", + ), + ), + ) + + assertEquals(groupId, Uuid.parse(groupId).toString()) + assertEquals( + "generated id", + repository + .draft(groupId) + .first() + ?.content + ?.text, + ) + } + + @Test + fun saveDraftMapsQuoteReferenceType() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val statusKey = MicroBlogKey("quoted", "mastodon.social") + + val groupId = + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "group-quote", + template = + ComposeData( + content = "quote", + referenceStatus = + ComposeData.ReferenceStatus( + data = null, + composeStatus = ComposeStatus.Quote(statusKey), + ), + ), + ), + ) + + val reference = + repository + .draft(groupId) + .first() + ?.content + ?.reference + + assertNotNull(reference) + assertEquals(DraftReferenceType.QUOTE, reference.type) + assertEquals(statusKey, reference.statusKey) + assertNull(reference.rootId) + } + + @Test + fun saveDraftMapsReplyReferenceType() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val statusKey = MicroBlogKey("reply", "mastodon.social") + + val groupId = + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "group-reply", + template = + ComposeData( + content = "reply", + referenceStatus = + ComposeData.ReferenceStatus( + data = null, + composeStatus = ComposeStatus.Reply(statusKey), + ), + ), + ), + ) + + val reference = + repository + .draft(groupId) + .first() + ?.content + ?.reference + + assertNotNull(reference) + assertEquals(DraftReferenceType.REPLY, reference.type) + assertEquals(statusKey, reference.statusKey) + assertNull(reference.rootId) + } + + @Test + fun saveDraftPreservesDefaultsAndNullFields() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val groupId = + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "group-defaults", + template = + ComposeData( + content = "", + medias = + listOf( + media(name = "note.bin", bytes = byteArrayOf(7, 8), type = FileType.Other, altText = null), + media(name = "empty-alt.png", bytes = byteArrayOf(9), altText = ""), + ), + spoilerText = null, + poll = null, + referenceStatus = null, + ), + ), + ) + + val draft = repository.draft(groupId).first() + + assertNotNull(draft) + assertEquals("", draft.content.text) + assertEquals(UiTimelineV2.Post.Visibility.Public, draft.content.visibility) + assertEquals(listOf("en"), draft.content.language) + assertEquals(false, draft.content.sensitive) + assertNull(draft.content.spoilerText) + assertEquals(false, draft.content.localOnly) + assertNull(draft.content.poll) + assertNull(draft.content.reference) + assertEquals(2, draft.medias.size) + assertEquals(listOf(DraftMediaType.OTHER, DraftMediaType.IMAGE), draft.medias.map { it.mediaType }) + assertEquals(listOf(null, ""), draft.medias.map { it.altText }) + } + + @Test + fun saveDraftPreservesPollBoundaryValues() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val groupId = + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "group-poll-boundary", + template = + ComposeData( + content = "poll", + spoilerText = "", + poll = + ComposeData.Poll( + options = emptyList(), + expiredAfter = Long.MAX_VALUE, + multiple = false, + ), + ), + ), + ) + + val draft = assertNotNull(repository.draft(groupId).first()) + val poll = assertNotNull(draft.content.poll) + + assertEquals(emptyList(), poll.options) + assertEquals(Long.MAX_VALUE, poll.expiredAfter) + assertEquals(false, poll.multiple) + assertEquals("", draft.content.spoilerText) + } + + @Test + fun saveDraftUpdatesExistingGroupAndReusesExistingMediaFiles() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val initialGroupId = + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "group-3", + template = + ComposeData( + content = "first", + medias = listOf(media(name = "a.png", bytes = byteArrayOf(1, 2, 3), altText = "a")), + ), + ), + ) + val initialDraft = repository.draft(initialGroupId).first() + assertNotNull(initialDraft) + val initialPath = initialDraft.medias.single().cachePath + val restoredMedia = mediaStore.restore(initialDraft.medias) + + val savedGroupId = + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = initialGroupId, + template = + ComposeData( + content = "second", + medias = restoredMedia, + ), + ), + ) + + val updatedDraft = repository.draft(savedGroupId).first() + + assertEquals(initialGroupId, savedGroupId) + assertNotNull(updatedDraft) + assertEquals("second", updatedDraft.content.text) + assertEquals(1, updatedDraft.medias.size) + assertEquals(initialPath, updatedDraft.medias.single().cachePath) + assertTrue(fileSystem.exists(initialPath.toPath())) + assertEquals( + 1, + fileSystem.list(root.resolve("draft_media").resolve(initialGroupId)).size, + ) + assertNotEquals("", updatedDraft.medias.single().cachePath) + } + + @Test + fun saveDraftRemovesStaleMediaFilesWhenMediaListShrinks() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val groupId = + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "group-cleanup", + template = + ComposeData( + content = "first", + medias = + listOf( + media(name = "a.png", bytes = byteArrayOf(1), altText = "a"), + media(name = "b.png", bytes = byteArrayOf(2), altText = "b"), + ), + ), + ), + ) + val initialDraft = assertNotNull(repository.draft(groupId).first()) + val removedPath = initialDraft.medias.last().cachePath + val restored = mediaStore.restore(initialDraft.medias).take(1) + + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = groupId, + template = + ComposeData( + content = "second", + medias = restored, + ), + ), + ) + + val updatedDraft = assertNotNull(repository.draft(groupId).first()) + + assertEquals(1, updatedDraft.medias.size) + assertFalse(fileSystem.exists(removedPath.toPath())) + assertEquals(1, fileSystem.list(root.resolve("draft_media").resolve(groupId)).size) + } + + @Test + fun saveDraftDeduplicatesRepeatedAccountsByAccountKey() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + + val groupId = + useCase( + ComposeDraftBundle( + accounts = listOf(account, account, account), + groupId = "group-duplicate-accounts", + template = + ComposeData( + content = "duplicate accounts", + ), + ), + ) + + val draft = assertNotNull(repository.draft(groupId).first()) + + assertEquals(1, draft.targets.size) + assertEquals(account.accountKey, draft.targets.single().accountKey) + } + + @Test + fun saveDraftKeepsCreatedAtAndRefreshesUpdatedAtOnResave() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val groupId = + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "group-timestamps", + template = + ComposeData( + content = "first", + ), + ), + ) + val firstDraft = assertNotNull(repository.draft(groupId).first()) + + delay(5) + + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = groupId, + template = + ComposeData( + content = "second", + ), + ), + ) + + val updatedDraft = assertNotNull(repository.draft(groupId).first()) + + assertEquals(firstDraft.createdAt, updatedDraft.createdAt) + assertTrue(updatedDraft.updatedAt >= firstDraft.updatedAt) + assertEquals("second", updatedDraft.content.text) + } + + @Test + fun saveDraftAllowsEmptyAccountList() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + + val groupId = + useCase( + ComposeDraftBundle( + accounts = emptyList(), + groupId = "group-no-accounts", + template = + ComposeData( + content = "no accounts", + ), + ), + ) + + val draft = repository.draft(groupId).first() + + assertNotNull(draft) + assertTrue(draft.targets.isEmpty()) + assertEquals("no accounts", draft.content.text) + } + + private fun mastodonAccount( + id: String, + host: String, + ): UiAccount = + UiAccount.Mastodon( + accountKey = MicroBlogKey(id, host), + instance = host, + ) + + private fun media( + name: String, + bytes: ByteArray, + type: FileType = FileType.Image, + altText: String?, + ): ComposeData.Media = + ComposeData.Media( + file = createTestFileItem(root = root, name = name, bytes = bytes, type = type), + altText = altText, + ) +} diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCaseTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCaseTest.kt new file mode 100644 index 000000000..803639ffc --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCaseTest.kt @@ -0,0 +1,834 @@ +package dev.dimension.flare.ui.presenter.compose + +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import dev.dimension.flare.RobolectricTest +import dev.dimension.flare.common.FileType +import dev.dimension.flare.createTestFileItem +import dev.dimension.flare.createTestRootPath +import dev.dimension.flare.data.database.app.AppDatabase +import dev.dimension.flare.data.database.app.model.DraftContent +import dev.dimension.flare.data.database.app.model.DraftMediaType +import dev.dimension.flare.data.database.app.model.DraftReferenceType +import dev.dimension.flare.data.database.app.model.DraftTargetStatus +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.data.io.PlatformPathProducer +import dev.dimension.flare.data.repository.ComposeDraftBundle +import dev.dimension.flare.data.repository.DraftMediaStore +import dev.dimension.flare.data.repository.DraftRepository +import dev.dimension.flare.data.repository.SaveDraftInput +import dev.dimension.flare.data.repository.SaveDraftMedia +import dev.dimension.flare.data.repository.SaveDraftTarget +import dev.dimension.flare.deleteTestRootPath +import dev.dimension.flare.memoryDatabaseBuilder +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiTimelineV2 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okio.FileSystem +import okio.Path +import okio.SYSTEM +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SendDraftUseCaseTest : RobolectricTest() { + private val root = createTestRootPath() + private val fileSystem = FileSystem.SYSTEM + private val pathProducer = + object : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = root.resolve(fileName) + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = root.resolve("draft_media").resolve(groupId).resolve(fileName) + } + + private lateinit var db: AppDatabase + private lateinit var repository: DraftRepository + private lateinit var mediaStore: DraftMediaStore + + @BeforeTest + fun setup() { + db = + Room + .memoryDatabaseBuilder() + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.Unconfined) + .build() + repository = DraftRepository(db) + mediaStore = DraftMediaStore(pathProducer, fileSystem) + } + + @AfterTest + fun tearDown() { + db.close() + deleteTestRootPath(root) + } + + @Test + fun sendBundleSuccessDeletesDraftAfterAllTargetsSucceed() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val sent = mutableListOf() + val progresses = mutableListOf() + val useCase = + testUseCase(sent = sent) { _, _, progress -> + progress() + } + val bundle = + ComposeDraftBundle( + accounts = listOf(account), + groupId = "send-success", + template = + ComposeData( + content = "hello", + medias = listOf(media(name = "a.png", bytes = byteArrayOf(1, 2, 3), altText = "cover")), + ), + ) + + useCase(bundle) { progresses += it } + advanceUntilIdle() + + assertEquals(1, sent.size) + assertEquals(account.accountKey, sent.single().account.accountKey) + assertEquals("hello", sent.single().data.content) + assertNull(repository.draft("send-success").first()) + assertEquals(ComposeProgressState.Progress(0, 2), progresses.first()) + assertEquals(ComposeProgressState.Progress(1, 2), progresses[1]) + assertEquals(ComposeProgressState.Progress(2, 2), progresses[2]) + assertIs(progresses.last()) + } + + @Test + fun sendBundleAllTargetsSuccessDeletesDraft() = + runTest { + val accountA = mastodonAccount("alice", "mastodon.social") + val accountB = mastodonAccount("bob", "mastodon.social") + val sent = mutableListOf() + val useCase = testUseCase(sent = sent) { _, _, progress -> progress() } + + useCase( + ComposeDraftBundle( + accounts = listOf(accountA, accountB), + groupId = "send-all-success", + template = + ComposeData( + content = "multi success", + ), + ), + ) {} + advanceUntilIdle() + + assertEquals(listOf(accountA.accountKey, accountB.accountKey), sent.map { it.account.accountKey }) + assertNull(repository.draft("send-all-success").first()) + } + + @Test + fun sendBundlePartialSuccessKeepsFailedTargetAndStillEmitsSuccess() = + runTest { + val accountA = mastodonAccount("alice", "mastodon.social") + val accountB = mastodonAccount("bob", "mastodon.social") + val progresses = mutableListOf() + val sent = mutableListOf() + val useCase = + testUseCase(sent = sent) { account, _, progress -> + progress() + if (account.accountKey == accountB.accountKey) { + error("account-b failed") + } + } + + useCase( + ComposeDraftBundle( + accounts = listOf(accountA, accountB), + groupId = "send-partial-failure", + template = + ComposeData( + content = "partial failure", + ), + ), + ) { progresses += it } + advanceUntilIdle() + + val draft = assertNotNull(repository.draft("send-partial-failure").first()) + assertEquals(1, draft.targets.size) + assertEquals(accountB.accountKey, draft.targets.single().accountKey) + assertEquals(DraftTargetStatus.FAILED, draft.targets.single().status) + assertEquals("account-b failed", draft.targets.single().errorMessage) + assertEquals(listOf(accountA.accountKey, accountB.accountKey), sent.map { it.account.accountKey }) + val error = assertIs(progresses.last()) + assertIs(error.throwable) + } + + @Test + fun sendBundleAllTargetsFailKeepsDraftWithAllFailedTargets() = + runTest { + val accountA = mastodonAccount("alice", "mastodon.social") + val accountB = mastodonAccount("bob", "mastodon.social") + val useCase = + testUseCase { _, _, _ -> + error("all failed") + } + + useCase( + ComposeDraftBundle( + accounts = listOf(accountA, accountB), + groupId = "send-all-failed", + template = + ComposeData( + content = "all fail", + ), + ), + ) {} + advanceUntilIdle() + + val draft = assertNotNull(repository.draft("send-all-failed").first()) + assertEquals(2, draft.targets.size) + assertTrue(draft.targets.all { it.status == DraftTargetStatus.FAILED }) + assertEquals(setOf("all failed"), draft.targets.mapNotNull { it.errorMessage }.toSet()) + } + + @Test + fun sendBundleFailureMarksTargetFailedAndKeepsDraft() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val progresses = mutableListOf() + val useCase = testUseCase { _, _, _ -> error("boom") } + + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "send-failure", + template = + ComposeData( + content = "failure", + ), + ), + ) { progresses += it } + advanceUntilIdle() + + val draft = assertNotNull(repository.draft("send-failure").first()) + val target = draft.targets.single() + + assertEquals(DraftTargetStatus.FAILED, target.status) + assertEquals("boom", target.errorMessage) + assertEquals(ComposeProgressState.Progress(0, 1), progresses.first()) + val error = assertIs(progresses.last()) + assertIs(error.throwable) + } + + @Test + fun sendBundleWithoutMediaPersistsAndSendsNormally() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val sent = mutableListOf() + val useCase = testUseCase(sent = sent) { _, _, _ -> } + + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "send-no-media", + template = + ComposeData( + content = "no media", + medias = emptyList(), + ), + ), + ) {} + advanceUntilIdle() + + assertEquals(1, sent.size) + assertTrue( + sent + .single() + .data.medias + .isEmpty(), + ) + assertNull(repository.draft("send-no-media").first()) + } + + @Test + fun sendBundleWithMultipleMediaPersistsAllAttachments() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + val sent = mutableListOf() + val useCase = testUseCase(sent = sent) { _, _, _ -> } + + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "send-multi-media", + template = + ComposeData( + content = "multi media", + medias = + listOf( + media(name = "a.png", bytes = byteArrayOf(1), altText = "a"), + media(name = "b.mov", bytes = byteArrayOf(2), type = FileType.Video, altText = "b"), + ), + ), + ), + ) {} + advanceUntilIdle() + + assertEquals( + 2, + sent + .single() + .data.medias.size, + ) + assertEquals( + listOf("a", "b"), + sent + .single() + .data.medias + .map { it.altText }, + ) + assertNull(repository.draft("send-multi-media").first()) + } + + @Test + fun sendBundleWithEmptyAccountsOnlyEmitsProgressAndSuccess() = + runTest { + val sent = mutableListOf() + val progresses = mutableListOf() + val useCase = testUseCase(sent = sent) { _, _, _ -> } + + useCase( + ComposeDraftBundle( + accounts = emptyList(), + groupId = "send-empty-accounts", + template = + ComposeData( + content = "empty accounts", + ), + ), + ) { progresses += it } + advanceUntilIdle() + + assertTrue(sent.isEmpty()) + assertEquals( + listOf( + ComposeProgressState.Progress(0, 0), + ComposeProgressState.Success, + ), + progresses, + ) + val draft = assertNotNull(repository.draft("send-empty-accounts").first()) + assertTrue(draft.targets.isEmpty()) + } + + @Test + fun resendMissingDraftReturnsWithoutProgress() = + runTest { + val progresses = mutableListOf() + val useCase = testUseCase() + + useCase("missing-group") { progresses += it } + advanceUntilIdle() + + assertTrue(progresses.isEmpty()) + } + + @Test + fun resendSkipsTargetsWithoutResolvedAccountAndKeepsTheirStatus() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + saveDraftGroup( + groupId = "resend-missing-account", + content = sampleContent("missing account"), + targets = + listOf( + SaveDraftTarget(accountKey = account.accountKey, status = DraftTargetStatus.FAILED), + ), + ) + val progresses = mutableListOf() + val useCase = testUseCase(findAccount = { null }) + + useCase("resend-missing-account") { progresses += it } + advanceUntilIdle() + + val draft = assertNotNull(repository.draft("resend-missing-account").first()) + assertEquals(DraftTargetStatus.FAILED, draft.targets.single().status) + assertEquals( + listOf( + ComposeProgressState.Progress(0, 0), + ComposeProgressState.Success, + ), + progresses, + ) + } + + @Test + fun resendAllSendingTargetsOnlyEmitsProgressAndSuccess() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + saveDraftGroup( + groupId = "resend-all-sending", + content = sampleContent("sending"), + targets = + listOf( + SaveDraftTarget(accountKey = account.accountKey, status = DraftTargetStatus.SENDING), + ), + ) + val sent = mutableListOf() + val progresses = mutableListOf() + val useCase = testUseCase(sent = sent, findAccount = { account }) + + useCase("resend-all-sending") { progresses += it } + advanceUntilIdle() + + assertTrue(sent.isEmpty()) + assertEquals( + listOf( + ComposeProgressState.Progress(0, 0), + ComposeProgressState.Success, + ), + progresses, + ) + assertEquals( + DraftTargetStatus.SENDING, + repository + .draft("resend-all-sending") + .first() + ?.targets + ?.single() + ?.status, + ) + } + + @Test + fun resendDraftGroupRestoresDataAndSkipsSendingTargets() = + runTest { + val failedAccount = mastodonAccount("alice", "mastodon.social") + val sendingAccount = mastodonAccount("bob", "mastodon.social") + val originalBytes = byteArrayOf(9, 8, 7) + val persistedMedias = + mediaStore.persist( + "resend-group", + listOf( + media(name = "cached.png", bytes = originalBytes, altText = "cached"), + ), + ) + repository.saveDraft( + SaveDraftInput( + groupId = "resend-group", + content = + DraftContent( + text = "restore me", + visibility = UiTimelineV2.Post.Visibility.Followers, + language = listOf("zh"), + sensitive = true, + spoilerText = "cw", + localOnly = true, + poll = + DraftContent.DraftPoll( + options = listOf("yes"), + expiredAfter = 999L, + multiple = false, + ), + reference = + DraftContent.DraftReference( + type = DraftReferenceType.QUOTE, + statusKey = MicroBlogKey("quoted", "mastodon.social"), + ), + ), + targets = + listOf( + SaveDraftTarget(accountKey = failedAccount.accountKey, status = DraftTargetStatus.FAILED), + SaveDraftTarget(accountKey = sendingAccount.accountKey, status = DraftTargetStatus.SENDING), + ), + medias = + persistedMedias.map { + SaveDraftMedia( + cachePath = it.cachePath, + fileName = it.fileName, + mediaType = it.mediaType, + altText = it.altText, + ) + }, + ), + ) + + val sent = mutableListOf() + val useCase = + SendDraftUseCase( + draftRepository = repository, + draftMediaStore = mediaStore, + findAccount = { + when (it) { + failedAccount.accountKey -> failedAccount + sendingAccount.accountKey -> sendingAccount + else -> null + } + }, + composeDraft = { account, data, _ -> sent += SentCompose(account = account, data = data) }, + ) + + useCase("resend-group") {} + advanceUntilIdle() + + assertEquals(1, sent.size) + val resent = sent.single() + assertEquals(failedAccount.accountKey, resent.account.accountKey) + assertEquals("restore me", resent.data.content) + assertEquals(UiTimelineV2.Post.Visibility.Followers, resent.data.visibility) + assertEquals(listOf("zh"), resent.data.language) + assertEquals(true, resent.data.sensitive) + assertEquals("cw", resent.data.spoilerText) + assertEquals(true, resent.data.localOnly) + assertEquals(listOf("yes"), resent.data.poll?.options) + assertEquals(999L, resent.data.poll?.expiredAfter) + assertEquals(false, resent.data.poll?.multiple) + assertEquals( + DraftReferenceType.QUOTE, + resent.data.referenceStatus?.composeStatus?.let { + when (it) { + is ComposeStatus.Quote -> DraftReferenceType.QUOTE + is ComposeStatus.Reply -> DraftReferenceType.REPLY + is ComposeStatus.VVOComment -> DraftReferenceType.VVO_COMMENT + } + }, + ) + assertEquals( + "cached", + resent.data.medias + .single() + .altText, + ) + assertContentEquals( + originalBytes, + resent.data.medias + .single() + .file + .readBytes(), + ) + + val remainingDraft = assertNotNull(repository.draft("resend-group").first()) + assertEquals(1, remainingDraft.targets.size) + assertEquals(sendingAccount.accountKey, remainingDraft.targets.single().accountKey) + assertEquals(DraftTargetStatus.SENDING, remainingDraft.targets.single().status) + } + + @Test + fun resendDraftRestoresReplyReference() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + saveDraftGroup( + groupId = "resend-reply", + content = + sampleContent("reply").copy( + reference = + DraftContent.DraftReference( + type = DraftReferenceType.REPLY, + statusKey = MicroBlogKey("reply", "mastodon.social"), + ), + ), + targets = listOf(SaveDraftTarget(accountKey = account.accountKey, status = DraftTargetStatus.FAILED)), + ) + val sent = mutableListOf() + val useCase = testUseCase(sent = sent, findAccount = { account }) + + useCase("resend-reply") {} + advanceUntilIdle() + + assertIs( + sent + .single() + .data.referenceStatus + ?.composeStatus, + ) + assertEquals( + MicroBlogKey("reply", "mastodon.social"), + sent + .single() + .data.referenceStatus + ?.composeStatus + ?.statusKey, + ) + } + + @Test + fun resendDraftRestoresVvoCommentReference() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + saveDraftGroup( + groupId = "resend-vvo", + content = + sampleContent("vvo").copy( + reference = + DraftContent.DraftReference( + type = DraftReferenceType.VVO_COMMENT, + statusKey = MicroBlogKey("reply", "weibo.com"), + rootId = "root-id", + ), + ), + targets = listOf(SaveDraftTarget(accountKey = account.accountKey, status = DraftTargetStatus.FAILED)), + ) + val sent = mutableListOf() + val useCase = testUseCase(sent = sent, findAccount = { account }) + + useCase("resend-vvo") {} + advanceUntilIdle() + + val composeStatus = + assertIs( + sent + .single() + .data.referenceStatus + ?.composeStatus, + ) + assertEquals("root-id", composeStatus.rootId) + } + + @Test + fun resendDraftWithoutPollAndReferenceRestoresNulls() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + saveDraftGroup( + groupId = "resend-nullables", + content = sampleContent("nullables"), + targets = listOf(SaveDraftTarget(accountKey = account.accountKey, status = DraftTargetStatus.FAILED)), + ) + val sent = mutableListOf() + val useCase = testUseCase(sent = sent, findAccount = { account }) + + useCase("resend-nullables") {} + advanceUntilIdle() + + assertNull(sent.single().data.poll) + assertNull(sent.single().data.referenceStatus) + } + + @Test + fun resendVvoCommentWithoutRootIdThrows() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + saveDraftGroup( + groupId = "resend-vvo-invalid", + content = + sampleContent("invalid").copy( + reference = + DraftContent.DraftReference( + type = DraftReferenceType.VVO_COMMENT, + statusKey = MicroBlogKey("reply", "weibo.com"), + rootId = null, + ), + ), + targets = listOf(SaveDraftTarget(accountKey = account.accountKey, status = DraftTargetStatus.FAILED)), + ) + val useCase = + testUseCase(findAccount = { account }) { _, data, _ -> + data.medias + .single() + .file + .readBytes() + } + + assertFailsWith { + useCase("resend-vvo-invalid") {} + } + } + + @Test + fun resendMissingCachedMediaMarksTargetFailed() = + runTest { + val account = mastodonAccount("alice", "mastodon.social") + saveDraftGroup( + groupId = "resend-restore-fail", + content = sampleContent("restore fail"), + targets = listOf(SaveDraftTarget(accountKey = account.accountKey, status = DraftTargetStatus.FAILED)), + medias = + listOf( + SaveDraftMedia( + cachePath = + root + .resolve("draft_media") + .resolve("resend-restore-fail") + .resolve("missing.png") + .toString(), + fileName = "missing.png", + mediaType = DraftMediaType.IMAGE, + ), + ), + ) + val useCase = + testUseCase(findAccount = { account }) { _, data, _ -> + data.medias + .single() + .file + .readBytes() + } + + useCase("resend-restore-fail") {} + advanceUntilIdle() + + val draft = assertNotNull(repository.draft("resend-restore-fail").first()) + assertEquals(DraftTargetStatus.FAILED, draft.targets.single().status) + assertTrue( + draft.targets + .single() + .errorMessage + ?.isNotBlank() == true, + ) + } + + @Test + fun sendBundlePersistFailurePropagatesException() = + runTest { + val blockedParent = root.resolve("blocked-send") + fileSystem.write(blockedParent) { + writeUtf8("not a directory") + } + val blockedStore = + DraftMediaStore( + platformPathProducer = + object : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = root.resolve(fileName) + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = blockedParent.resolve(groupId).resolve(fileName) + }, + fileSystem = fileSystem, + ) + val account = mastodonAccount("alice", "mastodon.social") + val useCase = + SendDraftUseCase( + draftRepository = repository, + draftMediaStore = blockedStore, + findAccount = { null }, + composeDraft = { _, _, _ -> }, + ) + + assertFailsWith { + useCase( + ComposeDraftBundle( + accounts = listOf(account), + groupId = "send-persist-fail", + template = + ComposeData( + content = "persist fail", + medias = listOf(media(name = "a.png", bytes = byteArrayOf(1), altText = null)), + ), + ), + ) {} + } + } + + @Test + fun sendBundleProgressSequenceIncludesErrorsAndFinalSuccess() = + runTest { + val accountA = mastodonAccount("alice", "mastodon.social") + val accountB = mastodonAccount("bob", "mastodon.social") + val progresses = mutableListOf() + val useCase = + testUseCase { account, _, progress -> + progress() + if (account.accountKey == accountB.accountKey) { + error("second failed") + } + progress() + } + + useCase( + ComposeDraftBundle( + accounts = listOf(accountA, accountB), + groupId = "send-progress-order", + template = + ComposeData( + content = "progress order", + ), + ), + ) { progresses += it } + advanceUntilIdle() + + assertEquals(ComposeProgressState.Progress(0, 2), progresses.first()) + assertTrue(progresses.filterIsInstance().contains(ComposeProgressState.Progress(1, 2))) + val error = assertIs(progresses.last()) + assertIs(error.throwable) + } + + private fun testUseCase( + sent: MutableList = mutableListOf(), + findAccount: suspend (MicroBlogKey) -> UiAccount? = { null }, + composeDraft: suspend (UiAccount, ComposeData, () -> Unit) -> Unit = { _, _, _ -> }, + ): SendDraftUseCase = + SendDraftUseCase( + draftRepository = repository, + draftMediaStore = mediaStore, + findAccount = findAccount, + composeDraft = { account, data, progress -> + sent += SentCompose(account = account, data = data) + composeDraft(account, data, progress) + }, + ) + + private suspend fun saveDraftGroup( + groupId: String, + content: DraftContent, + targets: List, + medias: List = emptyList(), + ) { + repository.saveDraft( + SaveDraftInput( + groupId = groupId, + content = content, + targets = targets, + medias = medias, + ), + ) + } + + private fun sampleContent(text: String) = + DraftContent( + text = text, + visibility = UiTimelineV2.Post.Visibility.Public, + language = listOf("en"), + sensitive = false, + spoilerText = null, + localOnly = false, + poll = null, + reference = null, + ) + + private fun mastodonAccount( + id: String, + host: String, + ): UiAccount = + UiAccount.Mastodon( + accountKey = MicroBlogKey(id, host), + instance = host, + ) + + private fun media( + name: String, + bytes: ByteArray, + type: FileType = FileType.Image, + altText: String?, + ): ComposeData.Media = + ComposeData.Media( + file = createTestFileItem(root = root, name = name, bytes = bytes, type = type), + altText = altText, + ) + + private data class SentCompose( + val account: UiAccount, + val data: ComposeData, + ) +} diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileItem.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileItem.jvm.kt index 02fbf1447..e111cb661 100644 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileItem.jvm.kt +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/common/FileItem.jvm.kt @@ -4,27 +4,14 @@ import java.io.File public actual class FileItem( private val file: File, + internal actual val name: String? = file.name, + internal actual val type: FileType = resolveType(file.name), ) { - internal actual val name: String? = file.name - internal actual suspend fun readBytes(): ByteArray = file.readBytes() - internal actual val type: FileType - get() { - val mimeType = - try { - java.nio.file.Files - .probeContentType(file.toPath()) - } catch (e: Exception) { - null - } - - if (mimeType != null) { - if (mimeType.startsWith("image")) return FileType.Image - if (mimeType.startsWith("video")) return FileType.Video - } - - val lowerName = file.name.lowercase() + private companion object { + fun resolveType(fileName: String): FileType { + val lowerName = fileName.lowercase() return when { lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") || @@ -40,4 +27,5 @@ public actual class FileItem( else -> FileType.Other } } + } } diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/JvmPlatformPathProducer.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/JvmPlatformPathProducer.kt new file mode 100644 index 000000000..a5d8663b1 --- /dev/null +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/JvmPlatformPathProducer.kt @@ -0,0 +1,20 @@ +package dev.dimension.flare.data.io + +import dev.dimension.flare.common.FileSystemUtilsExt +import okio.Path +import okio.Path.Companion.toOkioPath + +internal class JvmPlatformPathProducer : PlatformPathProducer { + override fun dataStoreFile(fileName: String): Path = FileSystemUtilsExt.flareDirectory().toOkioPath().resolve(fileName) + + override fun draftMediaFile( + groupId: String, + fileName: String, + ): Path = + FileSystemUtilsExt + .flareDirectory() + .toOkioPath() + .resolve("draft_media") + .resolve(groupId) + .resolve(fileName) +} diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.jvm.kt deleted file mode 100644 index 8d69a8fab..000000000 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/data/io/PlatformPathProducer.jvm.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.dimension.flare.data.io - -import dev.dimension.flare.common.FileSystemUtilsExt -import okio.Path -import okio.Path.Companion.toOkioPath - -public actual class PlatformPathProducer { - public actual fun dataStoreFile(fileName: String): Path = FileSystemUtilsExt.flareDirectory().toOkioPath().resolve(fileName) -} diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.jvm.kt new file mode 100644 index 000000000..c947ccc9f --- /dev/null +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/data/repository/DraftMediaStore.jvm.kt @@ -0,0 +1,16 @@ +package dev.dimension.flare.data.repository + +import dev.dimension.flare.common.FileItem +import dev.dimension.flare.common.FileType +import java.io.File + +internal actual fun draftFileItem( + path: String, + name: String?, + type: FileType, +): FileItem = + FileItem( + name = name, + type = type, + file = File(path), + ) diff --git a/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt b/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt index f4d48eec3..2b0710abe 100644 --- a/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt +++ b/shared/src/jvmMain/kotlin/dev/dimension/flare/di/PlatformModule.jvm.kt @@ -3,6 +3,7 @@ package dev.dimension.flare.di import dev.dimension.flare.common.OnDeviceAI import dev.dimension.flare.data.database.DriverFactory import dev.dimension.flare.data.datastore.AppDataStore +import dev.dimension.flare.data.io.JvmPlatformPathProducer import dev.dimension.flare.data.io.PlatformPathProducer import dev.dimension.flare.shared.image.ImageCompressor import dev.dimension.flare.shared.image.JvmImageCompressor @@ -17,7 +18,7 @@ internal actual val platformModule: Module = module { singleOf(::AppDataStore) singleOf(::DriverFactory) - singleOf(::PlatformPathProducer) + singleOf(::JvmPlatformPathProducer) bind PlatformPathProducer::class singleOf(::JVMFormatter) bind PlatformFormatter::class singleOf(::JvmImageCompressor) bind ImageCompressor::class singleOf(::OnDeviceAI) diff --git a/shared/src/jvmTest/kotlin/dev/dimension/flare/TestFileHelper.jvm.kt b/shared/src/jvmTest/kotlin/dev/dimension/flare/TestFileHelper.jvm.kt new file mode 100644 index 000000000..d45bafca0 --- /dev/null +++ b/shared/src/jvmTest/kotlin/dev/dimension/flare/TestFileHelper.jvm.kt @@ -0,0 +1,27 @@ +package dev.dimension.flare + +import dev.dimension.flare.common.FileItem +import dev.dimension.flare.common.FileType +import okio.Path +import okio.Path.Companion.toPath +import java.io.File +import java.nio.file.Files +import kotlin.uuid.Uuid + +internal actual fun createTestRootPath(): Path = Files.createTempDirectory("draft-media-store-test").toString().toPath() + +internal actual fun deleteTestRootPath(path: Path) { + File(path.toString()).deleteRecursively() +} + +internal actual fun createTestFileItem( + root: Path, + name: String?, + bytes: ByteArray, + type: FileType, +): FileItem { + val file = File(root.toString(), "source_${Uuid.random()}.bin") + file.parentFile?.mkdirs() + file.writeBytes(bytes) + return FileItem(file, name = name, type = type) +} From 901e954a8e7ef2a764c6ec224847c55cf9fdd832 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Tue, 10 Mar 2026 23:41:24 +0900 Subject: [PATCH 2/4] fix windows test --- .../data/repository/DraftMediaStoreTest.kt | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftMediaStoreTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftMediaStoreTest.kt index 61cfa329e..da0243052 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftMediaStoreTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/repository/DraftMediaStoreTest.kt @@ -179,7 +179,12 @@ class DraftMediaStoreTest { assertEquals("", persisted[1].fileName) persisted.forEach { assertTrue(fileSystem.exists(it.cachePath.toPath())) - assertTrue(it.cachePath.substringAfterLast('/').matches(Regex("\\d+_.+\\.bin"))) + assertTrue( + it.cachePath + .toPath() + .name + .matches(Regex("\\d+_.+\\.bin")), + ) } } @@ -197,7 +202,14 @@ class DraftMediaStoreTest { assertEquals(1, persisted.size) assertTrue(fileSystem.exists(persisted.single().cachePath.toPath())) - assertEquals("0_a__b__c___.png", persisted.single().cachePath.substringAfterLast('/')) + assertEquals( + "0_a__b__c___.png", + persisted + .single() + .cachePath + .toPath() + .name, + ) } @Test @@ -213,7 +225,14 @@ class DraftMediaStoreTest { ), ) - assertEquals("0______file_.png__", persisted.single().cachePath.substringAfterLast('/')) + assertEquals( + "0______file_.png__", + persisted + .single() + .cachePath + .toPath() + .name, + ) assertTrue(fileSystem.exists(persisted.single().cachePath.toPath())) } From d7a1732a4ce2364324363131ee401ef3d2222ce7 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 11 Mar 2026 02:02:01 +0900 Subject: [PATCH 3/4] [WIP] add draft ui for desktop --- .../flare/ui/screen/compose/ComposeScreen.kt | 2 +- .../main/composeResources/values/strings.xml | 6 + .../dev/dimension/flare/ui/route/Route.kt | 2 + .../dev/dimension/flare/ui/route/Router.kt | 16 + .../flare/ui/screen/compose/ComposeDialog.kt | 47 +- .../flare/ui/screen/compose/DraftBoxScreen.kt | 232 +++++++++ .../ui/screen/settings/SettingsScreen.kt | 20 + gradle.properties | 5 +- iosApp/flare/UI/Screen/ComposeScreen.swift | 2 +- .../dimension/flare/DatabaseHelper.android.kt | 6 +- .../dimension/flare/DatabaseHelper.apple.kt | 13 +- .../SerializationFormatBenchmarkAppleTest.kt | 441 ++++++++++++++++ .../dev/dimension/flare/common/FlowExt.kt | 19 + .../flare/common/ImmutableListWrapper.kt | 17 - .../datasource/mastodon/MastodonDataSource.kt | 13 +- .../datasource/microblog/ComposeConfig.kt | 3 + .../datasource/misskey/MisskeyDataSource.kt | 13 +- .../data/datasource/vvo/VVODataSource.kt | 10 +- .../dev/dimension/flare/di/CommonModule.kt | 2 + .../dev/dimension/flare/ui/model/UiDraft.kt | 46 ++ .../dev/dimension/flare/ui/model/UiEmoji.kt | 2 + .../ui/presenter/compose/ComposePresenter.kt | 489 +++++++++--------- .../ui/presenter/compose/DraftBoxPresenter.kt | 151 ++++++ .../presenter/compose/InitialTextResolver.kt | 79 +++ .../presenter/compose/RestoreDraftUseCase.kt | 94 ++++ .../ui/presenter/compose/SendDraftUseCase.kt | 36 +- .../status/action/AddReactionPresenter.kt | 2 +- .../dev/dimension/flare/DatabaseHelper.kt | 5 +- .../compose/InitialTextResolverTest.kt | 177 +++++++ .../dev/dimension/flare/DatabaseHelper.jvm.kt | 13 +- .../SerializationFormatBenchmarkTest.kt | 464 +++++++++++++++++ 31 files changed, 2086 insertions(+), 341 deletions(-) create mode 100644 desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/DraftBoxScreen.kt create mode 100644 shared/src/appleTest/kotlin/dev/dimension/flare/common/SerializationFormatBenchmarkAppleTest.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/common/FlowExt.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiDraft.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/DraftBoxPresenter.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolver.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/RestoreDraftUseCase.kt create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolverTest.kt create mode 100644 shared/src/jvmTest/kotlin/dev/dimension/flare/common/SerializationFormatBenchmarkTest.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt index 05b53b697..c822fdad6 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt @@ -1086,7 +1086,7 @@ private fun composePresenter( }, language = languageState.takeSuccess()?.selectedLanguage.orEmpty(), ) - state.send(data, state.draftGroupId) + state.send(data) } } } diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 49ae3864a..f886bfa2d 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -148,6 +148,8 @@ Customize the appearance of post Customize the look and feel of Flare Storage + Draft Box + View sending, failed, and unsent drafts Manage Flare\'s storage About Learn more about Flare @@ -348,6 +350,10 @@ View the app log RSS Management Manage RSS feeds + Draft Box + No drafts yet + Retry + Edit Copied to clipboard diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index fa6affd10..050ce4bd9 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -40,6 +40,8 @@ internal sealed interface Route : NavKey { data object Settings : ScreenRoute + data object DraftBox : ScreenRoute + data class Profile( val accountType: AccountType, val userKey: MicroBlogKey, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index fbbaf2cd5..58f7de187 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -46,6 +46,7 @@ import dev.dimension.flare.ui.route.Route.Search import dev.dimension.flare.ui.route.Route.Timeline import dev.dimension.flare.ui.route.WindowSceneStrategy.Companion.window import dev.dimension.flare.ui.screen.compose.ComposeDialog +import dev.dimension.flare.ui.screen.compose.DraftBoxScreen import dev.dimension.flare.ui.screen.dm.DmConversationScreen import dev.dimension.flare.ui.screen.dm.DmListScreen import dev.dimension.flare.ui.screen.dm.UserDMConversationScreen @@ -520,6 +521,9 @@ internal fun WindowScope.Router( toLogin = { navigate(Route.ServiceSelect) }, + toDraftBox = { + navigate(Route.DraftBox) + }, toLocalCache = { navigate(Route.LocalCache) }, @@ -532,6 +536,18 @@ internal fun WindowScope.Router( ) } + entry { + DraftBoxScreen( + onEdit = { _, accountKey -> + navigate( + Route.Compose.New( + accountType = Specific(accountKey = accountKey), + ), + ) + }, + ) + } + entry { AppLoggingScreen() } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt index 32648145d..d3399c1c2 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt @@ -126,11 +126,6 @@ import io.github.composefluent.component.Text import io.github.composefluent.component.TextField import io.github.composefluent.component.TextFieldDefaults import io.github.composefluent.surface.Card -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import moe.tlaster.precompose.molecule.producePresenter -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource import java.io.File import java.util.Locale import kotlin.time.Duration @@ -138,6 +133,11 @@ import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.uuid.ExperimentalUuidApi +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource private val imageExtensions = setOf("png", "jpg", "jpeg", "gif", "bmp") private val videoExtensions = setOf("mp4", "mov", "avi", "mkv", "webm") @@ -222,38 +222,36 @@ fun ComposeDialog( .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - state.state.selectedUsers.onSuccess { selectedUsers -> - for (i in 0 until selectedUsers.size) { - val (user, account) = selectedUsers[i] - user.onSuccess { + state.state.selectedUsers.onSuccess { users -> + users.forEach { userState -> + userState.onSuccess { user -> PillButton( // onClick = { // state.state.selectAccount(account) // }, selected = false, onSelectedChanged = { - state.state.selectAccount(account) + state.state.selectAccount(user.key) }, content = { - AvatarComponent(it.avatar, size = 24.dp) - Text(it.handle.canonical) + AvatarComponent(user.avatar, size = 24.dp) + Text(user.handle.canonical) }, ) } } - state.state.otherAccounts.onSuccess { others -> - if (others.size > 0) { + state.state.otherUsers.onSuccess { others -> + if (others.isNotEmpty()) { MenuFlyoutContainer( flyout = { - for (i in 0 until others.size) { - val (user, account) = others[i] - user.onSuccess { data -> + others.forEach { userState -> + userState.onSuccess { data -> MenuFlyoutItem( text = { Text(text = data.handle.canonical) }, onClick = { - state.state.selectAccount(account) + state.state.selectAccount(data.key) }, icon = { AvatarComponent( @@ -752,19 +750,10 @@ fun ComposeDialog( AnimatedVisibility(emojis.size > 0) { FlyoutContainer( flyout = { - val actualAccountType = - remember( - state.state.selectedAccounts, - ) { - state.state.selectedAccounts - .firstOrNull() - ?.accountKey - ?.let(AccountType::Specific) - } EmojiPicker( data = emojis.data, onEmojiSelected = state::selectEmoji, - accountType = actualAccountType ?: accountType, + accountType = emojis.accountType, modifier = Modifier .sizeIn( @@ -1032,7 +1021,7 @@ private fun composePresenter( }, language = languageState.takeSuccess()?.selectedLanguage.orEmpty(), ) - state.send(data, state.draftGroupId) + state.send(data, "") // cleanup textFieldState.edit { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/DraftBoxScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/DraftBoxScreen.kt new file mode 100644 index 000000000..23505b49c --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/DraftBoxScreen.kt @@ -0,0 +1,232 @@ +package dev.dimension.flare.ui.screen.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import compose.icons.FontAwesomeIcons +import compose.icons.fontawesomeicons.Solid +import compose.icons.fontawesomeicons.solid.ArrowsRotate +import compose.icons.fontawesomeicons.solid.Pen +import compose.icons.fontawesomeicons.solid.TriangleExclamation +import dev.dimension.flare.LocalWindowPadding +import dev.dimension.flare.Res +import dev.dimension.flare.draft_box_edit +import dev.dimension.flare.draft_box_empty +import dev.dimension.flare.draft_box_retry +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.FAIcon +import dev.dimension.flare.ui.component.FlareScrollBar +import dev.dimension.flare.ui.component.NetworkImage +import dev.dimension.flare.ui.presenter.compose.DraftBoxPresenter +import dev.dimension.flare.ui.model.UiDraft +import dev.dimension.flare.ui.model.UiDraftMediaType +import dev.dimension.flare.ui.model.UiDraftStatus +import dev.dimension.flare.ui.model.primaryAccountKey +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.Button +import io.github.composefluent.component.Text +import io.github.composefluent.surface.Card +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun DraftBoxScreen( + onEdit: (String, MicroBlogKey) -> Unit = { _, _ -> }, +) { + val state by producePresenter { + remember { DraftBoxPresenter() }.invoke() + } + val listState = rememberLazyListState() + FlareScrollBar( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = screenHorizontalPadding, + end = screenHorizontalPadding, + top = LocalWindowPadding.current.calculateTopPadding(), + bottom = LocalWindowPadding.current.calculateBottomPadding(), + ), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (state.items.isEmpty()) { + item { + Text( + text = stringResource(Res.string.draft_box_empty), + color = FluentTheme.colors.text.text.secondary, + ) + } + } else { + items(state.items, key = { it.groupId }) { item -> + DraftBoxCard( + item = item, + onRetry = { state.retry(item.groupId) }, + onEdit = { item.primaryAccountKey?.let { accountKey -> onEdit(item.groupId, accountKey) } }, + ) + } + } + } + } +} + +@Composable +private fun DraftBoxCard( + item: UiDraft, + onRetry: () -> Unit, + onEdit: () -> Unit, +) { + val disabled = item.status == UiDraftStatus.SENDING + Card( + onClick = onEdit, + disabled = disabled, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + item.accounts.forEach { account -> + AvatarComponent( + data = account.avatar, + size = 22.dp, + ) + } + } + if (item.status == UiDraftStatus.FAILED) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.TriangleExclamation, + contentDescription = null, + tint = FluentTheme.colors.system.critical, + modifier = Modifier.align(Alignment.TopEnd), + ) + } + } + + item.data.spoilerText + ?.takeIf { it.isNotBlank() } + ?.let { spoilerText -> + Text( + text = spoilerText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = FluentTheme.colors.text.text.secondary, + ) + } + + item.data.content + .takeIf { it.isNotBlank() } + ?.let { text -> + Text( + text = text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + if (item.medias.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item.medias.take(4).forEach { media -> + when (media.type) { + UiDraftMediaType.IMAGE -> + NetworkImage( + model = media.cachePath, + contentDescription = null, + modifier = Modifier.size(60.dp), + contentScale = ContentScale.Crop, + ) + + UiDraftMediaType.VIDEO, + UiDraftMediaType.OTHER -> + Box( + modifier = + Modifier + .size(60.dp) + .alpha(0.75f), + ) { + FAIcon( + imageVector = + if (media.type == UiDraftMediaType.VIDEO) { + FontAwesomeIcons.Solid.ArrowsRotate + } else { + FontAwesomeIcons.Solid.Pen + }, + contentDescription = null, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + if (item.status == UiDraftStatus.FAILED) { + AccentButton(onClick = onRetry) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.ArrowsRotate, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text(stringResource(Res.string.draft_box_retry)) + } + } + Spacer(modifier = Modifier.width(8.dp)) + } + if (item.status != UiDraftStatus.SENDING) { + Button(onClick = onEdit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + FAIcon( + imageVector = FontAwesomeIcons.Solid.Pen, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text(stringResource(Res.string.draft_box_edit)) + } + } + } + } + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 3a35a730b..9e5cce9a0 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -155,6 +155,8 @@ import dev.dimension.flare.settings_storage_clear_database import dev.dimension.flare.settings_storage_clear_database_description import dev.dimension.flare.settings_storage_clear_image_cache import dev.dimension.flare.settings_storage_clear_image_cache_description +import dev.dimension.flare.settings_draft_box_description +import dev.dimension.flare.settings_draft_box_title import dev.dimension.flare.settings_storage_export_data import dev.dimension.flare.settings_storage_export_data_description import dev.dimension.flare.settings_storage_import_data @@ -221,6 +223,7 @@ import java.util.Locale @Composable internal fun SettingsScreen( toLogin: () -> Unit, + toDraftBox: () -> Unit, toLocalCache: () -> Unit, toAppLog: () -> Unit, toRSSManagement: () -> Unit, @@ -845,6 +848,23 @@ internal fun SettingsScreen( } Header(stringResource(Res.string.settings_storage_title)) + CardExpanderItem( + onClick = toDraftBox, + heading = { + Text(stringResource(Res.string.settings_draft_box_title)) + }, + trailing = { + FAIcon( + imageVector = FontAwesomeIcons.Solid.AngleRight, + contentDescription = null, + modifier = Modifier.size(12.dp), + ) + }, + caption = { + Text(stringResource(Res.string.settings_draft_box_description)) + }, + icon = null, + ) AnimatedVisibility( state.accountState.activeAccount.isSuccess, ) { diff --git a/gradle.properties b/gradle.properties index 23e234e4d..5ccbd9da4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx8g -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx32g -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true @@ -6,9 +6,8 @@ android.nonTransitiveRClass=true #kotlin.experimental.tryK2=true android.lint.useK2Uast=true kotlin.native.cacheKind=none -# https://github.com/ajalt/clikt/discussions/571 -kotlin.native.cacheKind.linuxX64=none kotlin.native.binary.gc=cms +kotlin.incremental.native=true #MPP kotlin.mpp.stability.nowarn=true diff --git a/iosApp/flare/UI/Screen/ComposeScreen.swift b/iosApp/flare/UI/Screen/ComposeScreen.swift index 5bd7855eb..2e0e4555f 100644 --- a/iosApp/flare/UI/Screen/ComposeScreen.swift +++ b/iosApp/flare/UI/Screen/ComposeScreen.swift @@ -453,7 +453,7 @@ struct ComposeScreen: View { private func send() { let data = getComposeData() - presenter.state.send(data: data, groupId: presenter.state.draftGroupId) + presenter.state.send(data: data) dismiss() } diff --git a/shared/src/androidHostTest/kotlin/dev/dimension/flare/DatabaseHelper.android.kt b/shared/src/androidHostTest/kotlin/dev/dimension/flare/DatabaseHelper.android.kt index 2e54a20e1..330d22892 100644 --- a/shared/src/androidHostTest/kotlin/dev/dimension/flare/DatabaseHelper.android.kt +++ b/shared/src/androidHostTest/kotlin/dev/dimension/flare/DatabaseHelper.android.kt @@ -6,12 +6,14 @@ import androidx.test.platform.app.InstrumentationRegistry import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import kotlin.test.Ignore +import kotlin.reflect.KClass -internal actual inline fun Room.memoryDatabaseBuilder(): RoomDatabase.Builder = +internal actual fun Room.memoryDatabaseBuilder(databaseClass: KClass): RoomDatabase.Builder = Room.inMemoryDatabaseBuilder( InstrumentationRegistry.getInstrumentation().context, + databaseClass.java, ) @RunWith(RobolectricTestRunner::class) @Ignore -actual open class RobolectricTest actual constructor() \ No newline at end of file +actual open class RobolectricTest actual constructor() diff --git a/shared/src/appleTest/kotlin/dev/dimension/flare/DatabaseHelper.apple.kt b/shared/src/appleTest/kotlin/dev/dimension/flare/DatabaseHelper.apple.kt index cd0ab96a3..751ecd3c9 100644 --- a/shared/src/appleTest/kotlin/dev/dimension/flare/DatabaseHelper.apple.kt +++ b/shared/src/appleTest/kotlin/dev/dimension/flare/DatabaseHelper.apple.kt @@ -2,7 +2,18 @@ package dev.dimension.flare import androidx.room.Room import androidx.room.RoomDatabase +import dev.dimension.flare.data.database.app.AppDatabase +import dev.dimension.flare.data.database.app.AppDatabaseConstructor +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.CacheDatabaseConstructor +import kotlin.reflect.KClass -internal actual inline fun Room.memoryDatabaseBuilder(): RoomDatabase.Builder = Room.inMemoryDatabaseBuilder() +@Suppress("UNCHECKED_CAST") +internal actual fun Room.memoryDatabaseBuilder(databaseClass: KClass): RoomDatabase.Builder = + when (databaseClass) { + AppDatabase::class -> Room.inMemoryDatabaseBuilder(factory = AppDatabaseConstructor::initialize) + CacheDatabase::class -> Room.inMemoryDatabaseBuilder(factory = CacheDatabaseConstructor::initialize) + else -> error("Unsupported test database: ${databaseClass.qualifiedName}") + } as RoomDatabase.Builder actual open class RobolectricTest actual constructor() diff --git a/shared/src/appleTest/kotlin/dev/dimension/flare/common/SerializationFormatBenchmarkAppleTest.kt b/shared/src/appleTest/kotlin/dev/dimension/flare/common/SerializationFormatBenchmarkAppleTest.kt new file mode 100644 index 000000000..35b96ae7e --- /dev/null +++ b/shared/src/appleTest/kotlin/dev/dimension/flare/common/SerializationFormatBenchmarkAppleTest.kt @@ -0,0 +1,441 @@ +package dev.dimension.flare.common + +import com.fleeksoft.ksoup.nodes.Element +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiCard +import dev.dimension.flare.ui.model.UiHandle +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiNumber +import dev.dimension.flare.ui.model.UiPoll +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.render.toUi +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.protobuf.ProtoBuf +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Clock +import kotlin.time.measureTime + +@OptIn(ExperimentalSerializationApi::class) +class SerializationFormatBenchmarkAppleTest { + @BeforeTest + fun setUp() { + startKoin { + modules( + module { + single { TestFormatter() } + }, + ) + } + } + + @AfterTest + fun tearDown() { + stopKoin() + } + + @Test + fun compareJsonAndProtoBufForStoredTimelinePayloadOnApple() = + runTest { + val warmupIterations = DEFAULT_WARMUP_ITERATIONS + val benchmarkIterations = DEFAULT_BENCHMARK_ITERATIONS + val payload = createStoredPayload() + val serializer = UiTimelineV2.serializer() + + val jsonPayload = JSON.encodeToString(serializer, payload) + val protoPayload = ProtoBuf.encodeToByteArray(serializer, payload) + + verifyDecode(payload, JSON.decodeFromString(serializer, jsonPayload)) + verifyDecode(payload, ProtoBuf.decodeFromByteArray(serializer, protoPayload)) + + val jsonEncode = benchmarkStringEncoding(payload, serializer, warmupIterations, benchmarkIterations) + val jsonDecode = benchmarkStringDecoding(jsonPayload, serializer, warmupIterations, benchmarkIterations) + val protoEncode = benchmarkBytesEncoding(payload, serializer, warmupIterations, benchmarkIterations) + val protoDecode = benchmarkBytesDecoding(protoPayload, serializer, warmupIterations, benchmarkIterations) + + println( + buildString { + appendLine("Serialization benchmark (Apple)") + appendLine("Payload type: ${payload.itemType}") + appendLine("Warmup iterations: $warmupIterations") + appendLine("Benchmark iterations: $benchmarkIterations") + appendLine("JSON size: ${jsonPayload.encodeToByteArray().size} bytes") + appendLine("ProtoBuf size: ${protoPayload.size} bytes") + appendLine("JSON encode avg: ${jsonEncode.averageMicros.formatMicros()} us") + appendLine("JSON decode avg: ${jsonDecode.averageMicros.formatMicros()} us") + appendLine("ProtoBuf encode avg: ${protoEncode.averageMicros.formatMicros()} us") + appendLine("ProtoBuf decode avg: ${protoDecode.averageMicros.formatMicros()} us") + appendLine( + "Checksums: json=${jsonEncode.checksum}/${jsonDecode.checksum}, proto=${protoEncode.checksum}/${protoDecode.checksum}", + ) + }, + ) + } + + private fun benchmarkStringEncoding( + payload: UiTimelineV2, + serializer: kotlinx.serialization.KSerializer, + warmupIterations: Int, + benchmarkIterations: Int, + ): BenchmarkResult { + var checksum = 0L + repeat(warmupIterations) { + val value = JSON.encodeToString(serializer, payload) + checksum += value.length.toLong() + } + val totalNanos = + measureTime { + repeat(benchmarkIterations) { + val value = JSON.encodeToString(serializer, payload) + checksum = checksum * 31 + value.length + } + }.inWholeNanoseconds + return BenchmarkResult(totalNanos = totalNanos, iterations = benchmarkIterations, checksum = checksum) + } + + private fun benchmarkStringDecoding( + payload: String, + serializer: kotlinx.serialization.KSerializer, + warmupIterations: Int, + benchmarkIterations: Int, + ): BenchmarkResult { + var checksum = 0L + repeat(warmupIterations) { + val value = JSON.decodeFromString(serializer, payload) + checksum += value.statusKey.hashCode().toLong() + } + val totalNanos = + measureTime { + repeat(benchmarkIterations) { + val value = JSON.decodeFromString(serializer, payload) + checksum = checksum * 31 + value.statusKey.hashCode() + } + }.inWholeNanoseconds + return BenchmarkResult(totalNanos = totalNanos, iterations = benchmarkIterations, checksum = checksum) + } + + private fun benchmarkBytesEncoding( + payload: UiTimelineV2, + serializer: kotlinx.serialization.KSerializer, + warmupIterations: Int, + benchmarkIterations: Int, + ): BenchmarkResult { + var checksum = 0L + repeat(warmupIterations) { + val value = ProtoBuf.encodeToByteArray(serializer, payload) + checksum += value.size.toLong() + } + val totalNanos = + measureTime { + repeat(benchmarkIterations) { + val value = ProtoBuf.encodeToByteArray(serializer, payload) + checksum = checksum * 31 + value.size + } + }.inWholeNanoseconds + return BenchmarkResult(totalNanos = totalNanos, iterations = benchmarkIterations, checksum = checksum) + } + + private fun benchmarkBytesDecoding( + payload: ByteArray, + serializer: kotlinx.serialization.KSerializer, + warmupIterations: Int, + benchmarkIterations: Int, + ): BenchmarkResult { + var checksum = 0L + repeat(warmupIterations) { + val value = ProtoBuf.decodeFromByteArray(serializer, payload) + checksum += value.statusKey.hashCode().toLong() + } + val totalNanos = + measureTime { + repeat(benchmarkIterations) { + val value = ProtoBuf.decodeFromByteArray(serializer, payload) + checksum = checksum * 31 + value.statusKey.hashCode() + } + }.inWholeNanoseconds + return BenchmarkResult(totalNanos = totalNanos, iterations = benchmarkIterations, checksum = checksum) + } + + private fun verifyDecode( + expected: UiTimelineV2, + actual: UiTimelineV2, + ) { + assertEquals(expected.itemType, actual.itemType) + assertEquals(expected.statusKey, actual.statusKey) + val expectedPost = assertIs(expected) + val actualPost = assertIs(actual) + assertEquals(expectedPost.content.raw, actualPost.content.raw) + assertEquals(expectedPost.actions.size, actualPost.actions.size) + assertEquals(expectedPost.images.size, actualPost.images.size) + assertEquals(expectedPost.references.size, actualPost.references.size) + assertEquals(expectedPost.emojiReactions.size, actualPost.emojiReactions.size) + assertEquals(expectedPost.poll?.options?.size, actualPost.poll?.options?.size) + } + + private suspend fun createStoredPayload(): UiTimelineV2 { + val accountKey = MicroBlogKey(id = "benchmark-account", host = "bench.example") + val rootUser = createUser(MicroBlogKey(id = "root-user", host = "bench.example"), "Root User") + val quoteUser = createUser(MicroBlogKey(id = "quote-user", host = "bench.example"), "Quote User") + val parentUser = createUser(MicroBlogKey(id = "parent-user", host = "bench.example"), "Parent User") + val repostUser = createUser(MicroBlogKey(id = "repost-user", host = "bench.example"), "Repost User") + + val quote = + createPost( + accountKey = accountKey, + user = quoteUser, + statusKey = MicroBlogKey(id = "quote-status", host = "bench.example"), + text = "Quoted content with enough text to resemble a real payload.", + mediaCount = 2, + ) + val parent = + createPost( + accountKey = accountKey, + user = parentUser, + statusKey = MicroBlogKey(id = "parent-status", host = "bench.example"), + text = "Parent content that becomes a stored reference after sanitization.", + mediaCount = 1, + ) + val repost = + createPost( + accountKey = accountKey, + user = repostUser, + statusKey = MicroBlogKey(id = "repost-status", host = "bench.example"), + text = "Reposted content with a card and poll.", + mediaCount = 3, + ) + + val root = + createPost( + accountKey = accountKey, + user = rootUser, + statusKey = MicroBlogKey(id = "root-status", host = "bench.example"), + text = + "Root content with multiple attachments, actions, reactions, and a poll to approximate a stored timeline entry.", + quote = listOf(quote), + parents = listOf(parent), + internalRepost = repost, + mediaCount = 4, + ) + + val stored = + TimelinePagingMapper + .toDb(root, pagingKey = "benchmark") + .status + .status + .data + .content + val storedPost = assertIs(stored) + assertEquals(0, storedPost.parents.size) + assertEquals(0, storedPost.quote.size) + assertEquals(null, storedPost.internalRepost) + return stored + } + + private fun createUser( + key: MicroBlogKey, + name: String, + ): UiProfile = + UiProfile( + key = key, + handle = + UiHandle( + raw = key.id, + host = key.host, + ), + avatar = "https://${key.host}/${key.id}.png", + nameInternal = Element("span").apply { appendText(name) }.toUi(), + platformType = PlatformType.Mastodon, + clickEvent = ClickEvent.Noop, + banner = "https://${key.host}/${key.id}/banner.png", + description = + Element("p") + .apply { + appendText("Profile description for $name with links and some additional text.") + }.toUi(), + matrices = + UiProfile.Matrices( + fansCount = 1234, + followsCount = 567, + statusesCount = 8901, + platformFansCount = "1.2K", + ), + mark = persistentListOf(UiProfile.Mark.Verified, UiProfile.Mark.Bot), + bottomContent = + UiProfile.BottomContent.Fields( + fields = + persistentMapOf( + "Website" to Element("span").apply { appendText("https://${key.host}") }.toUi(), + "Location" to Element("span").apply { appendText("Benchmark City") }.toUi(), + ), + ), + ) + + private fun createPost( + accountKey: MicroBlogKey, + user: UiProfile, + statusKey: MicroBlogKey, + text: String, + quote: List = emptyList(), + parents: List = emptyList(), + internalRepost: UiTimelineV2.Post? = null, + mediaCount: Int, + ): UiTimelineV2.Post = + UiTimelineV2.Post( + message = null, + platformType = PlatformType.Mastodon, + images = + List(mediaCount) { index -> + UiMedia.Image( + url = "https://${statusKey.host}/${statusKey.id}/media-$index.jpg", + previewUrl = "https://${statusKey.host}/${statusKey.id}/media-$index-preview.jpg", + description = "image-$index", + height = 1080f, + width = 1920f, + sensitive = index % 2 == 0, + ) + }.toPersistentList(), + sensitive = false, + contentWarning = Element("span").apply { appendText("cw-$text") }.toUi(), + user = user, + quote = quote.toPersistentList(), + content = Element("span").apply { appendText(text) }.toUi(), + actions = createActions(statusKey), + poll = createPoll(statusKey, accountKey), + statusKey = statusKey, + card = + UiCard( + title = "Card for ${statusKey.id}", + description = "Card description for ${statusKey.id}", + media = + UiMedia.Image( + url = "https://${statusKey.host}/${statusKey.id}/card.jpg", + previewUrl = "https://${statusKey.host}/${statusKey.id}/card-preview.jpg", + description = "card", + height = 630f, + width = 1200f, + sensitive = false, + ), + url = "https://${statusKey.host}/${statusKey.id}", + ), + createdAt = Clock.System.now().toUi(), + emojiReactions = + listOf( + UiTimelineV2.Post.EmojiReaction( + name = ":flare:", + url = "https://${statusKey.host}/emoji/flare.png", + count = UiNumber(10), + clickEvent = ClickEvent.Noop, + isUnicode = false, + me = true, + ), + UiTimelineV2.Post.EmojiReaction( + name = "🔥", + url = "", + count = UiNumber(5), + clickEvent = ClickEvent.Noop, + isUnicode = true, + me = false, + ), + ).toPersistentList(), + sourceChannel = UiTimelineV2.Post.SourceChannel(id = "channel-${statusKey.id}", name = "Benchmark"), + visibility = UiTimelineV2.Post.Visibility.Public, + replyToHandle = "@reply@example.com", + references = persistentListOf(), + parents = parents.toPersistentList(), + internalRepost = internalRepost, + clickEvent = ClickEvent.Noop, + accountType = AccountType.Specific(accountKey), + ) + + private fun createActions(statusKey: MicroBlogKey) = + persistentListOf( + ActionMenu.Item( + updateKey = "reply-${statusKey.id}", + icon = UiIcon.Reply, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Reply), + count = UiNumber(100), + ), + ActionMenu.Group( + displayItem = + ActionMenu.Item( + updateKey = "like-${statusKey.id}", + icon = UiIcon.Like, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Like), + count = UiNumber(200), + ), + actions = + persistentListOf( + ActionMenu.Item( + updateKey = "bookmark-${statusKey.id}", + icon = UiIcon.Bookmark, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Bookmark), + ), + ActionMenu.Item( + updateKey = "share-${statusKey.id}", + icon = UiIcon.Share, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Share), + ), + ), + ), + ) + + private fun createPoll( + statusKey: MicroBlogKey, + accountKey: MicroBlogKey, + ) = UiPoll( + id = "poll-${statusKey.id}", + options = + listOf( + UiPoll.Option(title = "One", votesCount = 10, percentage = 0.2f), + UiPoll.Option(title = "Two", votesCount = 20, percentage = 0.4f), + UiPoll.Option(title = "Three", votesCount = 20, percentage = 0.4f), + ).toPersistentList(), + multiple = true, + ownVotes = persistentListOf(1), + voteEvent = + dev.dimension.flare.data.datasource.microblog.PostEvent.Mastodon.Vote( + id = "vote-${statusKey.id}", + accountKey = accountKey, + postKey = statusKey, + options = persistentListOf(1), + ), + expiresAt = Clock.System.now(), + ) + + private fun Double.formatMicros(): String { + val rounded = (this * 100.0).toLong() / 100.0 + return rounded.toString() + } + + private data class BenchmarkResult( + val totalNanos: Long, + val iterations: Int, + val checksum: Long, + ) { + val averageMicros: Double + get() = totalNanos.toDouble() / iterations.toDouble() / 1_000.0 + } + + private companion object { + const val DEFAULT_WARMUP_ITERATIONS = 200 + const val DEFAULT_BENCHMARK_ITERATIONS = 1_000 + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/FlowExt.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/FlowExt.kt new file mode 100644 index 000000000..dff302d85 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/FlowExt.kt @@ -0,0 +1,19 @@ +package dev.dimension.flare.common + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf + +@OptIn(ExperimentalCoroutinesApi::class) +internal inline fun Flow>>.combineLatestFlowLists(): Flow> = + flatMapLatest { flows -> + if (flows.isEmpty()) { + flowOf(emptyList()) + } else { + combine(flows) { array -> + array.toList() + } + } + } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/ImmutableListWrapper.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/ImmutableListWrapper.kt index 340fa0888..662c346b1 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/common/ImmutableListWrapper.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/ImmutableListWrapper.kt @@ -2,11 +2,6 @@ package dev.dimension.flare.common import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf @Immutable public data class ImmutableListWrapper( @@ -25,15 +20,3 @@ public data class ImmutableListWrapper( } internal fun ImmutableList.toImmutableListWrapper(): ImmutableListWrapper = ImmutableListWrapper(this) - -@OptIn(ExperimentalCoroutinesApi::class) -internal inline fun Flow>>.combineLatestFlowLists(): Flow> = - this.flatMapLatest { innerFlows -> - if (innerFlows.isEmpty()) { - flowOf(emptyList()) - } else { - combine(innerFlows) { latestValuesArray -> - latestValuesArray.toList() - } - } - } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt index d1c44ccb1..ac4a2b54c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt @@ -44,12 +44,12 @@ import dev.dimension.flare.ui.model.UiHashtag import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.compose.ComposeStatus +import kotlin.uuid.Uuid import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.map import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import kotlin.uuid.Uuid @OptIn(ExperimentalPagingApi::class) internal open class MastodonDataSource( @@ -135,14 +135,19 @@ internal open class MastodonDataSource( when (event) { is PostEvent.Mastodon.AcceptFollowRequest -> acceptFollowRequest(event, updater) + is PostEvent.Mastodon.Bookmark -> bookmark(event, updater) + is PostEvent.Mastodon.Like -> like(event, updater) + is PostEvent.Mastodon.Reblog -> reblog(event, updater) + is PostEvent.Mastodon.RejectFollowRequest -> rejectFollowRequest(event, updater) + is PostEvent.Mastodon.Vote -> vote(event, updater) } @@ -418,7 +423,11 @@ internal open class MastodonDataSource( } else { ComposeConfig.Poll(4) }, - emoji = ComposeConfig.Emoji(emojiHandler.emoji, mergeTag = "mastodon@${accountKey.host}"), + emoji = ComposeConfig.Emoji( + emojiHandler.emoji, + mergeTag = "mastodon@${accountKey.host}", + accountKey = accountKey, + ), contentWarning = ComposeConfig.ContentWarning, visibility = ComposeConfig.Visibility, language = ComposeConfig.Language(1), diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt index 9f4cbeae6..3def9f8f2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeConfig.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.data.datasource.microblog import androidx.compose.runtime.Immutable import dev.dimension.flare.common.CacheData +import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiEmoji import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -275,12 +276,14 @@ public data class ComposeConfig internal constructor( internal val emoji: CacheData>>, // Emojis picker can be merged only if their mergeTag is the same. val mergeTag: String, + val accountKey: MicroBlogKey, ) { internal fun merge(other: Emoji): Emoji? = if (mergeTag == other.mergeTag) { Emoji( emoji = emoji, mergeTag = mergeTag, + accountKey = accountKey, ) } else { null diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt index 430b4d763..2af60e1cc 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt @@ -191,7 +191,9 @@ internal class MisskeyDataSource( accountKey = accountKey, ), ), - isUnicode = !event.reaction.startsWith(':') && !event.reaction.endsWith(':'), + isUnicode = !event.reaction.startsWith(':') && !event.reaction.endsWith( + ':' + ), me = true, ), ) @@ -319,10 +321,11 @@ internal class MisskeyDataSource( when (request) { is PagingRequest.Prepend, is PagingRequest.Append, - -> + -> PagingResult( endOfPaginationReached = true, ) + PagingRequest.Refresh -> PagingResult( endOfPaginationReached = true, @@ -555,7 +558,11 @@ internal class MisskeyDataSource( allowMediaOnly = true, ), poll = ComposeConfig.Poll(9), - emoji = ComposeConfig.Emoji(emojiHandler.emoji, "misskey@${accountKey.host}"), + emoji = ComposeConfig.Emoji( + emojiHandler.emoji, + "misskey@${accountKey.host}", + accountKey = accountKey, + ), contentWarning = ComposeConfig.ContentWarning, visibility = ComposeConfig.Visibility, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt index 78a6395f6..f99a7e142 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt @@ -148,7 +148,7 @@ internal class VVODataSource( when (type) { NotificationFilter.All, NotificationFilter.Mention, - -> + -> MentionRemoteMediator( service = service, accountKey = accountKey, @@ -203,7 +203,7 @@ internal class VVODataSource( when (request) { is PagingRequest.Prepend, is PagingRequest.Append, - -> + -> return PagingResult( endOfPaginationReached = true, ) @@ -345,7 +345,11 @@ internal class VVODataSource( altTextMaxLength = -1, allowMediaOnly = false, ), - emoji = ComposeConfig.Emoji(emoji = emojiHandler.emoji, mergeTag = "vvo@${accountKey.host}"), + emoji = ComposeConfig.Emoji( + emoji = emojiHandler.emoji, + mergeTag = "vvo@${accountKey.host}", + accountKey = accountKey, + ), ) private suspend fun like(event: PostEvent.VVO.Like) { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt index 6c953eea3..f4fdfc6e6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt @@ -11,6 +11,7 @@ import dev.dimension.flare.data.repository.DraftRepository import dev.dimension.flare.data.repository.LocalFilterRepository import dev.dimension.flare.data.repository.SearchHistoryRepository import dev.dimension.flare.ui.presenter.compose.ComposeUseCase +import dev.dimension.flare.ui.presenter.compose.RestoreDraftUseCase import dev.dimension.flare.ui.presenter.compose.SaveDraftUseCase import dev.dimension.flare.ui.presenter.compose.SendDraftUseCase import kotlinx.coroutines.CoroutineScope @@ -30,6 +31,7 @@ internal val commonModule = singleOf(::LocalFilterRepository) single { CoroutineScope(Dispatchers.IO) } singleOf(::SaveDraftUseCase) + singleOf(::RestoreDraftUseCase) single { SendDraftUseCase( draftRepository = get(), diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiDraft.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiDraft.kt new file mode 100644 index 000000000..fa051ceb5 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiDraft.kt @@ -0,0 +1,46 @@ +package dev.dimension.flare.ui.model + +import androidx.compose.runtime.Immutable +import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.model.MicroBlogKey + +@Immutable +public data class UiDraft( + val groupId: String, + val status: UiDraftStatus, + val updatedAt: Long, + val accounts: List, + val data: ComposeData, + val medias: List, +) + +@Immutable +public data class UiDraftAccount( + val account: UiAccount, + val avatar: String? = null, +) + +@Immutable +public data class UiDraftMedia( + val cachePath: String, + val fileName: String?, + val type: UiDraftMediaType, + val altText: String?, +) + +@Immutable +public enum class UiDraftMediaType { + IMAGE, + VIDEO, + OTHER, +} + +@Immutable +public enum class UiDraftStatus { + SENDING, + FAILED, + DRAFT, +} + +public val UiDraft.primaryAccountKey: MicroBlogKey? + get() = accounts.firstOrNull()?.account?.accountKey diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiEmoji.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiEmoji.kt index d887cbb97..643e2e22c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiEmoji.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiEmoji.kt @@ -3,6 +3,7 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable import dev.dimension.flare.common.SerializableImmutableList import dev.dimension.flare.common.SerializableImmutableMap +import dev.dimension.flare.model.AccountType import kotlinx.serialization.Serializable @Serializable @@ -19,6 +20,7 @@ public data class UiEmoji internal constructor( @Immutable public data class EmojiData internal constructor( val data: SerializableImmutableMap>, + val accountType: AccountType, ) { private val list = data.toList() public val size: Int get() = data.size diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt index 89f73c8e2..0a94fb80c 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt @@ -3,53 +3,54 @@ package dev.dimension.flare.ui.presenter.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import dev.dimension.flare.common.ImmutableListWrapper -import dev.dimension.flare.common.collectAsState -import dev.dimension.flare.common.toImmutableListWrapper +import dev.dimension.flare.common.combineLatestFlowLists import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeType +import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource import dev.dimension.flare.data.datasource.microblog.datasource.UserDataSource import dev.dimension.flare.data.datastore.AppDataStore import dev.dimension.flare.data.repository.AccountRepository -import dev.dimension.flare.data.repository.accountProvider -import dev.dimension.flare.data.repository.accountServiceProvider -import dev.dimension.flare.data.repository.allAccountsPresenter -import dev.dimension.flare.data.repository.newDraftGroupId +import dev.dimension.flare.data.repository.accountServiceFlow import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.model.EmojiData -import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.UiTimelineV2 -import dev.dimension.flare.ui.model.flatMap -import dev.dimension.flare.ui.model.isSuccess +import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.flattenUiState import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.mapNotNull -import dev.dimension.flare.ui.model.merge -import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.model.takeSuccess -import dev.dimension.flare.ui.model.takeSuccessOr import dev.dimension.flare.ui.model.toUi import dev.dimension.flare.ui.presenter.PresenterBase -import dev.dimension.flare.ui.presenter.home.UserPresenter -import dev.dimension.flare.ui.presenter.status.StatusPresenter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +@OptIn(ExperimentalCoroutinesApi::class) public class ComposePresenter( - private val accountType: AccountType, + private val accountType: AccountType?, private val status: ComposeStatus? = null, ) : PresenterBase(), KoinComponent { @@ -57,197 +58,233 @@ public class ComposePresenter( private val accountRepository: AccountRepository by inject() private val appDataStore: AppDataStore by inject() - @Composable - override fun body(): ComposeState { - val userState = - remember(accountType) { - UserPresenter(accountType = accountType, userKey = null) - }.body().user - val accountState by accountProvider(accountType = accountType, repository = accountRepository) - val accounts by allAccountsPresenter(repository = accountRepository) - val selectedAccounts = - remember { - mutableStateListOf() + private val selectedAccountsKeyFlow by lazy { + MutableStateFlow>(persistentListOf()) + } + + private val enableCrossPostFlow by lazy { + selectedAccountsKeyFlow.map { accountKeys -> + accountKeys.size > 1 // && status == null + } + } + + private val selectedAccountsFlow by lazy { + selectedAccountsKeyFlow.map { accountKeys -> + accountKeys.map { key -> + accountRepository.getFlow(key) } - accountState.onSuccess { - LaunchedEffect(Unit) { - selectedAccounts.add(it) + }.combineLatestFlowLists().map { + it.mapNotNull { + it.takeSuccess() + }.toImmutableList() + } + } + + private val selectedAccountServicesFlow by lazy { + selectedAccountsKeyFlow.map { accountKeys -> + accountKeys.map { accountKey -> + accountServiceFlow( + accountType = AccountType.Specific(accountKey = accountKey), + repository = accountRepository, + ) } + }.combineLatestFlowLists().map { it.toImmutableList() } + } + + private val composeConfigFlow by lazy { + selectedAccountServicesFlow.map { services -> + services + .mapNotNull { + if (it is AuthenticatedMicroblogDataSource) { + it.composeConfig( + type = + when (status) { + is ComposeStatus.Quote -> ComposeType.Quote + is ComposeStatus.Reply -> ComposeType.Reply + null -> ComposeType.New + }, + ) + } else { + null + } + }.reduceOrNull { acc, config -> acc.merge(config) } ?: ComposeConfig() } - val statusState = - status?.let { status -> - remember(status.statusKey) { - StatusPresenter(accountType = accountType, statusKey = status.statusKey) - }.body().status + } + + private val selectedUsersFlow by lazy { + selectedAccountServicesFlow.map { services -> + services + .mapNotNull { service -> + if (service is UserDataSource && service is AuthenticatedMicroblogDataSource) { + service.userHandler.userById(service.accountKey.id).toUi() + } else { + null + } + } + }.combineLatestFlowLists().map { it.toImmutableList() } + } + + private val otherAccountsFlow by lazy { + combine( + accountRepository.allAccounts, + selectedAccountsKeyFlow, + statusFlow ?: flowOf(UiState.Error(Exception("No status for compose"))), + ) { allAccounts, selectedAccountKeys, status -> + val statusPlatform = status.takeSuccess()?.let { + it as? UiTimelineV2.Post + }?.platformType + allAccounts.filterNot { account -> + selectedAccountKeys.contains(account.accountKey) || + (statusPlatform != null && account.platformType != statusPlatform) + }.map { + it.accountKey } - val allUsers = - accounts.flatMap { data -> - accountState - .flatMap { current -> - statusState - ?.mapNotNull { - it as? UiTimelineV2.Post - }?.map { - current to listOf(it.platformType) - } ?: UiState.Success(current to PlatformType.entries.toList()) - }.map { (current, platforms) -> - data - .sortedBy { - it.accountKey != current.accountKey - }.filter { - it.platformType in platforms - }.map { account -> - accountServiceProvider( - accountType = AccountType.Specific(accountKey = account.accountKey), - repository = accountRepository, - ).flatMap { service -> - remember(account.accountKey) { - (service as UserDataSource).userHandler.userById(account.accountKey.id) - }.collectAsState() - .toUi() - } to account - }.toImmutableList() - .toImmutableListWrapper() + } + } + + private val otherUsersFlow by lazy { + otherAccountsFlow.map { keys -> + keys.map { key -> + accountServiceFlow( + accountType = AccountType.Specific(accountKey = key), + repository = accountRepository, + ).mapNotNull { + if (it is UserDataSource && it is AuthenticatedMicroblogDataSource) { + it.userHandler.userById(it.accountKey.id).toUi() + } else { + null } + }.flatMapLatest { it } } + }.combineLatestFlowLists().map { it.toImmutableList() } + } - val selectedUsers = - allUsers.map { - it - .toImmutableList() - .filter { - selectedAccounts.contains(it.second) - }.toImmutableList() - .toImmutableListWrapper() + private val emojiFlow by lazy { + composeConfigFlow.mapNotNull { config -> + config.emoji + }.flatMapLatest { emojiConfig -> + emojiConfig.emoji.toUi().map { emojiState -> + emojiState.map { emoji -> + EmojiData( + data = emoji, + accountType = AccountType.Specific(emojiConfig.accountKey), + ) + } } - val replyState = - statusState?.map { - if (it is UiTimelineV2.Post && it.platformType == PlatformType.VVo) { - it.quote.firstOrNull() ?: it + } + } + + private val textFlow by lazy { + MutableStateFlow("") + } + + private val mediaSizeFlow by lazy { + MutableStateFlow(0) + } + + private val remainingLengthFlow by lazy { + combine( + textFlow, + composeConfigFlow, + ) { text, config -> + config.text?.maxLength?.minus(text.length) ?: Int.MAX_VALUE + } + } + + private val canSendFlow by lazy { + combine( + textFlow, + mediaSizeFlow, + remainingLengthFlow, + selectedAccountsKeyFlow, + composeConfigFlow, + ) { text, mediaSize, remainingLength, selectedAccountKeys, composeConfig -> + (text.isNotBlank() && text.isNotEmpty() && selectedAccountKeys.isNotEmpty() && remainingLength >= 0) || + ((text.isEmpty() || text.isBlank()) && composeConfig.media?.allowMediaOnly == true && mediaSize > 0) + } + } + + + private val statusFlow by lazy { + if (status != null && accountType != null) { + accountServiceFlow( + accountType = accountType, + repository = accountRepository, + ).mapNotNull { + if (it is PostDataSource) { + it.postHandler.post(status.statusKey).toUi() } else { - it + null + } + }.flatMapLatest { it } + } else { + null + } + } + + private val replyStateFlow by lazy { + statusFlow?.map { statusState -> + statusState.map { post -> + if (post is UiTimelineV2.Post && post.platformType == PlatformType.VVo) { + post.quote.firstOrNull() ?: post + } else { + post } } - val remainingAccounts = - allUsers.map { - it - .toImmutableList() - .filter { - !selectedAccounts.contains(it.second) - }.toImmutableList() - .toImmutableListWrapper() - } - val enableCrossPost = - allUsers.map { - it.size > 1 // && status == null - } - val initialTextState = - if (statusState != null) { - userState.flatMap { user -> - remember(statusState) { - statusState.mapNotNull { timeline -> - val content = timeline - if (content is UiTimelineV2.Post) { - when (content.platformType) { - PlatformType.VVo -> { - if (content.quote.any() && status is ComposeStatus.Quote) { - InitialText( - text = "//@${content.user?.name?.raw}:${content.content.raw}", - cursorPosition = 0, - ) - } else { - null - } - } - PlatformType.Mastodon, PlatformType.Misskey -> { - if (status is ComposeStatus.Reply) { - val handleToAdd = mutableSetOf() - if (content.user?.key != selectedAccounts.firstOrNull()?.accountKey) { - content.user?.handle?.let { - handleToAdd.add(it.canonical) - } - } - content.content.data - .getElementsByAttributeValueStarting( - "href", - "flare://ProfileWithNameAndHost", - ).filter { - val href = it.attr("href") - val params = - href - .substringAfter("flare://ProfileWithNameAndHost/") - .substringBefore("?accountKey=") - .split('/') - val userName = params.getOrNull(0) - val host = params.getOrNull(1) - user.handle.canonical != "@$userName@$host" - }.filter { - it.text() != content.user?.handle?.canonical - }.forEach { - handleToAdd.add(it.text()) - } - val text = - buildString { - handleToAdd.distinct().forEach { - append("$it ") - } - } - InitialText( - text = text, - cursorPosition = text.length, - ) - } else { - null - } - } - else -> null - } - } else { - null - } + } + } + + private val initialTextFlow by lazy { + if (accountType is AccountType.Specific && status != null) { + statusFlow?.flatMapLatest { statusState -> + selectedUsersFlow.mapNotNull { + it.firstOrNull()?.takeSuccess() + }.map { user -> + statusState.mapNotNull { post -> + if (post is UiTimelineV2.Post) { + InitialTextResolver.resolve( + post = post, + composeStatus = status, + currentUserHandle = user.handle, + selectedAccountKey = accountType.accountKey, + ) + } else { + null } } } - } else { - null } + } else { + null + } + } - val services = - selectedAccounts.map { - accountServiceProvider(accountType = AccountType.Specific(accountKey = it.accountKey), repository = accountRepository) + @Composable + override fun body(): ComposeState { + val scope = rememberCoroutineScope() + val selectedUsers by selectedUsersFlow.collectAsUiState() + val remainingUsers by otherUsersFlow.collectAsUiState() + val emojiState by emojiFlow.flattenUiState() + val enableCrossPost by enableCrossPostFlow.collectAsUiState() + val composeConfig: UiState by composeConfigFlow.collectAsUiState() + val canSend by canSendFlow.collectAsState(false) + if (accountType != null && accountType is AccountType.Specific) { + LaunchedEffect(accountType) { + selectedAccountsKeyFlow.value = listOf(accountType.accountKey).toImmutableList() } - val composeConfig: UiState = - remember(services) { - services.merge().map { - it - .mapNotNull { - if (it is AuthenticatedMicroblogDataSource) { - it.composeConfig( - type = - when (status) { - is ComposeStatus.Quote -> ComposeType.Quote - is ComposeStatus.Reply -> ComposeType.Reply - null -> ComposeType.New - }, - ) - } else { - null - } - }.reduceOrNull { acc, config -> acc.merge(config) } ?: ComposeConfig() + } else { + // load active account + LaunchedEffect(Unit) { + accountRepository.activeAccount.firstOrNull()?.let { account -> + selectedAccountsKeyFlow.value = listOfNotNull(account.takeSuccess()?.accountKey) + .toImmutableList() } } + } + + val replyState = replyStateFlow?.flattenUiState()?.value + val initialTextState = initialTextFlow?.flattenUiState()?.value - val emojiState = - composeConfig - .mapNotNull { - it.emoji - }.flatMap { - it.emoji.collectAsState().toUi() - }.map { - remember(it) { - EmojiData(it) - } - } val visibilityState = composeConfig @@ -257,81 +294,53 @@ public class ComposePresenter( visibilityPresenter() } - var text by remember { - mutableStateOf("") - } - var mediaSize by remember { - mutableStateOf(0) - } - val draftGroupId = - remember { - newDraftGroupId() - } - val remainingLength = - composeConfig - .mapNotNull { - it.text - }.map { - it.maxLength - text.length - } - - val canSend = - remember(text, mediaSize, composeConfig) { - text.isNotBlank() && - text.isNotEmpty() && - accountState.isSuccess && - remainingLength.takeSuccessOr(0) >= 0 || - ( - (text.isEmpty() || text.isBlank()) && - composeConfig - .takeSuccess() - ?.media - ?.allowMediaOnly == true && - mediaSize > 0 - ) - } return object : ComposeState( canSend = canSend, - draftGroupId = draftGroupId, visibilityState = visibilityState, replyState = replyState, emojiState = emojiState, composeConfig = composeConfig, enableCrossPost = enableCrossPost, - selectedAccounts = selectedAccounts.toImmutableList(), selectedUsers = selectedUsers, - otherAccounts = remainingAccounts, + otherUsers = remainingUsers, initialTextState = initialTextState, ) { override fun send( data: ComposeData, groupId: String, ) { - composeUseCase.invoke( - accounts = selectedAccounts.toList(), - data = data, - groupId = groupId, - ) + scope.launch { + val selectedAccounts = selectedAccountsFlow.firstOrNull().orEmpty() + if (selectedAccounts.isNotEmpty()) { + composeUseCase.invoke( + accounts = selectedAccounts, + data = data, + groupId = groupId, + ) + } + } } - override fun selectAccount(account: UiAccount) { - if (selectedAccounts.contains(account)) { - if (selectedAccounts.size == 1) { + override fun selectAccount(accountKey: MicroBlogKey) { + if (selectedAccountsKeyFlow.value.contains(accountKey)) { + if (selectedAccountsKeyFlow.value.size == 1) { return } - selectedAccounts.remove(account) + selectedAccountsKeyFlow.value = + (selectedAccountsKeyFlow.value - accountKey).toImmutableList() } else { - selectedAccounts.add(account) + selectedAccountsKeyFlow.value = + (selectedAccountsKeyFlow.value + accountKey).toImmutableList() } } override fun setText(value: String) { - text = value + textFlow.value = value } override fun setMediaSize(value: Int) { - mediaSize = value + mediaSizeFlow.value = value } } } @@ -419,23 +428,21 @@ public sealed class ComposeStatus { @Immutable public abstract class ComposeState( public val canSend: Boolean, - public val draftGroupId: String, public val visibilityState: UiState, public val replyState: UiState?, public val initialTextState: UiState?, public val emojiState: UiState, public val composeConfig: UiState, public val enableCrossPost: UiState, - public val selectedAccounts: ImmutableList, - public val otherAccounts: UiState, UiAccount>>>, - public val selectedUsers: UiState, UiAccount>>>, + public val otherUsers: UiState>>, + public val selectedUsers: UiState>>, ) { public abstract fun send( data: ComposeData, groupId: String, ) - public abstract fun selectAccount(account: UiAccount) + public abstract fun selectAccount(accountKey: MicroBlogKey) public abstract fun setText(value: String) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/DraftBoxPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/DraftBoxPresenter.kt new file mode 100644 index 000000000..847b913d5 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/DraftBoxPresenter.kt @@ -0,0 +1,151 @@ +package dev.dimension.flare.ui.presenter.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import dev.dimension.flare.common.InAppNotification +import dev.dimension.flare.common.Message +import dev.dimension.flare.data.database.app.model.DraftMediaType +import dev.dimension.flare.data.repository.DraftRepository +import dev.dimension.flare.ui.model.UiDraft +import dev.dimension.flare.ui.model.UiDraftAccount +import dev.dimension.flare.ui.model.UiDraftMedia +import dev.dimension.flare.ui.model.UiDraftMediaType +import dev.dimension.flare.ui.model.UiDraftStatus +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.presenter.PresenterBase +import dev.dimension.flare.ui.presenter.settings.AccountsPresenter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +public class DraftBoxPresenter : + PresenterBase(), + KoinComponent { + private val draftRepository: DraftRepository by inject() + private val sendDraftUseCase: SendDraftUseCase by inject() + private val inAppNotification: InAppNotification by inject() + private val coroutineScope: CoroutineScope by inject() + + @Composable + override fun body(): DraftBoxState { + val visibleDrafts by draftRepository.visibleDrafts.collectAsState(emptyList()) + val sendingDrafts by draftRepository.sendingDrafts.collectAsState(emptyList()) + val accountsState = remember { AccountsPresenter() }.body() + val avatarMap = + remember( + accountsState.accounts, + ) { + accountsState.accounts + .takeSuccess() + ?.toImmutableList() + ?.associate { (account, profileState) -> + account.accountKey to profileState.takeSuccess()?.avatar + }.orEmpty() + } + + val items = + remember(visibleDrafts, sendingDrafts, avatarMap, accountsState.accounts) { + val accountMap = + accountsState.accounts + .takeSuccess() + ?.toImmutableList() + ?.associate { it.first.accountKey to it.first } + .orEmpty() + (visibleDrafts + sendingDrafts) + .associateBy { it.groupId } + .values + .sortedWith( + compareBy({ it.toUiDraftStatus().sortOrder }) + .thenByDescending { it.updatedAt }, + ).mapNotNull { draft -> + val accounts = + draft.targets.mapNotNull { target -> + accountMap[target.accountKey]?.let { account -> + UiDraftAccount( + account = account, + avatar = avatarMap[target.accountKey], + ) + } + } + if (accounts.isEmpty()) { + return@mapNotNull null + } + UiDraft( + groupId = draft.groupId, + status = draft.toUiDraftStatus(), + updatedAt = draft.updatedAt, + accounts = accounts, + data = draft.content.toComposeData(medias = emptyList()), + medias = + draft.medias + .map { media -> + UiDraftMedia( + cachePath = media.cachePath, + fileName = media.fileName, + type = + when (media.mediaType) { + DraftMediaType.IMAGE -> UiDraftMediaType.IMAGE + DraftMediaType.VIDEO -> UiDraftMediaType.VIDEO + DraftMediaType.OTHER -> UiDraftMediaType.OTHER + }, + altText = media.altText, + ) + }.toImmutableList(), + ) + }.toImmutableList() + } + + return object : DraftBoxState { + override val items: ImmutableList = items + + override fun retry(groupId: String) { + coroutineScope.launch { + sendDraftUseCase(groupId) { + when (it) { + is ComposeProgressState.Error -> + inAppNotification.onError(Message.Compose, it.throwable) + + is ComposeProgressState.Progress -> + if (it.max > 0) { + inAppNotification.onProgress( + Message.Compose, + it.current, + it.max, + ) + } + + ComposeProgressState.Success -> + inAppNotification.onSuccess(Message.Compose) + } + } + } + } + } + } +} + +public interface DraftBoxState { + public val items: ImmutableList + + public fun retry(groupId: String) +} + +private val UiDraftStatus.sortOrder: Int + get() = + when (this) { + UiDraftStatus.SENDING -> 0 + UiDraftStatus.FAILED -> 1 + UiDraftStatus.DRAFT -> 2 + } + +private fun dev.dimension.flare.data.repository.DraftGroup.toUiDraftStatus(): UiDraftStatus = + when { + targets.any { it.status == dev.dimension.flare.data.database.app.model.DraftTargetStatus.SENDING } -> UiDraftStatus.SENDING + targets.any { it.status == dev.dimension.flare.data.database.app.model.DraftTargetStatus.FAILED } -> UiDraftStatus.FAILED + else -> UiDraftStatus.DRAFT + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolver.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolver.kt new file mode 100644 index 000000000..422c02852 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolver.kt @@ -0,0 +1,79 @@ +package dev.dimension.flare.ui.presenter.compose + +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.UiHandle +import dev.dimension.flare.ui.model.UiTimelineV2 + +internal object InitialTextResolver { + fun resolve( + post: UiTimelineV2.Post, + composeStatus: ComposeStatus, + currentUserHandle: UiHandle, + selectedAccountKey: MicroBlogKey?, + ): InitialText? = + when (post.platformType) { + PlatformType.VVo -> resolveVVo(post, composeStatus) + PlatformType.Mastodon, PlatformType.Misskey -> + resolveMastodonMisskey(post, composeStatus, currentUserHandle, selectedAccountKey) + else -> null + } + + private fun resolveVVo( + post: UiTimelineV2.Post, + composeStatus: ComposeStatus, + ): InitialText? { + if (post.quote.any() && composeStatus is ComposeStatus.Quote) { + return InitialText( + text = "//@${post.user?.name?.raw}:${post.content.raw}", + cursorPosition = 0, + ) + } + return null + } + + private fun resolveMastodonMisskey( + post: UiTimelineV2.Post, + composeStatus: ComposeStatus, + currentUserHandle: UiHandle, + selectedAccountKey: MicroBlogKey?, + ): InitialText? { + if (composeStatus !is ComposeStatus.Reply) return null + + val handleToAdd = mutableSetOf() + if (post.user?.key != selectedAccountKey) { + post.user?.handle?.let { + handleToAdd.add(it.canonical) + } + } + post.content.data + .getElementsByAttributeValueStarting( + "href", + "flare://ProfileWithNameAndHost", + ).filter { + val href = it.attr("href") + val params = + href + .substringAfter("flare://ProfileWithNameAndHost/") + .substringBefore("?accountKey=") + .split('/') + val userName = params.getOrNull(0) + val host = params.getOrNull(1) + currentUserHandle.canonical != "@$userName@$host" + }.filter { + it.text() != post.user?.handle?.canonical + }.forEach { + handleToAdd.add(it.text()) + } + val text = + buildString { + handleToAdd.distinct().forEach { + append("$it ") + } + } + return InitialText( + text = text, + cursorPosition = text.length, + ) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/RestoreDraftUseCase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/RestoreDraftUseCase.kt new file mode 100644 index 000000000..f454632ea --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/RestoreDraftUseCase.kt @@ -0,0 +1,94 @@ +package dev.dimension.flare.ui.presenter.compose + +import dev.dimension.flare.data.database.app.model.DraftContent +import dev.dimension.flare.data.database.app.model.DraftMediaType +import dev.dimension.flare.data.repository.AccountRepository +import dev.dimension.flare.data.repository.DraftRepository +import dev.dimension.flare.ui.model.UiDraft +import dev.dimension.flare.ui.model.UiDraftAccount +import dev.dimension.flare.ui.model.UiDraftMedia +import dev.dimension.flare.ui.model.UiDraftMediaType +import dev.dimension.flare.ui.model.UiDraftStatus +import kotlinx.coroutines.flow.firstOrNull + +public class RestoreDraftUseCase internal constructor( + private val draftRepository: DraftRepository, + private val accountRepository: AccountRepository, +) { + public suspend operator fun invoke(groupId: String): UiDraft? { + val draft = draftRepository.draft(groupId).firstOrNull() ?: return null + val accounts = + draft.targets.mapNotNull { target -> + accountRepository.find(target.accountKey)?.let { + UiDraftAccount(account = it) + } + } + return UiDraft( + groupId = draft.groupId, + status = draft.toUiDraftStatus(), + updatedAt = draft.updatedAt, + accounts = accounts, + data = draft.content.toComposeData(medias = emptyList()), + medias = + draft.medias.map { media -> + UiDraftMedia( + cachePath = media.cachePath, + fileName = media.fileName, + type = + when (media.mediaType) { + DraftMediaType.IMAGE -> UiDraftMediaType.IMAGE + DraftMediaType.VIDEO -> UiDraftMediaType.VIDEO + DraftMediaType.OTHER -> UiDraftMediaType.OTHER + }, + altText = media.altText, + ) + }, + ) + } +} + +internal fun DraftContent.toComposeData( + medias: List, +): dev.dimension.flare.data.datasource.microblog.ComposeData = + dev.dimension.flare.data.datasource.microblog.ComposeData( + content = text, + visibility = visibility, + language = language, + medias = medias, + sensitive = sensitive, + spoilerText = spoilerText, + poll = + poll?.let { + dev.dimension.flare.data.datasource.microblog.ComposeData.Poll( + options = it.options, + expiredAfter = it.expiredAfter, + multiple = it.multiple, + ) + }, + localOnly = localOnly, + referenceStatus = + reference?.let { reference -> + dev.dimension.flare.data.datasource.microblog.ComposeData.ReferenceStatus( + data = null, + composeStatus = reference.toComposeStatus(), + ) + }, + ) + +internal fun DraftContent.DraftReference.toComposeStatus(): ComposeStatus = + when (type) { + dev.dimension.flare.data.database.app.model.DraftReferenceType.QUOTE -> ComposeStatus.Quote(statusKey) + dev.dimension.flare.data.database.app.model.DraftReferenceType.REPLY -> ComposeStatus.Reply(statusKey) + dev.dimension.flare.data.database.app.model.DraftReferenceType.VVO_COMMENT -> + ComposeStatus.VVOComment( + statusKey = statusKey, + rootId = requireNotNull(rootId), + ) + } + +private fun dev.dimension.flare.data.repository.DraftGroup.toUiDraftStatus(): UiDraftStatus = + when { + targets.any { it.status == dev.dimension.flare.data.database.app.model.DraftTargetStatus.SENDING } -> UiDraftStatus.SENDING + targets.any { it.status == dev.dimension.flare.data.database.app.model.DraftTargetStatus.FAILED } -> UiDraftStatus.FAILED + else -> UiDraftStatus.DRAFT + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCase.kt index 92c74cc8d..e94c07f55 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/SendDraftUseCase.kt @@ -1,6 +1,5 @@ package dev.dimension.flare.ui.presenter.compose -import dev.dimension.flare.data.database.app.model.DraftReferenceType import dev.dimension.flare.data.database.app.model.DraftTargetStatus import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.repository.AccountRepository @@ -74,40 +73,7 @@ internal class SendDraftUseCase( findAccount(target.accountKey)?.let { account -> ComposeTargetData( account = account, - data = - ComposeData( - content = draft.content.text, - visibility = draft.content.visibility, - language = draft.content.language, - medias = medias, - sensitive = draft.content.sensitive, - spoilerText = draft.content.spoilerText, - poll = - draft.content.poll?.let { - ComposeData.Poll( - options = it.options, - expiredAfter = it.expiredAfter, - multiple = it.multiple, - ) - }, - localOnly = draft.content.localOnly, - referenceStatus = - draft.content.reference?.let { reference -> - ComposeData.ReferenceStatus( - data = null, - composeStatus = - when (reference.type) { - DraftReferenceType.QUOTE -> ComposeStatus.Quote(reference.statusKey) - DraftReferenceType.REPLY -> ComposeStatus.Reply(reference.statusKey) - DraftReferenceType.VVO_COMMENT -> - ComposeStatus.VVOComment( - statusKey = reference.statusKey, - rootId = requireNotNull(reference.rootId), - ) - }, - ) - }, - ), + data = draft.content.toComposeData(medias = medias), ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/action/AddReactionPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/action/AddReactionPresenter.kt index ca1d9133c..334e3d7f0 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/action/AddReactionPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/status/action/AddReactionPresenter.kt @@ -54,7 +54,7 @@ public class AddReactionPresenter( } }.map { remember(it) { - EmojiData(it) + EmojiData(it, accountType) } } diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/DatabaseHelper.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/DatabaseHelper.kt index 5311616f4..eda5209e6 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/DatabaseHelper.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/DatabaseHelper.kt @@ -2,7 +2,10 @@ package dev.dimension.flare import androidx.room.Room import androidx.room.RoomDatabase +import kotlin.reflect.KClass -internal expect inline fun Room.memoryDatabaseBuilder(): RoomDatabase.Builder +internal expect fun Room.memoryDatabaseBuilder(databaseClass: KClass): RoomDatabase.Builder + +internal inline fun Room.memoryDatabaseBuilder(): RoomDatabase.Builder = memoryDatabaseBuilder(T::class) expect open class RobolectricTest() diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolverTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolverTest.kt new file mode 100644 index 000000000..7ba6301e1 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/compose/InitialTextResolverTest.kt @@ -0,0 +1,177 @@ +package dev.dimension.flare.ui.presenter.compose + +import com.fleeksoft.ksoup.Ksoup +import dev.dimension.flare.common.TestFormatter +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.humanizer.PlatformFormatter +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiHandle +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.render.UiRichText +import dev.dimension.flare.ui.render.toUi +import kotlinx.collections.immutable.persistentListOf +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Clock + +class InitialTextResolverTest { + @BeforeTest + fun setUp() { + stopKoin() + startKoin { + modules( + module { + single { TestFormatter() } + }, + ) + } + } + + @AfterTest + fun tearDown() { + stopKoin() + } + + @Test + fun `resolves quote text for vvo quote`() { + val post = + createPost( + platformType = PlatformType.VVo, + user = createUser(name = "Alice", handle = UiHandle("alice", "weibo.cn")), + content = text("Hello world"), + quote = persistentListOf(createPost()), + ) + + val result = + InitialTextResolver.resolve( + post = post, + composeStatus = ComposeStatus.Quote(post.statusKey), + currentUserHandle = UiHandle("current", "example.com"), + selectedAccountKey = null, + ) + + assertNotNull(result) + assertEquals("//@Alice:Hello world", result.text) + assertEquals(0, result.cursorPosition) + } + + @Test + fun `resolves reply mentions for mastodon and filters duplicates current user and selected account`() { + val selectedAccountKey = MicroBlogKey("author", "example.com") + val authorHandle = UiHandle("author", "example.com") + val currentUserHandle = UiHandle("me", "example.com") + val mentionedHandle = "@friend@example.com" + val post = + createPost( + platformType = PlatformType.Mastodon, + statusKey = MicroBlogKey("post-1", "example.com"), + user = createUser(handle = authorHandle, key = selectedAccountKey), + content = + text( + """ + + $mentionedHandle + @me@example.com + $mentionedHandle + @external@example.com + ${authorHandle.canonical} + + """.trimIndent(), + ), + ) + + val result = + InitialTextResolver.resolve( + post = post, + composeStatus = ComposeStatus.Reply(post.statusKey), + currentUserHandle = currentUserHandle, + selectedAccountKey = selectedAccountKey, + ) + + assertNotNull(result) + assertEquals("$mentionedHandle ", result.text) + assertEquals(result.text.length, result.cursorPosition) + } + + @Test + fun `returns null for non reply mastodon compose state`() { + val post = createPost(platformType = PlatformType.Mastodon) + + val result = + InitialTextResolver.resolve( + post = post, + composeStatus = ComposeStatus.Quote(post.statusKey), + currentUserHandle = UiHandle("current", "example.com"), + selectedAccountKey = null, + ) + + assertNull(result) + } + + private fun createPost( + platformType: PlatformType = PlatformType.Mastodon, + statusKey: MicroBlogKey = MicroBlogKey("post", "example.com"), + user: UiProfile = createUser(), + content: UiRichText = text("content"), + quote: kotlinx.collections.immutable.ImmutableList = persistentListOf(), + ): UiTimelineV2.Post = + UiTimelineV2.Post( + message = null, + platformType = platformType, + images = persistentListOf(), + sensitive = false, + contentWarning = null, + user = user, + quote = quote, + content = content, + actions = persistentListOf(), + poll = null, + statusKey = statusKey, + card = null, + createdAt = Clock.System.now().toUi(), + emojiReactions = persistentListOf(), + sourceChannel = null, + visibility = null, + replyToHandle = null, + parents = persistentListOf(), + clickEvent = ClickEvent.Noop, + accountType = AccountType.Specific(user.key), + ) + + private fun createUser( + key: MicroBlogKey = MicroBlogKey("user", "example.com"), + handle: UiHandle = UiHandle("user", "example.com"), + name: String = "User", + ): UiProfile = + UiProfile( + key = key, + handle = handle, + avatar = "", + nameInternal = text("$name"), + platformType = PlatformType.Mastodon, + clickEvent = ClickEvent.Noop, + banner = null, + description = null, + matrices = + UiProfile.Matrices( + fansCount = 0, + followsCount = 0, + statusesCount = 0, + ), + mark = persistentListOf(), + bottomContent = null, + ) + + private fun text(html: String) = Ksoup.parse(html).body().toUi() +} diff --git a/shared/src/jvmTest/kotlin/dev/dimension/flare/DatabaseHelper.jvm.kt b/shared/src/jvmTest/kotlin/dev/dimension/flare/DatabaseHelper.jvm.kt index cd0ab96a3..751ecd3c9 100644 --- a/shared/src/jvmTest/kotlin/dev/dimension/flare/DatabaseHelper.jvm.kt +++ b/shared/src/jvmTest/kotlin/dev/dimension/flare/DatabaseHelper.jvm.kt @@ -2,7 +2,18 @@ package dev.dimension.flare import androidx.room.Room import androidx.room.RoomDatabase +import dev.dimension.flare.data.database.app.AppDatabase +import dev.dimension.flare.data.database.app.AppDatabaseConstructor +import dev.dimension.flare.data.database.cache.CacheDatabase +import dev.dimension.flare.data.database.cache.CacheDatabaseConstructor +import kotlin.reflect.KClass -internal actual inline fun Room.memoryDatabaseBuilder(): RoomDatabase.Builder = Room.inMemoryDatabaseBuilder() +@Suppress("UNCHECKED_CAST") +internal actual fun Room.memoryDatabaseBuilder(databaseClass: KClass): RoomDatabase.Builder = + when (databaseClass) { + AppDatabase::class -> Room.inMemoryDatabaseBuilder(factory = AppDatabaseConstructor::initialize) + CacheDatabase::class -> Room.inMemoryDatabaseBuilder(factory = CacheDatabaseConstructor::initialize) + else -> error("Unsupported test database: ${databaseClass.qualifiedName}") + } as RoomDatabase.Builder actual open class RobolectricTest actual constructor() diff --git a/shared/src/jvmTest/kotlin/dev/dimension/flare/common/SerializationFormatBenchmarkTest.kt b/shared/src/jvmTest/kotlin/dev/dimension/flare/common/SerializationFormatBenchmarkTest.kt new file mode 100644 index 000000000..52caa3e8b --- /dev/null +++ b/shared/src/jvmTest/kotlin/dev/dimension/flare/common/SerializationFormatBenchmarkTest.kt @@ -0,0 +1,464 @@ +package dev.dimension.flare.common + +import com.fleeksoft.ksoup.nodes.Element +import dev.dimension.flare.data.datasource.microblog.ActionMenu +import dev.dimension.flare.data.datasource.microblog.paging.TimelinePagingMapper +import dev.dimension.flare.model.AccountType +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiCard +import dev.dimension.flare.ui.model.UiHandle +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiNumber +import dev.dimension.flare.ui.model.UiPoll +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.render.toUi +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.protobuf.ProtoBuf +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import kotlin.system.measureNanoTime +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Clock + +@OptIn(ExperimentalSerializationApi::class) +class SerializationFormatBenchmarkTest { + @BeforeTest + fun setUp() { + startKoin { + modules( + module { + single { TestFormatter() } + }, + ) + } + } + + @AfterTest + fun tearDown() { + stopKoin() + } + + @Test + fun compareJsonAndProtoBufForStoredTimelinePayload() = + runTest { + if (!readBooleanFlag(RUN_BENCHMARKS_PROPERTY, RUN_BENCHMARKS_ENV)) { + println( + "Skipping benchmark. Set -D$RUN_BENCHMARKS_PROPERTY=true or $RUN_BENCHMARKS_ENV=true to run it.", + ) + return@runTest + } + + val warmupIterations = readIntFlag(WARMUP_ITERATIONS_PROPERTY, WARMUP_ITERATIONS_ENV) ?: 1_000 + val benchmarkIterations = readIntFlag(BENCHMARK_ITERATIONS_PROPERTY, BENCHMARK_ITERATIONS_ENV) ?: 5_000 + val payload = createStoredPayload() + val serializer = UiTimelineV2.serializer() + + val jsonPayload = JSON.encodeToString(serializer, payload) + val protoPayload = ProtoBuf.encodeToByteArray(serializer, payload) + + verifyDecode(payload, JSON.decodeFromString(serializer, jsonPayload)) + verifyDecode(payload, ProtoBuf.decodeFromByteArray(serializer, protoPayload)) + + val jsonEncode = benchmarkStringEncoding(payload, serializer, warmupIterations, benchmarkIterations) + val jsonDecode = benchmarkStringDecoding(jsonPayload, serializer, warmupIterations, benchmarkIterations) + val protoEncode = benchmarkBytesEncoding(payload, serializer, warmupIterations, benchmarkIterations) + val protoDecode = benchmarkBytesDecoding(protoPayload, serializer, warmupIterations, benchmarkIterations) + + println( + buildString { + appendLine("Serialization benchmark") + appendLine("Payload type: ${payload.itemType}") + appendLine("Warmup iterations: $warmupIterations") + appendLine("Benchmark iterations: $benchmarkIterations") + appendLine("JSON size: ${jsonPayload.encodeToByteArray().size} bytes") + appendLine("ProtoBuf size: ${protoPayload.size} bytes") + appendLine("JSON encode avg: ${jsonEncode.averageMicros.formatMicros()} us") + appendLine("JSON decode avg: ${jsonDecode.averageMicros.formatMicros()} us") + appendLine("ProtoBuf encode avg: ${protoEncode.averageMicros.formatMicros()} us") + appendLine("ProtoBuf decode avg: ${protoDecode.averageMicros.formatMicros()} us") + appendLine( + "Checksums: json=${jsonEncode.checksum}/${jsonDecode.checksum}, proto=${protoEncode.checksum}/${protoDecode.checksum}", + ) + }, + ) + } + + private fun benchmarkStringEncoding( + payload: UiTimelineV2, + serializer: kotlinx.serialization.KSerializer, + warmupIterations: Int, + benchmarkIterations: Int, + ): BenchmarkResult { + var checksum = 0L + repeat(warmupIterations) { + val value = JSON.encodeToString(serializer, payload) + checksum += value.length.toLong() + } + val totalNanos = + measureNanoTime { + repeat(benchmarkIterations) { + val value = JSON.encodeToString(serializer, payload) + checksum = checksum * 31 + value.length + } + } + return BenchmarkResult(totalNanos = totalNanos, iterations = benchmarkIterations, checksum = checksum) + } + + private fun benchmarkStringDecoding( + payload: String, + serializer: kotlinx.serialization.KSerializer, + warmupIterations: Int, + benchmarkIterations: Int, + ): BenchmarkResult { + var checksum = 0L + repeat(warmupIterations) { + val value = JSON.decodeFromString(serializer, payload) + checksum += value.statusKey.hashCode().toLong() + } + val totalNanos = + measureNanoTime { + repeat(benchmarkIterations) { + val value = JSON.decodeFromString(serializer, payload) + checksum = checksum * 31 + value.statusKey.hashCode() + } + } + return BenchmarkResult(totalNanos = totalNanos, iterations = benchmarkIterations, checksum = checksum) + } + + private fun benchmarkBytesEncoding( + payload: UiTimelineV2, + serializer: kotlinx.serialization.KSerializer, + warmupIterations: Int, + benchmarkIterations: Int, + ): BenchmarkResult { + var checksum = 0L + repeat(warmupIterations) { + val value = ProtoBuf.encodeToByteArray(serializer, payload) + checksum += value.size.toLong() + } + val totalNanos = + measureNanoTime { + repeat(benchmarkIterations) { + val value = ProtoBuf.encodeToByteArray(serializer, payload) + checksum = checksum * 31 + value.size + } + } + return BenchmarkResult(totalNanos = totalNanos, iterations = benchmarkIterations, checksum = checksum) + } + + private fun benchmarkBytesDecoding( + payload: ByteArray, + serializer: kotlinx.serialization.KSerializer, + warmupIterations: Int, + benchmarkIterations: Int, + ): BenchmarkResult { + var checksum = 0L + repeat(warmupIterations) { + val value = ProtoBuf.decodeFromByteArray(serializer, payload) + checksum += value.statusKey.hashCode().toLong() + } + val totalNanos = + measureNanoTime { + repeat(benchmarkIterations) { + val value = ProtoBuf.decodeFromByteArray(serializer, payload) + checksum = checksum * 31 + value.statusKey.hashCode() + } + } + return BenchmarkResult(totalNanos = totalNanos, iterations = benchmarkIterations, checksum = checksum) + } + + private fun verifyDecode( + expected: UiTimelineV2, + actual: UiTimelineV2, + ) { + assertEquals(expected.itemType, actual.itemType) + assertEquals(expected.statusKey, actual.statusKey) + val expectedPost = assertIs(expected) + val actualPost = assertIs(actual) + assertEquals(expectedPost.content.raw, actualPost.content.raw) + assertEquals(expectedPost.actions.size, actualPost.actions.size) + assertEquals(expectedPost.images.size, actualPost.images.size) + assertEquals(expectedPost.references.size, actualPost.references.size) + assertEquals(expectedPost.emojiReactions.size, actualPost.emojiReactions.size) + assertEquals(expectedPost.poll?.options?.size, actualPost.poll?.options?.size) + } + + private suspend fun createStoredPayload(): UiTimelineV2 { + val accountKey = MicroBlogKey(id = "benchmark-account", host = "bench.example") + val rootUser = createUser(MicroBlogKey(id = "root-user", host = "bench.example"), "Root User") + val quoteUser = createUser(MicroBlogKey(id = "quote-user", host = "bench.example"), "Quote User") + val parentUser = createUser(MicroBlogKey(id = "parent-user", host = "bench.example"), "Parent User") + val repostUser = createUser(MicroBlogKey(id = "repost-user", host = "bench.example"), "Repost User") + + val quote = + createPost( + accountKey = accountKey, + user = quoteUser, + statusKey = MicroBlogKey(id = "quote-status", host = "bench.example"), + text = "Quoted content with enough text to resemble a real payload.", + mediaCount = 2, + ) + val parent = + createPost( + accountKey = accountKey, + user = parentUser, + statusKey = MicroBlogKey(id = "parent-status", host = "bench.example"), + text = "Parent content that becomes a stored reference after sanitization.", + mediaCount = 1, + ) + val repost = + createPost( + accountKey = accountKey, + user = repostUser, + statusKey = MicroBlogKey(id = "repost-status", host = "bench.example"), + text = "Reposted content with a card and poll.", + mediaCount = 3, + ) + + val root = + createPost( + accountKey = accountKey, + user = rootUser, + statusKey = MicroBlogKey(id = "root-status", host = "bench.example"), + text = + "Root content with multiple attachments, actions, reactions, and a poll to approximate a stored timeline entry.", + quote = listOf(quote), + parents = listOf(parent), + internalRepost = repost, + mediaCount = 4, + ) + + val stored = + TimelinePagingMapper + .toDb(root, pagingKey = "benchmark") + .status + .status + .data + .content + val storedPost = assertIs(stored) + assertEquals(0, storedPost.parents.size) + assertEquals(0, storedPost.quote.size) + assertEquals(null, storedPost.internalRepost) + return stored + } + + private fun createUser( + key: MicroBlogKey, + name: String, + ): UiProfile = + UiProfile( + key = key, + handle = + UiHandle( + raw = key.id, + host = key.host, + ), + avatar = "https://${key.host}/${key.id}.png", + nameInternal = Element("span").apply { appendText(name) }.toUi(), + platformType = PlatformType.Mastodon, + clickEvent = ClickEvent.Noop, + banner = "https://${key.host}/${key.id}/banner.png", + description = + Element("p") + .apply { + appendText("Profile description for $name with links and some additional text.") + }.toUi(), + matrices = + UiProfile.Matrices( + fansCount = 1234, + followsCount = 567, + statusesCount = 8901, + platformFansCount = "1.2K", + ), + mark = persistentListOf(UiProfile.Mark.Verified, UiProfile.Mark.Bot), + bottomContent = + UiProfile.BottomContent.Fields( + fields = + mapOf( + "Website" to Element("span").apply { appendText("https://${key.host}") }.toUi(), + "Location" to Element("span").apply { appendText("Benchmark City") }.toUi(), + ).let { + persistentMapOf( + "Website" to it.getValue("Website"), + "Location" to it.getValue("Location"), + ) + }, + ), + ) + + private fun createPost( + accountKey: MicroBlogKey, + user: UiProfile, + statusKey: MicroBlogKey, + text: String, + quote: List = emptyList(), + parents: List = emptyList(), + internalRepost: UiTimelineV2.Post? = null, + mediaCount: Int, + ): UiTimelineV2.Post = + UiTimelineV2.Post( + message = null, + platformType = PlatformType.Mastodon, + images = + List(mediaCount) { index -> + UiMedia.Image( + url = "https://${statusKey.host}/${statusKey.id}/media-$index.jpg", + previewUrl = "https://${statusKey.host}/${statusKey.id}/media-$index-preview.jpg", + description = "image-$index", + height = 1080f, + width = 1920f, + sensitive = index % 2 == 0, + ) + }.toPersistentList(), + sensitive = false, + contentWarning = Element("span").apply { appendText("cw-$text") }.toUi(), + user = user, + quote = quote.toPersistentList(), + content = Element("span").apply { appendText(text) }.toUi(), + actions = createActions(statusKey), + poll = createPoll(statusKey, accountKey), + statusKey = statusKey, + card = + UiCard( + title = "Card for ${statusKey.id}", + description = "Card description for ${statusKey.id}", + media = + UiMedia.Image( + url = "https://${statusKey.host}/${statusKey.id}/card.jpg", + previewUrl = "https://${statusKey.host}/${statusKey.id}/card-preview.jpg", + description = "card", + height = 630f, + width = 1200f, + sensitive = false, + ), + url = "https://${statusKey.host}/${statusKey.id}", + ), + createdAt = Clock.System.now().toUi(), + emojiReactions = + listOf( + UiTimelineV2.Post.EmojiReaction( + name = ":flare:", + url = "https://${statusKey.host}/emoji/flare.png", + count = UiNumber(10), + clickEvent = ClickEvent.Noop, + isUnicode = false, + me = true, + ), + UiTimelineV2.Post.EmojiReaction( + name = "🔥", + url = "", + count = UiNumber(5), + clickEvent = ClickEvent.Noop, + isUnicode = true, + me = false, + ), + ).toPersistentList(), + sourceChannel = UiTimelineV2.Post.SourceChannel(id = "channel-${statusKey.id}", name = "Benchmark"), + visibility = UiTimelineV2.Post.Visibility.Public, + replyToHandle = "@reply@example.com", + references = persistentListOf(), + parents = parents.toPersistentList(), + internalRepost = internalRepost, + clickEvent = ClickEvent.Noop, + accountType = AccountType.Specific(accountKey), + ) + + private fun createActions(statusKey: MicroBlogKey) = + persistentListOf( + ActionMenu.Item( + updateKey = "reply-${statusKey.id}", + icon = UiIcon.Reply, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Reply), + count = UiNumber(100), + ), + ActionMenu.Group( + displayItem = + ActionMenu.Item( + updateKey = "like-${statusKey.id}", + icon = UiIcon.Like, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Like), + count = UiNumber(200), + ), + actions = + persistentListOf( + ActionMenu.Item( + updateKey = "bookmark-${statusKey.id}", + icon = UiIcon.Bookmark, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Bookmark), + ), + ActionMenu.Item( + updateKey = "share-${statusKey.id}", + icon = UiIcon.Share, + text = ActionMenu.Item.Text.Localized(ActionMenu.Item.Text.Localized.Type.Share), + ), + ), + ), + ) + + private fun createPoll( + statusKey: MicroBlogKey, + accountKey: MicroBlogKey, + ) = UiPoll( + id = "poll-${statusKey.id}", + options = + listOf( + UiPoll.Option(title = "One", votesCount = 10, percentage = 0.2f), + UiPoll.Option(title = "Two", votesCount = 20, percentage = 0.4f), + UiPoll.Option(title = "Three", votesCount = 20, percentage = 0.4f), + ).toPersistentList(), + multiple = true, + ownVotes = persistentListOf(1), + voteEvent = + dev.dimension.flare.data.datasource.microblog.PostEvent.Mastodon.Vote( + id = "vote-${statusKey.id}", + accountKey = accountKey, + postKey = statusKey, + options = persistentListOf(1), + ), + expiresAt = Clock.System.now(), + ) + + private fun Double.formatMicros(): String = "%.2f".format(this) + + private fun readBooleanFlag( + propertyName: String, + envName: String, + ): Boolean = System.getProperty(propertyName) == "true" || System.getenv(envName) == "true" + + private fun readIntFlag( + propertyName: String, + envName: String, + ): Int? = System.getProperty(propertyName)?.toIntOrNull() ?: System.getenv(envName)?.toIntOrNull() + + private data class BenchmarkResult( + val totalNanos: Long, + val iterations: Int, + val checksum: Long, + ) { + val averageMicros: Double + get() = totalNanos.toDouble() / iterations.toDouble() / 1_000.0 + } + + private companion object { + const val RUN_BENCHMARKS_PROPERTY = "flare.runBenchmarks" + const val WARMUP_ITERATIONS_PROPERTY = "flare.serializationBenchmark.warmup" + const val BENCHMARK_ITERATIONS_PROPERTY = "flare.serializationBenchmark.iterations" + const val RUN_BENCHMARKS_ENV = "FLARE_RUN_BENCHMARKS" + const val WARMUP_ITERATIONS_ENV = "FLARE_SERIALIZATION_BENCHMARK_WARMUP" + const val BENCHMARK_ITERATIONS_ENV = "FLARE_SERIALIZATION_BENCHMARK_ITERATIONS" + } +} From b479d37b3c7b942dc1d6148f441d30ac5da858f8 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 11 Mar 2026 12:03:05 +0900 Subject: [PATCH 4/4] rm account type for new compose --- .../dev/dimension/flare/ui/route/Route.kt | 7 +- .../ui/screen/compose/ComposeEntryBuilder.kt | 4 +- .../flare/ui/screen/compose/ComposeScreen.kt | 4 +- .../flare/ui/screen/home/HomeEntryBuilder.kt | 2 +- .../flare/ui/screen/home/HomeScreen.kt | 36 +--- .../main/kotlin/dev/dimension/flare/App.kt | 4 +- .../dev/dimension/flare/ui/route/Route.kt | 6 +- .../dev/dimension/flare/ui/route/Router.kt | 7 +- .../flare/ui/screen/compose/ComposeDialog.kt | 14 +- .../flare/ui/screen/compose/DraftBoxScreen.kt | 9 +- .../ui/screen/settings/SettingsScreen.kt | 4 +- .../dev/dimension/flare/common/FlowExt.kt | 2 +- .../datasource/mastodon/MastodonDataSource.kt | 13 +- .../datasource/misskey/MisskeyDataSource.kt | 21 +- .../data/datasource/vvo/VVODataSource.kt | 15 +- .../ui/presenter/compose/ComposePresenter.kt | 193 ++++++++++-------- .../home/AllNotificationPresenter.kt | 32 ++- .../dimension/flare/ui/route/DeeplinkRoute.kt | 4 +- .../flare/ui/route/DeeplinkRouteTest.kt | 2 +- 19 files changed, 179 insertions(+), 200 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt index 04243bcb1..697b77c6e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt +++ b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt @@ -383,10 +383,7 @@ internal sealed interface Route : NavKey { @Serializable sealed interface Compose : Route { @Serializable - data class New( - override val accountType: AccountType, - ) : Compose, - WithAccountType + data object New : Compose @Serializable data class Reply( @@ -503,7 +500,7 @@ internal sealed interface Route : NavKey { is DeeplinkRoute.Login -> ServiceSelect.Selection is DeeplinkRoute.Compose.New -> - Compose.New(accountType = deeplinkRoute.accountType) + Compose.New is DeeplinkRoute.Compose.Quote -> Compose.Quote( diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeEntryBuilder.kt index 3baf318b9..80ca86082 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeEntryBuilder.kt @@ -15,10 +15,10 @@ internal fun EntryProviderScope.composeEntryBuilder( ) { entry( metadata = DialogSceneStrategy.dialog() - ) { args -> + ) { ComposeScreen( onBack = onBack, - accountType = args.accountType, + accountType = null, ) } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt index c822fdad6..fd4173f6e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt @@ -168,7 +168,7 @@ private fun activeAccountPresenter() = @Composable internal fun ComposeScreen( onBack: () -> Unit, - accountType: AccountType, + accountType: AccountType?, modifier: Modifier = Modifier, status: ComposeStatus? = null, initialText: String = "", @@ -926,7 +926,7 @@ private fun PollOption( @Composable private fun composePresenter( context: Context, - accountType: AccountType, + accountType: AccountType?, status: ComposeStatus? = null, initialText: String = "", initialMedias: ImmutableList = persistentListOf(), diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeEntryBuilder.kt index 36c7c9664..d6b814e41 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeEntryBuilder.kt @@ -26,7 +26,7 @@ internal fun EntryProviderScope.homeEntryBuilder( HomeTimelineScreen( accountType = args.accountType, toCompose = { - navigate(Route.Compose.New(args.accountType)) + navigate(Route.Compose.New) }, toQuickMenu = { openDrawer.invoke() diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt index ab81ef612..8fbe1b57d 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt @@ -86,7 +86,6 @@ import dev.dimension.flare.ui.component.platform.isBigScreen import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.isError -import dev.dimension.flare.ui.model.isSuccess import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess @@ -99,7 +98,6 @@ import dev.dimension.flare.ui.presenter.home.UserPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.route.Router -import dev.dimension.flare.ui.route.accountTypeOr import dev.dimension.flare.ui.screen.splash.SplashScreen import kotlinx.coroutines.launch import moe.tlaster.precompose.molecule.producePresenter @@ -124,10 +122,6 @@ internal fun HomeScreen(afterInit: () -> Unit) { ) { state.topLevelBackStack.takeSuccess()?.topLevelKey ?: getDirection(tabs.all.first()) } - val accountType = currentRoute.accountTypeOr(state.defaultAccountType) - val userState by producePresenter(key = "home_account_type_$accountType") { - userPresenter(accountType) - } OnNewIntent( withOnCreateIntent = true, ) { @@ -147,11 +141,10 @@ internal fun HomeScreen(afterInit: () -> Unit) { bottomBarAutoHideEnabled = state.navigationState.bottomBarAutoHideEnabled, layoutType = layoutType, showFab = - userState.isSuccess && - accountType !is AccountType.Guest && + state.defaultAccountType !is AccountType.Guest && state.topLevelBackStack.takeSuccess()?.currentKey is Route.Home, onFabClicked = { - state.navigate(Route.Compose.New(accountType)) + state.navigate(Route.Compose.New) }, navigationSuiteColors = NavigationSuiteDefaults.colors( @@ -160,10 +153,8 @@ internal fun HomeScreen(afterInit: () -> Unit) { railHeader = { HomeRailHeader( state.wideNavigationRailState, - userState, + state.userState, layoutType, - currentRoute, - state.defaultAccountType, state::navigate, ) }, @@ -256,7 +247,7 @@ internal fun HomeScreen(afterInit: () -> Unit) { } }, footerItems = { - if (!userState.isError) { + if (!state.userState.isError) { item( selected = currentRoute is Route.Settings.Main, onClick = { @@ -314,8 +305,6 @@ private fun HomeRailHeader( wideNavigationRailState: WideNavigationRailState, userState: UiState, layoutType: NavigationSuiteType, - currentRoute: Route, - defaultAccountType: AccountType, navigate: (Route) -> Unit, ) { val scope = rememberCoroutineScope() @@ -455,13 +444,7 @@ private fun HomeRailHeader( WideNavigationRailValue.Collapsed -> FloatingActionButton( onClick = { - navigate( - Route.Compose.New( - currentRoute.accountTypeOr( - defaultAccountType, - ), - ), - ) + navigate(Route.Compose.New) }, elevation = FloatingActionButtonDefaults.elevation( @@ -483,13 +466,7 @@ private fun HomeRailHeader( WideNavigationRailValue.Expanded -> ExtendedFloatingActionButton( onClick = { - navigate( - Route.Compose.New( - currentRoute.accountTypeOr( - defaultAccountType, - ), - ), - ) + navigate(Route.Compose.New) }, icon = { FAIcon( @@ -609,6 +586,7 @@ private fun presenter(uriHandler: UriHandler) = val deeplinkPresenter = deeplinkPresenter val topLevelBackStack = topLevelBackStack val wideNavigationRailState = wideNavigationRailState + val userState = activeAccountState.user val defaultAccountType: AccountType = activeAccountState.user .takeSuccess() diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index a9cc84237..4d227fe3b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -139,9 +139,7 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { Button( onClick = { state.navigate( - Route.Compose.New( - accountType = AccountType.Specific(user.key), - ), + Route.Compose.New, ) }, modifier = diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index 050ce4bd9..f89503273 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -152,9 +152,7 @@ internal sealed interface Route : NavKey { val statusKey: MicroBlogKey, ) : FloatingRoute - data class New( - val accountType: AccountType, - ) : FloatingRoute + data object New : FloatingRoute data class VVOReplyComment( val accountKey: MicroBlogKey, @@ -297,7 +295,7 @@ internal sealed interface Route : NavKey { .toImmutableMap(), ) is DeeplinkRoute.Login -> ServiceSelect - is DeeplinkRoute.Compose.New -> New(deeplinkRoute.accountType) + DeeplinkRoute.Compose.New -> New is DeeplinkRoute.Compose.Quote -> Quote( deeplinkRoute.accountKey, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 58f7de187..65f8ad113 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -343,7 +343,7 @@ internal fun WindowScope.Router( ) { ComposeDialog( onBack = onBack, - accountType = args.accountType, + accountType = null, ) } } @@ -539,11 +539,6 @@ internal fun WindowScope.Router( entry { DraftBoxScreen( onEdit = { _, accountKey -> - navigate( - Route.Compose.New( - accountType = Specific(accountKey = accountKey), - ), - ) }, ) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt index d3399c1c2..197fb31eb 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/ComposeDialog.kt @@ -126,6 +126,11 @@ import io.github.composefluent.component.Text import io.github.composefluent.component.TextField import io.github.composefluent.component.TextFieldDefaults import io.github.composefluent.surface.Card +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import moe.tlaster.precompose.molecule.producePresenter +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import java.io.File import java.util.Locale import kotlin.time.Duration @@ -133,11 +138,6 @@ import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.uuid.ExperimentalUuidApi -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import moe.tlaster.precompose.molecule.producePresenter -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource private val imageExtensions = setOf("png", "jpg", "jpeg", "gif", "bmp") private val videoExtensions = setOf("mp4", "mov", "avi", "mkv", "webm") @@ -147,7 +147,7 @@ private val pickerFileExtensions = imageExtensions + videoExtensions @Composable fun ComposeDialog( onBack: (() -> Unit)?, - accountType: AccountType, + accountType: AccountType?, modifier: Modifier = Modifier, status: ComposeStatus? = null, initialText: String = "", @@ -874,7 +874,7 @@ private fun PollOption( @Composable private fun composePresenter( - accountType: AccountType, + accountType: AccountType?, status: ComposeStatus? = null, initialText: String = "", ) = run { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/DraftBoxScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/DraftBoxScreen.kt index 23505b49c..ef4139514 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/DraftBoxScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/compose/DraftBoxScreen.kt @@ -38,11 +38,11 @@ import dev.dimension.flare.ui.component.AvatarComponent import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.FlareScrollBar import dev.dimension.flare.ui.component.NetworkImage -import dev.dimension.flare.ui.presenter.compose.DraftBoxPresenter import dev.dimension.flare.ui.model.UiDraft import dev.dimension.flare.ui.model.UiDraftMediaType import dev.dimension.flare.ui.model.UiDraftStatus import dev.dimension.flare.ui.model.primaryAccountKey +import dev.dimension.flare.ui.presenter.compose.DraftBoxPresenter import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.composefluent.FluentTheme @@ -54,9 +54,7 @@ import moe.tlaster.precompose.molecule.producePresenter import org.jetbrains.compose.resources.stringResource @Composable -internal fun DraftBoxScreen( - onEdit: (String, MicroBlogKey) -> Unit = { _, _ -> }, -) { +internal fun DraftBoxScreen(onEdit: (String, MicroBlogKey) -> Unit = { _, _ -> }) { val state by producePresenter { remember { DraftBoxPresenter() }.invoke() } @@ -168,7 +166,8 @@ private fun DraftBoxCard( ) UiDraftMediaType.VIDEO, - UiDraftMediaType.OTHER -> + UiDraftMediaType.OTHER, + -> Box( modifier = Modifier diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 9e5cce9a0..e03970d23 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -140,6 +140,8 @@ import dev.dimension.flare.settings_appearance_theme_light import dev.dimension.flare.settings_appearance_title import dev.dimension.flare.settings_appearance_video_autoplay import dev.dimension.flare.settings_appearance_video_autoplay_description +import dev.dimension.flare.settings_draft_box_description +import dev.dimension.flare.settings_draft_box_title import dev.dimension.flare.settings_language_description import dev.dimension.flare.settings_language_title import dev.dimension.flare.settings_local_history_description @@ -155,8 +157,6 @@ import dev.dimension.flare.settings_storage_clear_database import dev.dimension.flare.settings_storage_clear_database_description import dev.dimension.flare.settings_storage_clear_image_cache import dev.dimension.flare.settings_storage_clear_image_cache_description -import dev.dimension.flare.settings_draft_box_description -import dev.dimension.flare.settings_draft_box_title import dev.dimension.flare.settings_storage_export_data import dev.dimension.flare.settings_storage_export_data_description import dev.dimension.flare.settings_storage_import_data diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/FlowExt.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/FlowExt.kt index dff302d85..09ef0a3d2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/common/FlowExt.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/FlowExt.kt @@ -16,4 +16,4 @@ internal inline fun Flow>>.combineLatestFlowLists(): Fl array.toList() } } - } \ No newline at end of file + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt index ac4a2b54c..2b5eb36e3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt @@ -44,12 +44,12 @@ import dev.dimension.flare.ui.model.UiHashtag import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.presenter.compose.ComposeStatus -import kotlin.uuid.Uuid import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.map import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import kotlin.uuid.Uuid @OptIn(ExperimentalPagingApi::class) internal open class MastodonDataSource( @@ -423,11 +423,12 @@ internal open class MastodonDataSource( } else { ComposeConfig.Poll(4) }, - emoji = ComposeConfig.Emoji( - emojiHandler.emoji, - mergeTag = "mastodon@${accountKey.host}", - accountKey = accountKey, - ), + emoji = + ComposeConfig.Emoji( + emojiHandler.emoji, + mergeTag = "mastodon@${accountKey.host}", + accountKey = accountKey, + ), contentWarning = ComposeConfig.ContentWarning, visibility = ComposeConfig.Visibility, language = ComposeConfig.Language(1), diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt index 2af60e1cc..a2e551ae6 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt @@ -191,9 +191,11 @@ internal class MisskeyDataSource( accountKey = accountKey, ), ), - isUnicode = !event.reaction.startsWith(':') && !event.reaction.endsWith( - ':' - ), + isUnicode = + !event.reaction.startsWith(':') && + !event.reaction.endsWith( + ':', + ), me = true, ), ) @@ -321,7 +323,7 @@ internal class MisskeyDataSource( when (request) { is PagingRequest.Prepend, is PagingRequest.Append, - -> + -> PagingResult( endOfPaginationReached = true, ) @@ -558,11 +560,12 @@ internal class MisskeyDataSource( allowMediaOnly = true, ), poll = ComposeConfig.Poll(9), - emoji = ComposeConfig.Emoji( - emojiHandler.emoji, - "misskey@${accountKey.host}", - accountKey = accountKey, - ), + emoji = + ComposeConfig.Emoji( + emojiHandler.emoji, + "misskey@${accountKey.host}", + accountKey = accountKey, + ), contentWarning = ComposeConfig.ContentWarning, visibility = ComposeConfig.Visibility, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt index f99a7e142..074a44e25 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt @@ -148,7 +148,7 @@ internal class VVODataSource( when (type) { NotificationFilter.All, NotificationFilter.Mention, - -> + -> MentionRemoteMediator( service = service, accountKey = accountKey, @@ -203,7 +203,7 @@ internal class VVODataSource( when (request) { is PagingRequest.Prepend, is PagingRequest.Append, - -> + -> return PagingResult( endOfPaginationReached = true, ) @@ -345,11 +345,12 @@ internal class VVODataSource( altTextMaxLength = -1, allowMediaOnly = false, ), - emoji = ComposeConfig.Emoji( - emoji = emojiHandler.emoji, - mergeTag = "vvo@${accountKey.host}", - accountKey = accountKey, - ), + emoji = + ComposeConfig.Emoji( + emoji = emojiHandler.emoji, + mergeTag = "vvo@${accountKey.host}", + accountKey = accountKey, + ), ) private suspend fun like(event: PostEvent.VVO.Like) { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt index 0a94fb80c..9577c32a0 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/compose/ComposePresenter.kt @@ -39,6 +39,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -69,26 +70,31 @@ public class ComposePresenter( } private val selectedAccountsFlow by lazy { - selectedAccountsKeyFlow.map { accountKeys -> - accountKeys.map { key -> - accountRepository.getFlow(key) + selectedAccountsKeyFlow + .map { accountKeys -> + accountKeys.map { key -> + accountRepository.getFlow(key) + } + }.combineLatestFlowLists() + .map { + it + .mapNotNull { + it.takeSuccess() + }.toImmutableList() } - }.combineLatestFlowLists().map { - it.mapNotNull { - it.takeSuccess() - }.toImmutableList() - } } private val selectedAccountServicesFlow by lazy { - selectedAccountsKeyFlow.map { accountKeys -> - accountKeys.map { accountKey -> - accountServiceFlow( - accountType = AccountType.Specific(accountKey = accountKey), - repository = accountRepository, - ) - } - }.combineLatestFlowLists().map { it.toImmutableList() } + selectedAccountsKeyFlow + .map { accountKeys -> + accountKeys.map { accountKey -> + accountServiceFlow( + accountType = AccountType.Specific(accountKey = accountKey), + repository = accountRepository, + ) + } + }.combineLatestFlowLists() + .map { it.toImmutableList() } } private val composeConfigFlow by lazy { @@ -112,16 +118,18 @@ public class ComposePresenter( } private val selectedUsersFlow by lazy { - selectedAccountServicesFlow.map { services -> - services - .mapNotNull { service -> - if (service is UserDataSource && service is AuthenticatedMicroblogDataSource) { - service.userHandler.userById(service.accountKey.id).toUi() - } else { - null + selectedAccountServicesFlow + .map { services -> + services + .mapNotNull { service -> + if (service is UserDataSource && service is AuthenticatedMicroblogDataSource) { + service.userHandler.userById(service.accountKey.id).toUi() + } else { + null + } } - } - }.combineLatestFlowLists().map { it.toImmutableList() } + }.combineLatestFlowLists() + .map { it.toImmutableList() } } private val otherAccountsFlow by lazy { @@ -130,48 +138,55 @@ public class ComposePresenter( selectedAccountsKeyFlow, statusFlow ?: flowOf(UiState.Error(Exception("No status for compose"))), ) { allAccounts, selectedAccountKeys, status -> - val statusPlatform = status.takeSuccess()?.let { - it as? UiTimelineV2.Post - }?.platformType - allAccounts.filterNot { account -> - selectedAccountKeys.contains(account.accountKey) || - (statusPlatform != null && account.platformType != statusPlatform) - }.map { - it.accountKey - } + val statusPlatform = + status + .takeSuccess() + ?.let { + it as? UiTimelineV2.Post + }?.platformType + allAccounts + .filterNot { account -> + selectedAccountKeys.contains(account.accountKey) || + (statusPlatform != null && account.platformType != statusPlatform) + }.map { + it.accountKey + } } } private val otherUsersFlow by lazy { - otherAccountsFlow.map { keys -> - keys.map { key -> - accountServiceFlow( - accountType = AccountType.Specific(accountKey = key), - repository = accountRepository, - ).mapNotNull { - if (it is UserDataSource && it is AuthenticatedMicroblogDataSource) { - it.userHandler.userById(it.accountKey.id).toUi() - } else { - null - } - }.flatMapLatest { it } - } - }.combineLatestFlowLists().map { it.toImmutableList() } + otherAccountsFlow + .map { keys -> + keys.map { key -> + accountServiceFlow( + accountType = AccountType.Specific(accountKey = key), + repository = accountRepository, + ).mapNotNull { + if (it is UserDataSource && it is AuthenticatedMicroblogDataSource) { + it.userHandler.userById(it.accountKey.id).toUi() + } else { + null + } + }.flatMapLatest { it } + } + }.combineLatestFlowLists() + .map { it.toImmutableList() } } private val emojiFlow by lazy { - composeConfigFlow.mapNotNull { config -> - config.emoji - }.flatMapLatest { emojiConfig -> - emojiConfig.emoji.toUi().map { emojiState -> - emojiState.map { emoji -> - EmojiData( - data = emoji, - accountType = AccountType.Specific(emojiConfig.accountKey), - ) - } - } - } + composeConfigFlow + .map { config -> + config.emoji + }.flatMapLatest { emojiConfig -> + emojiConfig?.emoji?.toUi()?.map { emojiState -> + emojiState.map { emoji -> + EmojiData( + data = emoji, + accountType = AccountType.Specific(emojiConfig.accountKey), + ) + } + } ?: flowOf(UiState.Error(Exception("No emoji config"))) + }.distinctUntilChanged() } private val textFlow by lazy { @@ -200,11 +215,10 @@ public class ComposePresenter( composeConfigFlow, ) { text, mediaSize, remainingLength, selectedAccountKeys, composeConfig -> (text.isNotBlank() && text.isNotEmpty() && selectedAccountKeys.isNotEmpty() && remainingLength >= 0) || - ((text.isEmpty() || text.isBlank()) && composeConfig.media?.allowMediaOnly == true && mediaSize > 0) + ((text.isEmpty() || text.isBlank()) && composeConfig.media?.allowMediaOnly == true && mediaSize > 0) } } - private val statusFlow by lazy { if (status != null && accountType != null) { accountServiceFlow( @@ -223,36 +237,38 @@ public class ComposePresenter( } private val replyStateFlow by lazy { - statusFlow?.map { statusState -> - statusState.map { post -> - if (post is UiTimelineV2.Post && post.platformType == PlatformType.VVo) { - post.quote.firstOrNull() ?: post - } else { - post + statusFlow + ?.map { statusState -> + statusState.map { post -> + if (post is UiTimelineV2.Post && post.platformType == PlatformType.VVo) { + post.quote.firstOrNull() ?: post + } else { + post + } } - } - } + }?.distinctUntilChanged() } private val initialTextFlow by lazy { if (accountType is AccountType.Specific && status != null) { statusFlow?.flatMapLatest { statusState -> - selectedUsersFlow.mapNotNull { - it.firstOrNull()?.takeSuccess() - }.map { user -> - statusState.mapNotNull { post -> - if (post is UiTimelineV2.Post) { - InitialTextResolver.resolve( - post = post, - composeStatus = status, - currentUserHandle = user.handle, - selectedAccountKey = accountType.accountKey, - ) - } else { - null + selectedUsersFlow + .mapNotNull { + it.firstOrNull()?.takeSuccess() + }.map { user -> + statusState.mapNotNull { post -> + if (post is UiTimelineV2.Post) { + InitialTextResolver.resolve( + post = post, + composeStatus = status, + currentUserHandle = user.handle, + selectedAccountKey = accountType.accountKey, + ) + } else { + null + } } - } - } + }.distinctUntilChanged() } } else { null @@ -276,8 +292,9 @@ public class ComposePresenter( // load active account LaunchedEffect(Unit) { accountRepository.activeAccount.firstOrNull()?.let { account -> - selectedAccountsKeyFlow.value = listOfNotNull(account.takeSuccess()?.accountKey) - .toImmutableList() + selectedAccountsKeyFlow.value = + listOfNotNull(account.takeSuccess()?.accountKey) + .toImmutableList() } } } @@ -285,7 +302,6 @@ public class ComposePresenter( val replyState = replyStateFlow?.flattenUiState()?.value val initialTextState = initialTextFlow?.flattenUiState()?.value - val visibilityState = composeConfig .mapNotNull { @@ -294,7 +310,6 @@ public class ComposePresenter( visibilityPresenter() } - return object : ComposeState( canSend = canSend, visibilityState = visibilityState, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/AllNotificationPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/AllNotificationPresenter.kt index 0e6243b91..12afb8855 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/AllNotificationPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/AllNotificationPresenter.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import dev.dimension.flare.common.CacheState import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.combineLatestFlowLists import dev.dimension.flare.common.refreshSuspend @@ -22,7 +21,11 @@ import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.UiTimelineV2 import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.flatMap +import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.model.toUi import dev.dimension.flare.ui.presenter.PresenterBase import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -77,29 +80,22 @@ public class AllNotificationPresenter : } !is NotificationDataSource -> { - dataSource.userHandler.userById(account.accountKey.id).data.map { - if (it is CacheState.Success) { - it.data to 0 - } else { - null + dataSource.userHandler.userById(account.accountKey.id).toUi().map { + it.map { + it to 0 } } } else -> { combine( - dataSource.userHandler.userById(account.accountKey.id).data, - dataSource.notificationHandler.notificationBadgeCount.data, + dataSource.userHandler.userById(account.accountKey.id).toUi(), + dataSource.notificationHandler.notificationBadgeCount.toUi(), ) { user, badge -> - if (user is CacheState.Success) { - user.data to - if (badge is CacheState.Success) { - badge.data - } else { - 0 - } - } else { - null + user.flatMap { profile -> + badge.map { count -> + profile to count + } } } } @@ -108,7 +104,7 @@ public class AllNotificationPresenter : }.combineLatestFlowLists() .map { it - .filterNotNull() + .mapNotNull { it?.takeSuccess() } .sortedWith( compareByDescending> { it.second } .thenBy { it.first.handle.canonical } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt index 6e5bc8910..d671a4825 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt @@ -107,9 +107,7 @@ public sealed class DeeplinkRoute { @Serializable public sealed class Compose : DeeplinkRoute() { @Serializable - public data class New( - val accountType: AccountType, - ) : Compose() + public data object New : Compose() @Serializable public data class Reply( diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/route/DeeplinkRouteTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/route/DeeplinkRouteTest.kt index 8dec9bb1f..b23d4e6b8 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/route/DeeplinkRouteTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/route/DeeplinkRouteTest.kt @@ -149,7 +149,7 @@ class DeeplinkRouteTest { @Test fun testComposeNew() { - val route = DeeplinkRoute.Compose.New(accountType = activeAccountType) + val route = DeeplinkRoute.Compose.New val uri = route.toUri() val parsed = DeeplinkRoute.parse(uri) assertEquals(route, parsed)