From a549853878c5b047e76fcffa71b3d28f6d00e9de Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 31 Aug 2026 14:08:35 +0800 Subject: [PATCH] feat: show compose progress in home timeline --- .../flare/ui/screen/home/HomeEntryBuilder.kt | 6 + .../ui/screen/home/HomeTimelineScreen.kt | 10 + .../flare/ui/screen/home/TimelineScreen.kt | 3 + .../Sources/FlareAppleUI/OutboxPostView.swift | 257 ++++++++++ .../Resources/Localizable.xcstrings | 88 ++++ .../Component/GalleryTimelinePagingView.swift | 71 ++- .../ios/UI/Component/TimelinePagingView.swift | 6 +- .../ios/UI/Screen/HomeTimelineScreen.swift | 14 +- appleApp/ios/UI/Screen/TimelineScreen.swift | 107 +++- .../UI/Component/TimelinePagingView.swift | 60 ++- appleApp/macos/UI/Screen/TimelineScreen.swift | 8 +- .../values-zh-rCN/strings.xml | 5 + .../values-zh-rTW/strings.xml | 5 + .../composeResources/values/strings.xml | 5 + .../ui/component/status/OutboxPostItem.kt | 293 +++++++++++ .../dev/dimension/flare/ui/route/Router.kt | 6 + .../ui/screen/home/HomeTimelineScreen.kt | 2 + .../flare/ui/screen/home/TimelineScreen.kt | 12 +- .../feature/agent/common/AgentToolsTest.kt | 6 +- .../13.json | 455 ++++++++++++++++++ .../dev/dimension/flare/common/PagingState.kt | 4 + .../flare/data/database/ProvideDatabase.kt | 1 + .../flare/data/database/app/AppDatabase.kt | 16 +- .../flare/data/database/app/dao/DraftDao.kt | 67 +++ .../flare/data/database/app/model/DbDraft.kt | 6 + .../AuthenticatedMicroblogDataSource.kt | 4 +- .../datasource/microblog/ComposeResult.kt | 9 + .../flare/data/model/tab/Timeline.kt | 15 + .../flare/data/repository/DraftRepository.kt | 80 +++ .../dimension/flare/ui/model/UiOutboxPost.kt | 40 ++ .../ui/presenter/TimelineItemPresenter.kt | 191 +++++++- .../ui/presenter/compose/ComposeUseCase.kt | 53 +- .../ui/presenter/compose/DraftBoxPresenter.kt | 45 +- .../presenter/compose/RestoreDraftUseCase.kt | 10 +- .../ui/presenter/compose/SendDraftUseCase.kt | 47 +- .../presenter/compose/SendDraftUseCaseTest.kt | 72 ++- .../app/AppDatabaseMigration11To12Test.kt | 29 +- .../datasource/bluesky/BlueskyDataSource.kt | 25 +- .../datasource/mastodon/MastodonDataSource.kt | 86 ++-- .../datasource/misskey/MisskeyDataSource.kt | 60 +-- .../data/datasource/nostr/NostrDataSource.kt | 63 +-- .../data/datasource/vvo/VVODataSource.kt | 79 +-- .../data/datasource/xqt/XQTDataSource.kt | 79 +-- 43 files changed, 2186 insertions(+), 314 deletions(-) create mode 100644 appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/OutboxPostView.swift create mode 100644 compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/OutboxPostItem.kt create mode 100644 shared/schemas/dev.dimension.flare.data.database.app.AppDatabase/13.json create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeResult.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiOutboxPost.kt 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 7407cedc58..6a75363bf6 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 @@ -39,6 +39,9 @@ internal fun EntryProviderScope.homeEntryBuilder( navigate(Route.TabSettings) }, uriHandler = uriHandler, + onEditDraft = { groupId -> + navigate(Route.Compose.Draft(draftGroupId = groupId)) + }, ) } entry { args -> @@ -59,6 +62,9 @@ internal fun EntryProviderScope.homeEntryBuilder( toTabSettings = { navigate(Route.TabSettings) }, + onEditDraft = { groupId -> + navigate(Route.Compose.Draft(draftGroupId = groupId)) + }, ) } entry { args -> diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt index c33aa66edd..638f51cdc1 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -108,6 +108,7 @@ import dev.dimension.flare.ui.component.platform.isBigScreen import dev.dimension.flare.ui.component.platform.isCompatScreen import dev.dimension.flare.ui.component.status.AdaptiveCard import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid +import dev.dimension.flare.ui.component.status.outboxItems import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onError @@ -136,6 +137,7 @@ internal fun HomeTimelineScreen( toLogin: () -> Unit, toTabSettings: () -> Unit, uriHandler: UriHandler, + onEditDraft: (String) -> Unit, ) { val state by producePresenter(key = "home_timeline") { timelinePresenter() @@ -459,6 +461,7 @@ internal fun HomeTimelineScreen( isCurrentlyVisible = pagerState.currentPage == index, autoRefreshInterval = LocalAppSettings.current.homeTimelineAutoRefreshInterval, + onEditDraft = onEditDraft, ) } } @@ -544,6 +547,7 @@ internal fun TimelineItemContent( isCurrentlyVisible: Boolean = true, autoRefreshInterval: TimelineAutoRefreshInterval = TimelineAutoRefreshInterval.DISABLED, lazyStaggeredGridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + onEditDraft: (String) -> Unit = {}, ) { val isBigScreen = isBigScreen() val layoutDirection = LocalLayoutDirection.current @@ -650,6 +654,12 @@ internal fun TimelineItemContent( } } + outboxItems( + posts = state.outboxItems, + onRetry = state::retryOutbox, + onEdit = onEditDraft, + onDelete = state::deleteOutbox, + ) status(state.listState) } state.listState.onSuccess { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index 765c0effe3..dce72fb6d7 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -48,6 +48,7 @@ internal fun DeckTimelineScreen( toQuickMenu: () -> Unit, toLogin: () -> Unit, toTabSettings: () -> Unit, + onEditDraft: (String) -> Unit, ) { val state by producePresenter("deck_timeline_$id") { val loginState = remember { LoggedInPresenter() }.invoke() @@ -121,6 +122,8 @@ internal fun DeckTimelineScreen( item = tabItem, contentPadding = contentPadding, modifier = Modifier.fillMaxSize(), + isHomeTimeline = true, + onEditDraft = onEditDraft, ) } } diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/OutboxPostView.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/OutboxPostView.swift new file mode 100644 index 0000000000..1615678d50 --- /dev/null +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/OutboxPostView.swift @@ -0,0 +1,257 @@ +import FlareAppleCore +@preconcurrency import KotlinSharedUI +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +public struct OutboxPostView: View { + private let post: UiOutboxPost + private let onRetry: () -> Void + private let onEdit: () -> Void + private let onDelete: () -> Void + + public init( + post: UiOutboxPost, + onRetry: @escaping () -> Void, + onEdit: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.post = post + self.onRetry = onRetry + self.onEdit = onEdit + self.onDelete = onDelete + } + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + + if let spoilerText = post.data.spoilerText?.outboxNonEmpty { + Text(spoilerText) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if let content = post.data.content.outboxNonEmpty { + Text(content) + .font(.body) + .lineLimit(6) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if !post.medias.isEmpty { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(Array(post.medias.prefix(4).enumerated()), id: \.offset) { _, media in + OutboxMediaThumbnail(media: media) + } + } + } + .scrollIndicators(.hidden) + } + + ProgressView( + value: Double(post.progressCurrent), + total: Double(max(post.progressMax, 1)) + ) + Text( + FlareAppleUILocalization.string( + "outbox_progress", + fallback: "Step %d of %d", + arguments: [post.progressCurrent, post.progressMax] + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + + if post.targets.count > 1 { + VStack(spacing: 6) { + ForEach(Array(post.targets.enumerated()), id: \.offset) { _, target in + OutboxTargetRow(target: target) + } + } + } + + if post.status == .failed { + if let message = post.targets.compactMap(\.errorMessage).first(where: { !$0.isEmpty }) { + Text(message) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } + HStack { + Spacer() + Button(action: onEdit) { + Text(FlareAppleUILocalization.string("edit", fallback: "Edit")) + } + Button(role: .destructive, action: onDelete) { + Text(FlareAppleUILocalization.string("delete", fallback: "Delete")) + } + Button(action: onRetry) { + Text(FlareAppleUILocalization.string("action_retry", fallback: "Retry")) + } + .buttonStyle(.borderedProminent) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.flareSecondarySystemGroupedBackground) + .opacity(post.status == .failed ? 1 : 0.62) + .disabled(post.status != .failed) + .accessibilityElement(children: .contain) + .accessibilityValue(Text(post.status.localizedTitle)) + } + + private var header: some View { + HStack(spacing: 8) { + HStack(spacing: 4) { + ForEach(Array(post.targets.prefix(4).enumerated()), id: \.offset) { _, target in + if let avatar = target.avatar { + NetworkImage(data: avatar.url, customHeader: avatar.customHeaders) + .frame(width: 24, height: 24) + .clipShape(Circle()) + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + } + } + if post.targets.count == 1, let target = post.targets.first { + Text(target.account.accountKey.description()) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + DateTimeText(data: post.updatedAt) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + Text(post.status.localizedTitle) + .font(.caption.weight(.semibold)) + .foregroundStyle(post.status.tint) + .lineLimit(1) + } + } +} + +private struct OutboxTargetRow: View { + let target: UiOutboxTarget + + var body: some View { + HStack(spacing: 8) { + if let avatar = target.avatar { + NetworkImage(data: avatar.url, customHeader: avatar.customHeaders) + .frame(width: 20, height: 20) + .clipShape(Circle()) + } else { + Image(systemName: "person.crop.circle.fill") + .foregroundStyle(.secondary) + .frame(width: 20, height: 20) + } + Text(target.account.accountKey.description()) + .font(.caption) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Text(target.status.localizedTitle) + .font(.caption.weight(.medium)) + .foregroundStyle(target.status.tint) + Text("\(target.progressCurrent)/\(target.progressMax)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } +} + +private struct OutboxMediaThumbnail: View { + let media: UiDraftMedia + + var body: some View { + Group { + switch media.type { + case .image: + platformImage + case .video: + placeholder(icon: "video") + case .other: + placeholder(icon: "doc") + } + } + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + @ViewBuilder + private var platformImage: some View { + #if os(iOS) + if let image = UIImage(contentsOfFile: media.cachePath) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + placeholder(icon: "photo") + } + #elseif os(macOS) + if let image = NSImage(contentsOfFile: media.cachePath) { + Image(nsImage: image) + .resizable() + .scaledToFill() + } else { + placeholder(icon: "photo") + } + #else + placeholder(icon: "photo") + #endif + } + + private func placeholder(icon: String) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.quaternary) + Image(systemName: icon) + .foregroundStyle(.secondary) + } + } +} + +private extension UiOutboxStatus { + var localizedTitle: String { + switch self { + case .sending: + FlareAppleUILocalization.string("outbox_status_sending", fallback: "Sending") + case .failed: + FlareAppleUILocalization.string("outbox_status_failed", fallback: "Failed") + case .sent: + FlareAppleUILocalization.string("outbox_status_sent", fallback: "Sent") + } + } + + var tint: Color { + switch self { + case .sending: + .accentColor + case .failed: + .red + case .sent: + .green + } + } +} + +private extension String { + var outboxNonEmpty: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings b/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings index 0b5e0fb611..31b1a3d9d6 100644 --- a/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings +++ b/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings @@ -114986,6 +114986,94 @@ } } }, + "outbox_progress" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step %d of %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %d / %d 步" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %d / %d 步" + } + } + } + }, + "outbox_status_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送失败" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "傳送失敗" + } + } + } + }, + "outbox_status_sending" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sending" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在傳送" + } + } + } + }, + "outbox_status_sent" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sent" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已傳送" + } + } + } + }, "permission_denied_message" : { "localizations" : { "af" : { diff --git a/appleApp/ios/UI/Component/GalleryTimelinePagingView.swift b/appleApp/ios/UI/Component/GalleryTimelinePagingView.swift index 7ee934c57b..d863880136 100644 --- a/appleApp/ios/UI/Component/GalleryTimelinePagingView.swift +++ b/appleApp/ios/UI/Component/GalleryTimelinePagingView.swift @@ -10,6 +10,7 @@ import FlareAppleCore struct UIGalleryTimelinePagingView: UIViewControllerRepresentable { let data: PagingState + var accessoryItems: [UITimelineCollectionViewAccessoryItem] = [] var onIsAtTopChanged: (Bool) -> Void = { _ in } @Environment(\.timelineAppearance) private var timelineAppearance @Environment(\.translateConfig) private var translateConfig @@ -29,6 +30,7 @@ struct UIGalleryTimelinePagingView: UIViewControllerRepresentable { } controller.openURL = { url in openURL.callAsFunction(url) } controller.onIsAtTopChanged = onIsAtTopChanged + controller.accessoryItems = accessoryItems // Apply data before appearance so the appearance setter's reconfigure // sees a coherent itemIndexMap / currentSuccess pair. controller.update(data: data) @@ -48,6 +50,7 @@ struct UIGalleryTimelinePagingView: UIViewControllerRepresentable { ) controller.openURL = { url in openURL.callAsFunction(url) } controller.onIsAtTopChanged = onIsAtTopChanged + controller.accessoryItems = accessoryItems } } @@ -55,9 +58,11 @@ struct UIGalleryTimelinePagingView: UIViewControllerRepresentable { final class UIGalleryTimelineController: UIViewController, UICollectionViewDelegate, CHTCollectionViewDelegateWaterfallLayout { - private static let sectionMain = 0 - private static let sectionFooter = 1 + private static let sectionAccessories = 0 + private static let sectionMain = 1 + private static let sectionFooter = 2 + private static let accessoryPrefix = "ga:" private static let itemPrefix = "g:" private static let placeholderPrefix = "gp:" private static let emptyID = "__g_empty__" @@ -85,10 +90,25 @@ final class UIGalleryTimelineController: UIViewController, UICollectionViewDeleg private var lastLoadedItemIDs: Set = [] private var lastProcessedDataRef: AnyObject? private var lastProcessedUpdateSignature: UpdateSignature? + private var accessoryItemMap: [String: UITimelineCollectionViewAccessoryItem] = [:] var refreshCallback: (() async -> Void)? var openURL: ((URL) -> Void)? var onIsAtTopChanged: ((Bool) -> Void)? + var accessoryItems: [UITimelineCollectionViewAccessoryItem] = [] { + didSet { + let oldIDs = oldValue.map { "\(Self.accessoryPrefix)\($0.id)" } + let newIDs = accessoryItems.map { "\(Self.accessoryPrefix)\($0.id)" } + accessoryItemMap = Dictionary(uniqueKeysWithValues: zip(newIDs, accessoryItems)) + guard isViewLoaded else { return } + if oldIDs == newIDs { + collectionView.collectionViewLayout.invalidateLayout() + reconfigureItems(newIDs) + } else if let currentData { + applySnapshot(data: currentData) + } + } + } var appearance = GalleryUIKitAppearance(timeline: TimelineAppearance.companion.Default) { didSet { guard isViewLoaded else { return } @@ -116,6 +136,7 @@ final class UIGalleryTimelineController: UIViewController, UICollectionViewDeleg private var lastReportedIsAtTop: Bool? private struct SnapshotSignature: Equatable { + let accessoryIDs: [String] let itemIDs: [String] let footerIDs: [String] } @@ -222,7 +243,9 @@ final class UIGalleryTimelineController: UIViewController, UICollectionViewDeleg // MARK: - Cell configuration private func configureCell(_ cell: GalleryTimelineCollectionViewCell, itemID: String) { - if itemID.hasPrefix(Self.itemPrefix) { + if let accessory = accessoryItemMap[itemID] { + cell.setHostedView(accessory.view) + } else if itemID.hasPrefix(Self.itemPrefix) { if let index = itemIndexMap[itemID], let success = currentSuccess, index >= 0, @@ -499,6 +522,11 @@ final class UIGalleryTimelineController: UIViewController, UICollectionViewDeleg var newLoadedItemIDs = Set() var itemIDs: [String] = [] var footerIDs: [String] = [] + let accessoryIDs = accessoryItems.map { "\(Self.accessoryPrefix)\($0.id)" } + if !accessoryIDs.isEmpty { + snapshot.appendSections([Self.sectionAccessories]) + snapshot.appendItems(accessoryIDs, toSection: Self.sectionAccessories) + } switch onEnum(of: data) { case .loading: @@ -531,10 +559,15 @@ final class UIGalleryTimelineController: UIViewController, UICollectionViewDeleg } } - let newSignature = SnapshotSignature(itemIDs: itemIDs, footerIDs: footerIDs) + let newSignature = SnapshotSignature( + accessoryIDs: accessoryIDs, + itemIDs: itemIDs, + footerIDs: footerIDs + ) let previousSignature = lastAppliedSignature let scrollAnchor = previousSignature != nil && - previousSignature?.itemIDs != newSignature.itemIDs && + (previousSignature?.accessoryIDs != newSignature.accessoryIDs || + previousSignature?.itemIDs != newSignature.itemIDs) && allowsScrollAnchorRestoration ? captureScrollAnchor() : nil @@ -554,7 +587,8 @@ final class UIGalleryTimelineController: UIViewController, UICollectionViewDeleg return } - if previousSignature?.itemIDs == newSignature.itemIDs { + if previousSignature?.accessoryIDs == newSignature.accessoryIDs, + previousSignature?.itemIDs == newSignature.itemIDs { let changedIDs = changedItemIDs( in: itemIDs, newRenderHashMap: newRenderHashMap, @@ -687,8 +721,10 @@ final class UIGalleryTimelineController: UIViewController, UICollectionViewDeleg guard let layout = collectionViewLayout as? CHTCollectionViewWaterfallLayout else { return CGSize(width: collectionView.bounds.width, height: 200) } - let section = indexPath.section - let columns = section == Self.sectionFooter ? 1 : max(layout.columnCount, 1) + let section = sectionIdentifier(at: indexPath.section) + let columns = section == Self.sectionAccessories || section == Self.sectionFooter + ? 1 + : max(layout.columnCount, 1) let insets = layout.sectionInset let available = collectionView.bounds.width - insets.left - insets.right let totalSpacing = CGFloat(columns - 1) * layout.minimumColumnSpacing @@ -706,6 +742,15 @@ final class UIGalleryTimelineController: UIViewController, UICollectionViewDeleg default: break } + if let accessory = accessoryItemMap[itemID] { + let height = accessory.view.systemLayoutSizeFitting( + CGSize(width: width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height + return CGSize(width: width, height: max(ceil(height), 1)) + } + if itemID.hasPrefix(Self.placeholderPrefix) { return CGSize(width: width, height: width + 32) } @@ -728,7 +773,15 @@ final class UIGalleryTimelineController: UIViewController, UICollectionViewDeleg columnCountFor section: Int ) -> Int { guard let layout = collectionViewLayout as? CHTCollectionViewWaterfallLayout else { return 2 } - return section == Self.sectionFooter ? 1 : max(layout.columnCount, 1) + let sectionID = sectionIdentifier(at: section) + return sectionID == Self.sectionAccessories || sectionID == Self.sectionFooter + ? 1 + : max(layout.columnCount, 1) + } + + private func sectionIdentifier(at index: Int) -> Int? { + let sections = dataSource.snapshot().sectionIdentifiers + return sections.indices.contains(index) ? sections[index] : nil } private func estimatedHeight(for item: UiTimelineV2, itemID: String, width: CGFloat) -> CGFloat { diff --git a/appleApp/ios/UI/Component/TimelinePagingView.swift b/appleApp/ios/UI/Component/TimelinePagingView.swift index dd256adecf..7098bc0038 100644 --- a/appleApp/ios/UI/Component/TimelinePagingView.swift +++ b/appleApp/ios/UI/Component/TimelinePagingView.swift @@ -38,7 +38,11 @@ struct UITimelinePagingView: View { var body: some View { if allowGalleryMode && timelineDisplayMode == .gallery { - UIGalleryTimelinePagingView(data: data, onIsAtTopChanged: onIsAtTopChanged) + UIGalleryTimelinePagingView( + data: data, + accessoryItems: accessoryItems, + onIsAtTopChanged: onIsAtTopChanged + ) .ignoresSafeArea(edges: .vertical) } else if UIDevice.current.userInterfaceIdiom == .phone || horizontalSizeClass == .compact { diff --git a/appleApp/ios/UI/Screen/HomeTimelineScreen.swift b/appleApp/ios/UI/Screen/HomeTimelineScreen.swift index 0ea701457d..63835594c2 100644 --- a/appleApp/ios/UI/Screen/HomeTimelineScreen.swift +++ b/appleApp/ios/UI/Screen/HomeTimelineScreen.swift @@ -109,7 +109,8 @@ struct HomeTimelineScreen: View { isHomeTimeline: true, accessoryItems: resolvedTimelineAppearance.timelineDisplayMode == .gallery ? [] - : changeLogAccessoryItems + : changeLogAccessoryItems, + onEditDraft: { onNavigate(.composeDraft($0)) } ) .environment(\.timelineAppearance, resolvedTimelineAppearance) .id(tab.id) @@ -416,7 +417,8 @@ private struct DeckTimelineLayout: View { DeckTimelineColumnRoot( tabItem: tab, baseTimelineAppearance: baseTimelineAppearance, - toTabSetting: toTabSetting + toTabSetting: toTabSetting, + onGlobalRoute: onGlobalRoute ) .environment(\.horizontalSizeClass, .compact) .ignoresSafeArea() @@ -437,9 +439,15 @@ private struct DeckTimelineColumnRoot: View { let tabItem: UiTimelineTabItem let baseTimelineAppearance: TimelineAppearance let toTabSetting: () -> Void + let onGlobalRoute: (Route) -> Void var body: some View { - TimelineScreen(tabItem: tabItem, allowGalleryMode: true) + TimelineScreen( + tabItem: tabItem, + allowGalleryMode: true, + isHomeTimeline: true, + onEditDraft: { onGlobalRoute(.composeDraft($0)) } + ) .safeAreaInset(edge: .bottom) { Label { TimelineTabTitle(title: tabItem.title) diff --git a/appleApp/ios/UI/Screen/TimelineScreen.swift b/appleApp/ios/UI/Screen/TimelineScreen.swift index 4931652039..352b28bd7c 100644 --- a/appleApp/ios/UI/Screen/TimelineScreen.swift +++ b/appleApp/ios/UI/Screen/TimelineScreen.swift @@ -10,22 +10,26 @@ struct TimelineScreen: View { let allowGalleryMode: Bool let isHomeTimeline: Bool let accessoryItems: [UITimelineCollectionViewAccessoryItem] + let onEditDraft: (String) -> Void @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.appSettings) private var appSettings @Environment(\.scenePhase) private var scenePhase @StateObject var presenter: KotlinPresenter @State private var isAtTop = true @State private var isTabRefreshInFlight = false + @StateObject private var outboxAccessoryStore = OutboxAccessoryStore() init( tabItem: UiTimelineTabItem, allowGalleryMode: Bool = false, isHomeTimeline: Bool = false, - accessoryItems: [UITimelineCollectionViewAccessoryItem] = [] + accessoryItems: [UITimelineCollectionViewAccessoryItem] = [], + onEditDraft: @escaping (String) -> Void = { _ in } ) { self.tabItem = tabItem self.allowGalleryMode = allowGalleryMode self.isHomeTimeline = isHomeTimeline self.accessoryItems = accessoryItems + self.onEditDraft = onEditDraft self._presenter = .init( wrappedValue: .init( presenter: TimelineItemPresenter( @@ -36,12 +40,18 @@ struct TimelineScreen: View { ) } var body: some View { + let outboxAccessoryItems = outboxAccessoryStore.update( + posts: Array(presenter.state.outboxItems), + onRetry: { presenter.state.retryOutbox(groupId: $0) }, + onEdit: onEditDraft, + onDelete: { presenter.state.deleteOutbox(groupId: $0) } + ) UITimelinePagingView( data: presenter.state.listState, detailStatusKey: nil, key: presenter.key, allowGalleryMode: allowGalleryMode, - accessoryItems: accessoryItems, + accessoryItems: outboxAccessoryItems + accessoryItems, onIsAtTopChanged: { isAtTop = $0 } ) .refreshable { @@ -75,6 +85,99 @@ struct TimelineScreen: View { } } +private final class OutboxAccessoryStore: ObservableObject { + private var hosts: [String: OutboxHostedAccessoryView] = [:] + + func update( + posts: [UiOutboxPost], + onRetry: @escaping (String) -> Void, + onEdit: @escaping (String) -> Void, + onDelete: @escaping (String) -> Void + ) -> [UITimelineCollectionViewAccessoryItem] { + let activeIDs = Set(posts.map(\.groupId)) + hosts = hosts.filter { activeIDs.contains($0.key) } + return posts.map { post in + let host = hosts[post.groupId] ?? OutboxHostedAccessoryView() + hosts[post.groupId] = host + host.update( + AnyView( + OutboxPostView( + post: post, + onRetry: { onRetry(post.groupId) }, + onEdit: { onEdit(post.groupId) }, + onDelete: { onDelete(post.groupId) } + ) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.horizontal, 16) + .padding(.vertical, 4) + ) + ) + return UITimelineCollectionViewAccessoryItem(id: "outbox_\(post.groupId)", view: host) + } + } +} + +private final class OutboxHostedAccessoryView: UIView { + private let host = UIHostingController(rootView: AnyView(EmptyView())) + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + func update(_ rootView: AnyView) { + host.rootView = rootView + host.view.invalidateIntrinsicContentSize() + invalidateIntrinsicContentSize() + setNeedsLayout() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + host.willMove(toParent: nil) + host.removeFromParent() + } else if let parent = findParentViewController(), host.parent !== parent { + host.willMove(toParent: nil) + host.removeFromParent() + parent.addChild(host) + host.didMove(toParent: parent) + } + } + + private func commonInit() { + backgroundColor = .clear + host.view.backgroundColor = .clear + host.view.translatesAutoresizingMaskIntoConstraints = false + if #available(iOS 16.0, *) { + host.sizingOptions = [.intrinsicContentSize] + } + addSubview(host.view) + NSLayoutConstraint.activate([ + host.view.topAnchor.constraint(equalTo: topAnchor), + host.view.leadingAnchor.constraint(equalTo: leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: trailingAnchor), + host.view.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + private func findParentViewController() -> UIViewController? { + var responder: UIResponder? = self + while let current = responder { + if let viewController = current as? UIViewController { + return viewController + } + responder = current.next + } + return nil + } +} + struct ListTimelineScreen: View { let tabItem: UiTimelineTabItem var body: some View { diff --git a/appleApp/macos/UI/Component/TimelinePagingView.swift b/appleApp/macos/UI/Component/TimelinePagingView.swift index 7c2aac66a2..5b76317b3b 100644 --- a/appleApp/macos/UI/Component/TimelinePagingView.swift +++ b/appleApp/macos/UI/Component/TimelinePagingView.swift @@ -15,6 +15,10 @@ struct TimelinePagingView: View { let topContentInset: CGFloat let allowGalleryMode: Bool let suppressInitialRefreshIndicator: Bool + let outboxItems: [UiOutboxPost] + let onRetryOutbox: (String) -> Void + let onEditOutbox: (String) -> Void + let onDeleteOutbox: (String) -> Void init( data: PagingState, @@ -22,7 +26,11 @@ struct TimelinePagingView: View { key: String, topContentInset: CGFloat = 0, allowGalleryMode: Bool = false, - suppressInitialRefreshIndicator: Bool = false + suppressInitialRefreshIndicator: Bool = false, + outboxItems: [UiOutboxPost] = [], + onRetryOutbox: @escaping (String) -> Void = { _ in }, + onEditOutbox: @escaping (String) -> Void = { _ in }, + onDeleteOutbox: @escaping (String) -> Void = { _ in } ) { self.data = data self.detailStatusKey = detailStatusKey @@ -30,6 +38,10 @@ struct TimelinePagingView: View { self.topContentInset = topContentInset self.allowGalleryMode = allowGalleryMode self.suppressInitialRefreshIndicator = suppressInitialRefreshIndicator + self.outboxItems = outboxItems + self.onRetryOutbox = onRetryOutbox + self.onEditOutbox = onEditOutbox + self.onDeleteOutbox = onDeleteOutbox } var body: some View { @@ -54,22 +66,38 @@ struct TimelinePagingView: View { @ViewBuilder private func content(columnCount: Int, availableWidth: CGFloat) -> some View { - if allowGalleryMode && timelineDisplayMode == .gallery { - MacGalleryTimelineMasonryView( - data: data, - columnCount: max(columnCount, 2), - availableWidth: availableWidth - ) - } else if columnCount > 1 { - MacTimelineMasonryView( - data: data, - detailStatusKey: detailStatusKey, - columnCount: columnCount, - availableWidth: availableWidth - ) - } else { + VStack(spacing: 0) { LazyVStack(spacing: 2) { - TimelinePagingContent(data: data, detailStatusKey: detailStatusKey) + ForEach(outboxItems, id: \.groupId) { post in + OutboxPostView( + post: post, + onRetry: { onRetryOutbox(post.groupId) }, + onEdit: { onEditOutbox(post.groupId) }, + onDelete: { onDeleteOutbox(post.groupId) } + ) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + } + + if allowGalleryMode && timelineDisplayMode == .gallery { + MacGalleryTimelineMasonryView( + data: data, + columnCount: max(columnCount, 2), + availableWidth: availableWidth + ) + } else if columnCount > 1 { + MacTimelineMasonryView( + data: data, + detailStatusKey: detailStatusKey, + columnCount: columnCount, + availableWidth: availableWidth + ) + } else { + LazyVStack(spacing: 2) { + TimelinePagingContent(data: data, detailStatusKey: detailStatusKey) + } } } } diff --git a/appleApp/macos/UI/Screen/TimelineScreen.swift b/appleApp/macos/UI/Screen/TimelineScreen.swift index 836db7cdbc..f2c0e8c40a 100644 --- a/appleApp/macos/UI/Screen/TimelineScreen.swift +++ b/appleApp/macos/UI/Screen/TimelineScreen.swift @@ -33,7 +33,13 @@ struct TimelineScreen: View { data: presenter.state.listState, detailStatusKey: nil, key: presenter.key, - allowGalleryMode: allowGalleryMode + allowGalleryMode: allowGalleryMode, + outboxItems: Array(presenter.state.outboxItems), + onRetryOutbox: { presenter.state.retryOutbox(groupId: $0) }, + onEditOutbox: { + MacComposeWindowCoordinator.shared.openDraft(groupId: $0, openWindow: openWindow) + }, + onDeleteOutbox: { presenter.state.deleteOutbox(groupId: $0) } ) .environment(\.timelineAppearance, tabItem.resolveTimelineAppearance(base: timelineAppearance)) .refreshable { diff --git a/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml b/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml index 542e53954d..aa78dc5eb6 100644 --- a/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -1,6 +1,11 @@ 删除 + 正在发送 + 发送失败 + 已发送 + 第 %1$d / %2$d 步 + 编辑 完成 取消 确定 diff --git a/compose-ui/src/commonMain/composeResources/values-zh-rTW/strings.xml b/compose-ui/src/commonMain/composeResources/values-zh-rTW/strings.xml index 37354f115f..88f6dcbff4 100644 --- a/compose-ui/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -1,6 +1,11 @@ 刪除 + 正在傳送 + 傳送失敗 + 已傳送 + 第 %1$d / %2$d 步 + 編輯 完成 取消 確定 diff --git a/compose-ui/src/commonMain/composeResources/values/strings.xml b/compose-ui/src/commonMain/composeResources/values/strings.xml index b646ad050c..07c5792edb 100644 --- a/compose-ui/src/commonMain/composeResources/values/strings.xml +++ b/compose-ui/src/commonMain/composeResources/values/strings.xml @@ -1,6 +1,11 @@ Flare Delete + Sending + Failed + Sent + Step %1$d of %2$d + Edit Done Cancel OK diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/OutboxPostItem.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/OutboxPostItem.kt new file mode 100644 index 0000000000..77acc3a310 --- /dev/null +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/status/OutboxPostItem.kt @@ -0,0 +1,293 @@ +package dev.dimension.flare.ui.component.status + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.dimension.flare.compose.ui.Res +import dev.dimension.flare.compose.ui.delete +import dev.dimension.flare.compose.ui.outbox_edit +import dev.dimension.flare.compose.ui.outbox_progress +import dev.dimension.flare.compose.ui.outbox_status_failed +import dev.dimension.flare.compose.ui.outbox_status_sending +import dev.dimension.flare.compose.ui.outbox_status_sent +import dev.dimension.flare.compose.ui.status_loadmore_error_retry +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.DateTimeText +import dev.dimension.flare.ui.component.NetworkImage +import dev.dimension.flare.ui.component.platform.PlatformButton +import dev.dimension.flare.ui.component.platform.PlatformErrorButton +import dev.dimension.flare.ui.component.platform.PlatformFilledTonalButton +import dev.dimension.flare.ui.component.platform.PlatformLinearProgressIndicator +import dev.dimension.flare.ui.component.platform.PlatformText +import dev.dimension.flare.ui.model.UiDraftMediaType +import dev.dimension.flare.ui.model.UiOutboxPost +import dev.dimension.flare.ui.model.UiOutboxStatus +import dev.dimension.flare.ui.model.UiOutboxTarget +import dev.dimension.flare.ui.theme.PlatformTheme +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.stringResource + +public fun LazyStaggeredGridScope.outboxItems( + posts: ImmutableList, + onRetry: (String) -> Unit, + onEdit: (String) -> Unit, + onDelete: (String) -> Unit, +) { + items( + count = posts.size, + key = { index -> posts[index].key }, + contentType = { "outbox" }, + span = { StaggeredGridItemSpan.FullLine }, + ) { index -> + val item = posts[index] + AdaptiveCard( + modifier = Modifier.fillMaxWidth(), + respectTimelineMode = true, + ) { + OutboxPostItem( + item = item, + onRetry = { onRetry(item.groupId) }, + onEdit = { onEdit(item.groupId) }, + onDelete = { onDelete(item.groupId) }, + ) + } + } +} + +@Composable +private fun OutboxPostItem( + item: UiOutboxPost, + onRetry: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val inactive = item.status != UiOutboxStatus.FAILED + Column( + modifier = + Modifier + .fillMaxWidth() + .then(if (inactive) Modifier.alpha(0.62f).semantics { disabled() } else Modifier) + .padding(horizontal = screenHorizontalPadding, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutboxHeader(item) + + item.data.spoilerText + ?.takeIf { it.isNotBlank() } + ?.let { + PlatformText( + text = it, + color = PlatformTheme.colorScheme.caption, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + item.data.content + .takeIf { it.isNotBlank() } + ?.let { + PlatformText( + text = it, + maxLines = 6, + overflow = TextOverflow.Ellipsis, + ) + } + + if (item.medias.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item.medias.take(4).forEach { media -> + if (media.type == UiDraftMediaType.IMAGE) { + NetworkImage( + model = media.cachePath, + contentDescription = media.altText, + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = + Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)) + .background(PlatformTheme.colorScheme.cardAlt), + contentAlignment = Alignment.Center, + ) { + PlatformText( + text = media.fileName.orEmpty(), + color = PlatformTheme.colorScheme.caption, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + + OutboxProgress(item) + + if (item.targets.size > 1) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + item.targets.forEach { target -> + OutboxTargetRow(target) + } + } + } + + if (item.status == UiOutboxStatus.FAILED) { + item.targets + .firstNotNullOfOrNull { target -> target.errorMessage?.takeIf { it.isNotBlank() } } + ?.let { + PlatformText( + text = it, + color = PlatformTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + PlatformButton(onClick = onEdit) { + PlatformText(stringResource(Res.string.outbox_edit)) + } + PlatformErrorButton(onClick = onDelete) { + PlatformText(stringResource(Res.string.delete)) + } + PlatformFilledTonalButton(onClick = onRetry) { + PlatformText(stringResource(Res.string.status_loadmore_error_retry)) + } + } + } + } +} + +@Composable +private fun OutboxHeader(item: UiOutboxPost) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + item.targets.take(4).forEach { target -> + AvatarComponent(data = target.avatar, size = 24.dp) + } + if (item.targets.size == 1) { + PlatformText( + text = + item.targets + .single() + .account + .accountKey + .toString(), + color = PlatformTheme.colorScheme.caption, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + DateTimeText( + data = item.updatedAt, + color = PlatformTheme.colorScheme.caption, + maxLines = 1, + ) + PlatformText( + text = statusLabel(item.status), + color = statusColor(item.status), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } +} + +@Composable +private fun OutboxProgress(item: UiOutboxPost) { + val progress = item.progressCurrent.toFloat() / item.progressMax.coerceAtLeast(1) + PlatformLinearProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + modifier = + Modifier + .fillMaxWidth() + .height(4.dp), + color = statusColor(item.status), + ) + PlatformText( + text = stringResource(Res.string.outbox_progress, item.progressCurrent, item.progressMax), + color = PlatformTheme.colorScheme.caption, + ) +} + +@Composable +private fun OutboxTargetRow(target: UiOutboxTarget) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AvatarComponent(data = target.avatar, size = 20.dp) + PlatformText( + text = target.account.accountKey.toString(), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + PlatformText( + text = statusLabel(target.status), + color = statusColor(target.status), + maxLines = 1, + ) + PlatformText( + text = "${target.progressCurrent}/${target.progressMax}", + color = PlatformTheme.colorScheme.caption, + maxLines = 1, + ) + } +} + +@Composable +private fun statusLabel(status: UiOutboxStatus): String = + stringResource( + when (status) { + UiOutboxStatus.SENDING -> Res.string.outbox_status_sending + UiOutboxStatus.FAILED -> Res.string.outbox_status_failed + UiOutboxStatus.SENT -> Res.string.outbox_status_sent + }, + ) + +@Composable +private fun statusColor(status: UiOutboxStatus) = + when (status) { + UiOutboxStatus.SENDING -> PlatformTheme.colorScheme.primary + UiOutboxStatus.FAILED -> PlatformTheme.colorScheme.error + UiOutboxStatus.SENT -> PlatformTheme.colorScheme.retweetColor + } 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 8eed89c224..95d9f9a53d 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 @@ -770,6 +770,9 @@ internal fun Router( Route.TabSetting, ) }, + onEditDraft = { groupId -> + navigate(Route.Compose.Draft(draftGroupId = groupId)) + }, ) } @@ -779,6 +782,9 @@ internal fun Router( toTabSettings = { navigate(Route.TabSetting) }, + onEditDraft = { groupId -> + navigate(Route.Compose.Draft(draftGroupId = groupId)) + }, ) } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt index aced40508d..d2ef26b8f5 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/HomeTimelineScreen.kt @@ -95,6 +95,7 @@ import dev.dimension.flare.ui.component.Text as UiText internal fun HomeTimelineScreen( accountType: AccountType, onAddTab: () -> Unit, + onEditDraft: (String) -> Unit, ) { val uriHandler = LocalUriHandler.current val state by producePresenter(key = "home_timeline_$accountType") { @@ -266,6 +267,7 @@ internal fun HomeTimelineScreen( onScrollToTop = { state.setTopBarExpanded(true) }, + onEditDraft = onEditDraft, header = if (LocalGlobalAppearance.current.showComposeInHomeTimeline && canComposeState.canCompose.takeSuccess() == true diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt index af97ca1771..0295242430 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/TimelineScreen.kt @@ -50,6 +50,7 @@ import dev.dimension.flare.ui.component.LocalTimelineAppearance import dev.dimension.flare.ui.component.TabIcon import dev.dimension.flare.ui.component.floatingToolbarVerticalNestedScroll import dev.dimension.flare.ui.component.status.LazyStatusVerticalStaggeredGrid +import dev.dimension.flare.ui.component.status.outboxItems import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.TimelineWithLazyListState @@ -71,12 +72,13 @@ import org.jetbrains.compose.resources.stringResource internal fun DeckTimelineScreen( id: String, toTabSettings: () -> Unit, + onEditDraft: (String) -> Unit, ) { val tabState by producePresenter("deck_timeline_$id") { remember { HomeTabItemPresenter(id = id) }.invoke() } tabState.tabItem.onSuccess { tabItem -> - val timelineState = rememberTimelineItemPresenterWithLazyListState(tabItem) + val timelineState = rememberTimelineItemPresenterWithLazyListState(tabItem, isHomeTimeline = true) val isTopBarExpanded = remember(tabItem.id) { androidx.compose.runtime.mutableStateOf(true) } val timelineAppearance = LocalTimelineAppearance.current @@ -108,6 +110,7 @@ internal fun DeckTimelineScreen( onScrollToTop = { isTopBarExpanded.value = true }, + onEditDraft = onEditDraft, ) AnimatedVisibility( visible = isTopBarExpanded.value, @@ -233,6 +236,7 @@ internal fun TimelineContent( header: @Composable (() -> Unit)? = null, onScrollToTop: (() -> Unit)? = null, allowGalleryMode: Boolean = false, + onEditDraft: (String) -> Unit = {}, ) { val scope = rememberCoroutineScope() RegisterTabCallback( @@ -273,6 +277,12 @@ internal fun TimelineContent( header.invoke() } } + outboxItems( + posts = state.outboxItems, + onRetry = state::retryOutbox, + onEdit = onEditDraft, + onDelete = state::deleteOutbox, + ) status(state.listState) } } diff --git a/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt b/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt index 4eab469b48..c05134f358 100644 --- a/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt +++ b/feature/agent/src/commonTest/kotlin/dev/dimension/flare/feature/agent/common/AgentToolsTest.kt @@ -6,6 +6,7 @@ import dev.dimension.flare.data.datasource.microblog.ActionMenu import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeResult import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.MicroblogDataSource import dev.dimension.flare.data.datasource.microblog.NotificationFilter @@ -1640,10 +1641,11 @@ private class StubComposeDataSource( override suspend fun compose( data: ComposeData, - progress: () -> Unit, - ) { + progress: suspend () -> Unit, + ): ComposeResult { composed = true lastData = data + return ComposeResult(MicroBlogKey("remote", accountKey.host)) } override fun composeConfig(type: ComposeType): ComposeConfig = diff --git a/shared/schemas/dev.dimension.flare.data.database.app.AppDatabase/13.json b/shared/schemas/dev.dimension.flare.data.database.app.AppDatabase/13.json new file mode 100644 index 0000000000..417c619187 --- /dev/null +++ b/shared/schemas/dev.dimension.flare.data.database.app.AppDatabase/13.json @@ -0,0 +1,455 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "c2c0c5827092ec29b02f0e6af596ccfd", + "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, `sort_id` INTEGER NOT NULL DEFAULT 0, 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": "platformId", + "columnName": "platform_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "last_active", + "columnName": "last_active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sort_id", + "columnName": "sort_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_key" + ] + } + }, + { + "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, `progress_current` INTEGER NOT NULL, `progress_max` INTEGER NOT NULL, `remote_post_key` TEXT, `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": "progress_current", + "columnName": "progress_current", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress_max", + "columnName": "progress_max", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remote_post_key", + "columnName": "remote_post_key", + "affinity": "TEXT" + }, + { + "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, `is_regex` INTEGER NOT NULL DEFAULT 0, 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 + }, + { + "fieldPath": "is_regex", + "columnName": "is_regex", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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, `displayMode` TEXT NOT NULL DEFAULT 'FULL_CONTENT', `lastUpdate` INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT 'RSS')", + "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": "displayMode", + "columnName": "displayMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'FULL_CONTENT'" + }, + { + "fieldPath": "lastUpdate", + "columnName": "lastUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'RSS'" + } + ], + "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, 'c2c0c5827092ec29b02f0e6af596ccfd')" + ] + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/PagingState.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/PagingState.kt index 716abbdab1..850d2e8cc9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/common/PagingState.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/PagingState.kt @@ -42,6 +42,7 @@ public sealed class PagingState { public sealed class Success : PagingState() { public abstract val itemCount: Int public abstract val isRefreshing: Boolean + public abstract val refreshError: Throwable? public abstract val appendState: LoadState public abstract operator fun get(index: Int): T? @@ -61,6 +62,7 @@ public sealed class PagingState { private val data: ImmutableList, override val itemCount: Int = data.size, override val isRefreshing: Boolean = false, + override val refreshError: Throwable? = null, override val appendState: LoadState = LoadState.NotLoading(endOfPaginationReached = true), private val onRefresh: suspend () -> Unit = {}, private val onRetry: () -> Unit = {}, @@ -93,6 +95,8 @@ public sealed class PagingState { get() = data.itemCount override val isRefreshing: Boolean get() = data.isRefreshing + override val refreshError: Throwable? + get() = (data.loadState.refresh as? LoadState.Error)?.error override operator fun get(index: Int): T? = if (index < 0 || index >= data.itemCount) { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/ProvideDatabase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/ProvideDatabase.kt index 815e5264bd..a60dcb06c9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/ProvideDatabase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/ProvideDatabase.kt @@ -14,6 +14,7 @@ internal fun provideAppDatabase(driverFactory: DriverFactory): AppDatabase = AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11, AppDatabase.MIGRATION_11_12, + AppDatabase.MIGRATION_12_13, ).setDriver(createDatabaseDriver()) .setQueryCoroutineContext(PlatformDispatchers.IO) .build() 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 89f891a1d3..db8049d504 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 @@ -25,7 +25,7 @@ import dev.dimension.flare.data.database.app.dao.SearchHistoryDao dev.dimension.flare.data.database.app.model.DbSearchHistory::class, dev.dimension.flare.data.database.app.model.DbRssSources::class, ], - version = 12, + version = 13, autoMigrations = [ AutoMigration( from = 3, @@ -103,6 +103,20 @@ internal abstract class AppDatabase : RoomDatabase() { // Kotlin now models platform_type as a String. The physical schema is unchanged. } } + val MIGRATION_12_13 = + object : Migration(12, 13) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.executeSQL( + "ALTER TABLE DbDraftTarget ADD COLUMN progress_current INTEGER NOT NULL DEFAULT 0", + ) + connection.executeSQL( + "ALTER TABLE DbDraftTarget ADD COLUMN progress_max INTEGER NOT NULL DEFAULT 1", + ) + connection.executeSQL( + "ALTER TABLE DbDraftTarget ADD COLUMN remote_post_key TEXT", + ) + } + } } } 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 index c2025a95bb..ac8ff49aa3 100644 --- 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 @@ -96,6 +96,20 @@ internal interface DraftDao { ) fun sendingDraftGroups(): Flow> + @Transaction + @Query( + """ + SELECT * FROM DbDraftGroup + WHERE EXISTS ( + SELECT 1 FROM DbDraftTarget + WHERE DbDraftTarget.group_id = DbDraftGroup.group_id + AND DbDraftTarget.status IN ('SENDING', 'SENT', 'FAILED') + ) + ORDER BY updated_at DESC + """, + ) + fun outboxDraftGroups(): Flow> + @Query( """ UPDATE DbDraftTarget @@ -116,6 +130,59 @@ internal interface DraftDao { updatedAt: Long, ) + @Query( + """ + UPDATE DbDraftTarget + SET status = 'SENDING', + error_message = NULL, + attempt_count = :attemptCount, + last_attempt_at = :lastAttemptAt, + progress_current = 0, + progress_max = :progressMax, + remote_post_key = NULL, + updated_at = :updatedAt + WHERE target_id = :targetId + """, + ) + suspend fun prepareTargetForSending( + targetId: String, + attemptCount: Int, + lastAttemptAt: Long, + progressMax: Int, + updatedAt: Long, + ) + + @Query( + """ + UPDATE DbDraftTarget + SET progress_current = :progressCurrent, + updated_at = :updatedAt + WHERE target_id = :targetId + """, + ) + suspend fun updateTargetProgress( + targetId: String, + progressCurrent: Int, + updatedAt: Long, + ) + + @Query( + """ + UPDATE DbDraftTarget + SET status = 'SENT', + error_message = NULL, + progress_current = progress_max, + remote_post_key = :remotePostKey, + updated_at = :updatedAt + WHERE target_id = :targetId + """, + ) + suspend fun markTargetSent( + targetId: String, + remotePostKey: dev.dimension.flare.model.MicroBlogKey?, + updatedAt: Long, + ) + @Query( """ UPDATE DbDraftGroup 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 index 589efc9e3d..c8992f98e1 100644 --- 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 @@ -61,6 +61,9 @@ internal enum class DraftTargetStatus { @SerialName("sending") SENDING, + @SerialName("sent") + SENT, + @SerialName("failed") FAILED, } @@ -115,6 +118,9 @@ internal data class DbDraftTarget( val error_message: String? = null, val attempt_count: Int = 0, val last_attempt_at: Long? = null, + val progress_current: Int = 0, + val progress_max: Int = 1, + val remote_post_key: MicroBlogKey? = null, val created_at: Long, val updated_at: Long, @PrimaryKey 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 91cc82c145..8a3d5296dd 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 @@ -21,8 +21,8 @@ public interface NotificationTimelineDataSource : AuthenticatedMicroblogDataSour public interface ComposeDataSource : AuthenticatedMicroblogDataSource { public suspend fun compose( data: ComposeData, - progress: () -> Unit, - ) + progress: suspend () -> Unit, + ): ComposeResult public fun composeConfig(type: ComposeType): ComposeConfig } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeResult.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeResult.kt new file mode 100644 index 0000000000..977c37f8c1 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ComposeResult.kt @@ -0,0 +1,9 @@ +package dev.dimension.flare.data.datasource.microblog + +import androidx.compose.runtime.Immutable +import dev.dimension.flare.model.MicroBlogKey + +@Immutable +public data class ComposeResult( + val remotePostKey: MicroBlogKey? = null, +) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/Timeline.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/Timeline.kt index e6f09efeb2..e30a77cdbd 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/Timeline.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/model/tab/Timeline.kt @@ -662,6 +662,21 @@ internal class TimelineResolver( is UiGroupTimelineTabItem -> null } + fun resolveAccountKeys(item: UiTimelineTabItem): Set = + when (item) { + is UiSourceTimelineTabItem -> { + setOfNotNull(resolveAccountKey(item)) + } + + is UiGroupTimelineTabItem -> { + item.children + .asSequence() + .filter { it.enabled } + .flatMap { resolveAccountKeys(it).asSequence() } + .toSet() + } + } + @OptIn(ExperimentalSerializationApi::class) fun resolveAccountKey(slot: TimelineSlot): MicroBlogKey? = when (val content = slot.content) { 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 index 0d36af504b..5d0de2c9e5 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/DraftRepository.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/repository/DraftRepository.kt @@ -35,6 +35,12 @@ internal class DraftRepository( .sendingDraftGroups() .map { drafts -> drafts.map { it.toModel() } } + val outboxDrafts: Flow> = + database + .draftDao() + .outboxDraftGroups() + .map { drafts -> drafts.map { it.toModel() } } + fun draft(groupId: String): Flow = database .draftDao() @@ -66,6 +72,9 @@ internal class DraftRepository( error_message = target.errorMessage, attempt_count = target.attemptCount, last_attempt_at = target.lastAttemptAt, + progress_current = target.progressCurrent, + progress_max = target.progressMax, + remote_post_key = target.remotePostKey, created_at = target.createdAt ?: now, updated_at = now, ) @@ -133,6 +142,63 @@ internal class DraftRepository( } } + suspend fun updateTargetProgress( + groupId: String, + accountKey: MicroBlogKey, + current: Int, + ) { + val now = Clock.System.now().toEpochMilliseconds() + database.connect { + database.draftDao().updateTargetProgress( + targetId = targetId(groupId, accountKey), + progressCurrent = current, + updatedAt = now, + ) + database.draftDao().touchGroup(groupId = groupId, updatedAt = now) + } + } + + suspend fun prepareTargetForSending( + groupId: String, + accountKey: MicroBlogKey, + progressMax: Int, + attemptCount: Int = 1, + ) { + val now = Clock.System.now().toEpochMilliseconds() + database.connect { + database.draftDao().prepareTargetForSending( + targetId = targetId(groupId, accountKey), + attemptCount = attemptCount, + lastAttemptAt = now, + progressMax = progressMax.coerceAtLeast(1), + updatedAt = now, + ) + database.draftDao().touchGroup(groupId = groupId, updatedAt = now) + } + } + + suspend fun markTargetSent( + groupId: String, + accountKey: MicroBlogKey, + remotePostKey: MicroBlogKey?, + ) { + val now = Clock.System.now().toEpochMilliseconds() + database.connect { + database.draftDao().markTargetSent( + targetId = targetId(groupId, accountKey), + remotePostKey = remotePostKey, + updatedAt = now, + ) + database.draftDao().touchGroup(groupId = groupId, updatedAt = now) + } + } + + suspend fun deleteTargets(targets: Collection) { + targets.distinct().forEach { target -> + deleteTarget(target.groupId, target.accountKey) + } + } + suspend fun deleteGroup(groupId: String) { val medias = database @@ -202,6 +268,9 @@ internal data class SaveDraftTarget( val errorMessage: String? = null, val attemptCount: Int = 0, val lastAttemptAt: Long? = null, + val progressCurrent: Int = 0, + val progressMax: Int = 1, + val remotePostKey: MicroBlogKey? = null, val createdAt: Long? = null, ) @@ -230,10 +299,18 @@ internal data class DraftTarget( val errorMessage: String?, val attemptCount: Int, val lastAttemptAt: Long?, + val progressCurrent: Int, + val progressMax: Int, + val remotePostKey: MicroBlogKey?, val createdAt: Long, val updatedAt: Long, ) +internal data class DraftTargetKey( + val groupId: String, + val accountKey: MicroBlogKey, +) + internal data class DraftMedia( val mediaId: String, val groupId: String, @@ -275,6 +352,9 @@ private fun DbDraftGroupWithRelations.toModel(): DraftGroup = errorMessage = it.error_message, attemptCount = it.attempt_count, lastAttemptAt = it.last_attempt_at, + progressCurrent = it.progress_current, + progressMax = it.progress_max, + remotePostKey = it.remote_post_key, createdAt = it.created_at, updatedAt = it.updated_at, ) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiOutboxPost.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiOutboxPost.kt new file mode 100644 index 0000000000..1f760706ba --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiOutboxPost.kt @@ -0,0 +1,40 @@ +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 +import dev.dimension.flare.ui.render.UiDateTime +import kotlinx.collections.immutable.ImmutableList + +@Immutable +public data class UiOutboxPost( + val groupId: String, + val status: UiOutboxStatus, + val updatedAt: UiDateTime, + val targets: ImmutableList, + val data: ComposeData, + val medias: ImmutableList, + val progressCurrent: Int, + val progressMax: Int, +) { + public val key: String + get() = "outbox_$groupId" +} + +@Immutable +public data class UiOutboxTarget( + val account: UiAccount, + val avatar: UiMedia.Image? = null, + val status: UiOutboxStatus, + val progressCurrent: Int, + val progressMax: Int, + val errorMessage: String? = null, + val remotePostKey: MicroBlogKey? = null, +) + +@Immutable +public enum class UiOutboxStatus { + SENDING, + FAILED, + SENT, +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt index f1137b2e28..4ba0afcc25 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/TimelineItemPresenter.kt @@ -1,31 +1,74 @@ package dev.dimension.flare.ui.presenter import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.isRefreshing +import dev.dimension.flare.data.database.app.model.DraftMediaType +import dev.dimension.flare.data.database.app.model.DraftTargetStatus import dev.dimension.flare.data.model.tab.TimelinePresenterFactory import dev.dimension.flare.data.model.tab.TimelineResolver import dev.dimension.flare.data.model.tab.UiTimelineTabItem +import dev.dimension.flare.data.repository.DraftGroup +import dev.dimension.flare.data.repository.DraftRepository +import dev.dimension.flare.data.repository.DraftTargetKey import dev.dimension.flare.di.koinInject +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.ui.model.UiAccount +import dev.dimension.flare.ui.model.UiDraftMedia +import dev.dimension.flare.ui.model.UiDraftMediaType +import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiOutboxPost +import dev.dimension.flare.ui.model.UiOutboxStatus +import dev.dimension.flare.ui.model.UiOutboxTarget import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.presenter.compose.ComposeUseCase +import dev.dimension.flare.ui.presenter.compose.toComposeData +import dev.dimension.flare.ui.presenter.settings.AccountsPresenter +import dev.dimension.flare.ui.render.toUi import dev.dimension.flare.web.shared.WebPresenter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Instant public class TimelineItemPresenter( private val timelineTabItem: UiTimelineTabItem, private val isHomeTimeline: Boolean = false, ) : PresenterBase() { private val timelinePresenterFactory by koinInject() + private val timelineResolver by koinInject() + private val draftRepository by koinInject() + private val composeUseCase by koinInject() + + private val outboxAccountKeys by lazy { + if (isHomeTimeline) { + timelineResolver.resolveAccountKeys(timelineTabItem) + } else { + emptySet() + } + } public interface State { public val listState: PagingState + public val outboxItems: ImmutableList public fun refreshSync() public suspend fun refreshSuspend() public val isRefreshing: Boolean + + public fun retryOutbox(groupId: String) + + public fun deleteOutbox(groupId: String) } private val timelinePresenter by lazy { @@ -36,23 +79,167 @@ public class TimelineItemPresenter( override fun body(): State { val state = timelinePresenter.body() val scope = rememberCoroutineScope() + val outboxItems = + if (outboxAccountKeys.isEmpty()) { + persistentListOf() + } else { + val drafts by draftRepository.outboxDrafts.collectAsState(emptyList()) + val accountsState = remember { AccountsPresenter() }.body() + remember(drafts, accountsState.accounts, outboxAccountKeys) { + val accountItems = accountsState.accounts.takeSuccess().orEmpty() + val accountMap = accountItems.associate { it.account.accountKey to it.account } + val avatarMap = + accountItems.associate { item -> + item.account.accountKey to item.profile.takeSuccess()?.avatar + } + drafts.toUiOutboxPosts( + accountKeys = outboxAccountKeys, + accountMap = accountMap, + avatarMap = avatarMap, + ) + } + } + + suspend fun refreshWithOutboxCleanup() { + val success = state.listState as? PagingState.Success + val sentTargets = outboxItems.sentTargetKeys() + state.refresh() + if (success != null && success.refreshError == null) { + draftRepository.deleteTargets(sentTargets) + } + } + + val loadedRemoteKeys = + (state.listState as? PagingState.Success) + ?.let { success -> + buildSet { + repeat(success.itemCount) { index -> + success.peek(index)?.statusKey?.let(::add) + } + } + }.orEmpty() + val matchedTargets = outboxItems.sentTargetKeys(remoteKeys = loadedRemoteKeys) + LaunchedEffect(matchedTargets) { + if (matchedTargets.isNotEmpty()) { + draftRepository.deleteTargets(matchedTargets) + } + } + + val completedTargets = + outboxItems + .filter { it.status != UiOutboxStatus.SENDING } + .sentTargetKeys() + LaunchedEffect(completedTargets) { + if (completedTargets.isNotEmpty()) { + delay(3_000) + refreshWithOutboxCleanup() + } + } + return object : State { override val listState = state.listState + override val outboxItems = outboxItems override val isRefreshing = listState.isRefreshing override fun refreshSync() { scope.launch { - state.refresh() + refreshWithOutboxCleanup() } } override suspend fun refreshSuspend() { - state.refresh() + refreshWithOutboxCleanup() + } + + override fun retryOutbox(groupId: String) { + composeUseCase.sendDraft(groupId) + } + + override fun deleteOutbox(groupId: String) { + scope.launch { + draftRepository.deleteGroup(groupId) + } } } } } +private fun List.toUiOutboxPosts( + accountKeys: Set, + accountMap: Map, + avatarMap: Map, +): ImmutableList = + mapNotNull { draft -> + val targets = + draft.targets + .filter { it.accountKey in accountKeys } + .mapNotNull { target -> + val status = target.status.toUiOutboxStatus() ?: return@mapNotNull null + val account = accountMap[target.accountKey] ?: return@mapNotNull null + UiOutboxTarget( + account = account, + avatar = avatarMap[target.accountKey], + status = status, + progressCurrent = target.progressCurrent.coerceIn(0, target.progressMax), + progressMax = target.progressMax.coerceAtLeast(1), + errorMessage = target.errorMessage, + remotePostKey = target.remotePostKey, + ) + } + if (targets.isEmpty()) { + return@mapNotNull null + } + val status = + when { + targets.any { it.status == UiOutboxStatus.SENDING } -> UiOutboxStatus.SENDING + targets.any { it.status == UiOutboxStatus.FAILED } -> UiOutboxStatus.FAILED + else -> UiOutboxStatus.SENT + } + UiOutboxPost( + groupId = draft.groupId, + status = status, + updatedAt = Instant.fromEpochMilliseconds(draft.updatedAt).toUi(), + targets = targets.toImmutableList(), + 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(), + progressCurrent = targets.sumOf { it.progressCurrent }, + progressMax = targets.sumOf { it.progressMax }, + ) + }.toImmutableList() + +private fun DraftTargetStatus.toUiOutboxStatus(): UiOutboxStatus? = + when (this) { + DraftTargetStatus.DRAFT -> null + DraftTargetStatus.SENDING -> UiOutboxStatus.SENDING + DraftTargetStatus.SENT -> UiOutboxStatus.SENT + DraftTargetStatus.FAILED -> UiOutboxStatus.FAILED + } + +private fun List.sentTargetKeys(remoteKeys: Set? = null): List = + flatMap { item -> + item.targets + .filter { target -> + target.status == UiOutboxStatus.SENT && + (remoteKeys == null || target.remotePostKey?.let(remoteKeys::contains) == true) + }.map { target -> + DraftTargetKey(groupId = item.groupId, accountKey = target.account.accountKey) + } + } + @WebPresenter("timelineItem") public class WebTimelineItemPresenter( private val loaderKey: String, 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 1769f94e39..b090211da4 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 @@ -36,26 +36,8 @@ internal class ComposeUseCase( data = data, groupId = groupId, onPrepared = onPrepared, - ) { - if (it is ComposeProgressState.Error) { - DebugRepository.error(it.throwable) - } - withContext(Dispatchers.Main) { - when (it) { - is ComposeProgressState.Error -> { - inAppNotification.onError(Message.Compose, it.throwable) - } - - is ComposeProgressState.Progress -> { - inAppNotification.onProgress(Message.Compose, it.current, it.max) - } - - ComposeProgressState.Success -> { - inAppNotification.onSuccess(Message.Compose) - } - } - } - } + progress = ::notifyProgress, + ) } operator fun invoke( @@ -101,6 +83,37 @@ internal class ComposeUseCase( } } } + + fun sendDraft(groupId: String) { + scope.launch { + tryRun { + sendDraftUseCase(groupId = groupId, progress = ::notifyProgress) + } + } + } + + private suspend fun notifyProgress(state: ComposeProgressState) { + if (state is ComposeProgressState.Error) { + DebugRepository.error(state.throwable) + } + withContext(Dispatchers.Main) { + when (state) { + is ComposeProgressState.Error -> { + inAppNotification.onError(Message.Compose, state.throwable) + } + + is ComposeProgressState.Progress -> { + if (state.max > 0) { + inAppNotification.onProgress(Message.Compose, state.current, state.max) + } + } + + ComposeProgressState.Success -> { + inAppNotification.onSuccess(Message.Compose) + } + } + } + } } @Immutable 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 index f6eff02450..2ee2699725 100644 --- 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 @@ -4,8 +4,6 @@ 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.di.koinInject @@ -26,8 +24,7 @@ import kotlin.time.Instant public class DraftBoxPresenter : PresenterBase() { private val draftRepository: DraftRepository by koinInject() - private val sendDraftUseCase: SendDraftUseCase by koinInject() - private val inAppNotification: InAppNotification by koinInject() + private val composeUseCase: ComposeUseCase by koinInject() private val coroutineScope: CoroutineScope by koinInject() @Composable @@ -63,14 +60,16 @@ public class DraftBoxPresenter : PresenterBase() { .thenByDescending { it.updatedAt }, ).mapNotNull { draft -> val accounts = - draft.targets.mapNotNull { target -> - accountMap[target.accountKey]?.let { account -> - UiDraftAccount( - account = account, - avatar = avatarMap[target.accountKey], - ) + draft.targets + .filter { it.status != dev.dimension.flare.data.database.app.model.DraftTargetStatus.SENT } + .mapNotNull { target -> + accountMap[target.accountKey]?.let { account -> + UiDraftAccount( + account = account, + avatar = avatarMap[target.accountKey], + ) + } } - } if (accounts.isEmpty()) { return@mapNotNull null } @@ -117,29 +116,7 @@ public class DraftBoxPresenter : PresenterBase() { } private fun sendDraft(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) - } - } - } - } + composeUseCase.sendDraft(groupId) } } } 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 index 8e2e2fb125..ae6196d0ed 100644 --- 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 @@ -23,11 +23,13 @@ public class RestoreDraftUseCase internal constructor( 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) + draft.targets + .filter { it.status != dev.dimension.flare.data.database.app.model.DraftTargetStatus.SENT } + .mapNotNull { target -> + accountRepository.find(target.accountKey)?.let { + UiDraftAccount(account = it) + } } - } return UiDraft( groupId = draft.groupId, status = draft.toUiDraftStatus(), 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 a117e575af..d84026c420 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 @@ -3,6 +3,7 @@ package dev.dimension.flare.ui.presenter.compose import dev.dimension.flare.data.database.app.model.DraftTargetStatus import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeResult import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.ComposeDraftBundle import dev.dimension.flare.data.repository.DraftMediaStore @@ -18,7 +19,7 @@ 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, + private val composeDraft: suspend (UiAccount, ComposeData, suspend () -> Unit) -> ComposeResult, ) { constructor( draftRepository: DraftRepository, @@ -55,6 +56,7 @@ internal class SendDraftUseCase( status = DraftTargetStatus.SENDING, attemptCount = 1, lastAttemptAt = Clock.System.now().toEpochMilliseconds(), + progressMax = bundle.template.medias.size + 1, ) }, medias = persistedMedia, @@ -83,7 +85,7 @@ internal class SendDraftUseCase( val medias = draftMediaStore.restore(draft.medias) val datas = draft.targets - .filter { it.status != DraftTargetStatus.SENDING } + .filter { it.status == DraftTargetStatus.DRAFT || it.status == DraftTargetStatus.FAILED } .mapNotNull { target -> findAccount(target.accountKey)?.let { account -> ComposeTargetData( @@ -108,30 +110,32 @@ internal class SendDraftUseCase( progress(progressTracker.state()) val failures = mutableListOf() targets.forEach { target -> - draftRepository.updateTargetStatus( + draftRepository.prepareTargetForSending( groupId = groupId, accountKey = target.account.accountKey, - status = DraftTargetStatus.SENDING, + progressMax = target.data.medias.size + 1, 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()) - } + val result = + composeDraft(target.account, target.data) { + if (progressTracker.onComposeProgress(target.account.accountKey)) { + draftRepository.updateTargetProgress( + groupId = groupId, + accountKey = target.account.accountKey, + current = progressTracker.accountCurrent(target.account.accountKey), + ) + progress(progressTracker.state()) + } + } progressTracker.onComposeSuccess(target.account.accountKey) + draftRepository.markTargetSent( + groupId = groupId, + accountKey = target.account.accountKey, + remotePostKey = result.remotePostKey, + ) progress(progressTracker.state()) - draftRepository.deleteTarget(groupId, target.account.accountKey) } catch (throwable: Exception) { - repeat(pendingProgressTicks) { - progressTracker.onComposeProgress(target.account.accountKey) - progress(progressTracker.state()) - } draftRepository.updateTargetStatus( groupId = groupId, accountKey = target.account.accountKey, @@ -161,16 +165,19 @@ private class ComposeProgressTracker( private val maxSteps = targets.sumOf { it.data.medias.size + 1 } private var completedSteps = 0 - fun onComposeProgress(accountKey: MicroBlogKey) { + fun onComposeProgress(accountKey: MicroBlogKey): Boolean { val mediaLimit = mediaStepLimitsByAccount.getValue(accountKey) val currentMediaSteps = completedMediaStepsByAccount[accountKey] ?: 0 if (currentMediaSteps >= mediaLimit) { - return + return false } completedMediaStepsByAccount[accountKey] = currentMediaSteps + 1 completedSteps++ + return true } + fun accountCurrent(accountKey: MicroBlogKey): Int = completedMediaStepsByAccount[accountKey] ?: 0 + fun onComposeSuccess(accountKey: MicroBlogKey) { if (completedSendAccounts.add(accountKey)) { completedSteps++ 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 index b17a9efe36..8048043253 100644 --- 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 @@ -13,6 +13,7 @@ import dev.dimension.flare.data.database.app.model.DraftReferenceType import dev.dimension.flare.data.database.app.model.DraftTargetStatus import dev.dimension.flare.data.database.createDatabaseDriver import dev.dimension.flare.data.datasource.microblog.ComposeData +import dev.dimension.flare.data.datasource.microblog.ComposeResult import dev.dimension.flare.data.io.OkioFileStorage import dev.dimension.flare.data.repository.ComposeDraftBundle import dev.dimension.flare.data.repository.DraftMediaStore @@ -72,14 +73,22 @@ class SendDraftUseCaseTest : RobolectricTest() { } @Test - fun sendBundleSuccessDeletesDraftAfterAllTargetsSucceed() = + fun sendBundleSuccessKeepsSentTargetUntilTimelineRefresh() = runTest { val account = mastodonAccount("alice", "mastodon.social") val sent = mutableListOf() val progresses = mutableListOf() + var persistedProgressDuringSend: Int? = null val useCase = testUseCase(sent = sent) { _, _, progress -> progress() + persistedProgressDuringSend = + repository + .draft("send-success") + .first() + ?.targets + ?.single() + ?.progressCurrent } val bundle = ComposeDraftBundle( @@ -98,7 +107,12 @@ class SendDraftUseCaseTest : RobolectricTest() { assertEquals(1, sent.size) assertEquals(account.accountKey, sent.single().account.accountKey) assertEquals("hello", sent.single().data.content) - assertNull(repository.draft("send-success").first()) + val draft = assertNotNull(repository.draft("send-success").first()) + assertEquals(DraftTargetStatus.SENT, draft.targets.single().status) + assertEquals(MicroBlogKey("remote-alice", "mastodon.social"), draft.targets.single().remotePostKey) + assertEquals(2, draft.targets.single().progressCurrent) + assertEquals(2, draft.targets.single().progressMax) + assertEquals(1, persistedProgressDuringSend) assertEquals(ComposeProgressState.Progress(0, 2), progresses.first()) assertEquals(ComposeProgressState.Progress(1, 2), progresses[1]) assertEquals(ComposeProgressState.Progress(2, 2), progresses[2]) @@ -106,7 +120,7 @@ class SendDraftUseCaseTest : RobolectricTest() { } @Test - fun sendBundleAllTargetsSuccessDeletesDraft() = + fun sendBundleAllTargetsSuccessKeepsSentTargets() = runTest { val accountA = mastodonAccount("alice", "mastodon.social") val accountB = mastodonAccount("bob", "mastodon.social") @@ -126,7 +140,16 @@ class SendDraftUseCaseTest : RobolectricTest() { advanceUntilIdle() assertEquals(listOf(accountA.accountKey, accountB.accountKey), sent.map { it.account.accountKey }) - assertNull(repository.draft("send-all-success").first()) + val draft = assertNotNull(repository.draft("send-all-success").first()) + assertEquals(2, draft.targets.size) + assertTrue(draft.targets.all { it.status == DraftTargetStatus.SENT }) + assertEquals( + setOf( + MicroBlogKey("remote-alice", "mastodon.social"), + MicroBlogKey("remote-bob", "mastodon.social"), + ), + draft.targets.mapNotNull { it.remotePostKey }.toSet(), + ) } @Test @@ -157,10 +180,13 @@ class SendDraftUseCaseTest : RobolectricTest() { 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(2, draft.targets.size) + val successfulTarget = draft.targets.single { it.accountKey == accountA.accountKey } + val failedTarget = draft.targets.single { it.accountKey == accountB.accountKey } + assertEquals(DraftTargetStatus.SENT, successfulTarget.status) + assertEquals(MicroBlogKey("remote-alice", "mastodon.social"), successfulTarget.remotePostKey) + assertEquals(DraftTargetStatus.FAILED, failedTarget.status) + assertEquals("account-b failed", failedTarget.errorMessage) assertEquals(listOf(accountA.accountKey, accountB.accountKey), sent.map { it.account.accountKey }) val error = assertIs(progresses.last()) assertIs(error.throwable) @@ -250,7 +276,9 @@ class SendDraftUseCaseTest : RobolectricTest() { .data.medias .isEmpty(), ) - assertNull(repository.draft("send-no-media").first()) + val draft = assertNotNull(repository.draft("send-no-media").first()) + assertEquals(DraftTargetStatus.SENT, draft.targets.single().status) + assertEquals(1, draft.targets.single().progressCurrent) } @Test @@ -295,7 +323,9 @@ class SendDraftUseCaseTest : RobolectricTest() { listOf("a", "b"), sentMedias.map { it.altText }, ) - assertNull(repository.draft("send-multi-media").first()) + val draft = assertNotNull(repository.draft("send-multi-media").first()) + assertEquals(DraftTargetStatus.SENT, draft.targets.single().status) + assertEquals(3, draft.targets.single().progressMax) } @Test @@ -473,7 +503,10 @@ class SendDraftUseCaseTest : RobolectricTest() { else -> null } }, - composeDraft = { account, data, _ -> sent += SentCompose(account = account, data = data) }, + composeDraft = { account, data, _ -> + sent += SentCompose(account = account, data = data) + ComposeResult(MicroBlogKey("remote-${account.accountKey.id}", account.accountKey.host)) + }, ) useCase("resend-group") {} @@ -516,9 +549,15 @@ class SendDraftUseCaseTest : RobolectricTest() { ) 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) + assertEquals(2, remainingDraft.targets.size) + assertEquals( + DraftTargetStatus.SENT, + remainingDraft.targets.single { it.accountKey == failedAccount.accountKey }.status, + ) + assertEquals( + DraftTargetStatus.SENDING, + remainingDraft.targets.single { it.accountKey == sendingAccount.accountKey }.status, + ) } @Test @@ -698,7 +737,7 @@ class SendDraftUseCaseTest : RobolectricTest() { draftRepository = repository, draftMediaStore = blockedStore, findAccount = { null }, - composeDraft = { _, _, _ -> }, + composeDraft = { _, _, _ -> ComposeResult() }, ) assertFailsWith { @@ -752,7 +791,7 @@ class SendDraftUseCaseTest : RobolectricTest() { private fun testUseCase( sent: MutableList = mutableListOf(), findAccount: suspend (MicroBlogKey) -> UiAccount? = { null }, - composeDraft: suspend (UiAccount, ComposeData, () -> Unit) -> Unit = { _, _, _ -> }, + composeDraft: suspend (UiAccount, ComposeData, suspend () -> Unit) -> Unit = { _, _, _ -> }, ): SendDraftUseCase = SendDraftUseCase( draftRepository = repository, @@ -761,6 +800,7 @@ class SendDraftUseCaseTest : RobolectricTest() { composeDraft = { account, data, progress -> sent += SentCompose(account = account, data = data) composeDraft(account, data, progress) + ComposeResult(MicroBlogKey("remote-${account.accountKey.id}", account.accountKey.host)) }, ) diff --git a/shared/src/jvmTest/kotlin/dev/dimension/flare/data/database/app/AppDatabaseMigration11To12Test.kt b/shared/src/jvmTest/kotlin/dev/dimension/flare/data/database/app/AppDatabaseMigration11To12Test.kt index c48b9d4595..445f993de2 100644 --- a/shared/src/jvmTest/kotlin/dev/dimension/flare/data/database/app/AppDatabaseMigration11To12Test.kt +++ b/shared/src/jvmTest/kotlin/dev/dimension/flare/data/database/app/AppDatabaseMigration11To12Test.kt @@ -24,7 +24,7 @@ class AppDatabaseMigration11To12Test { val database = Room .databaseBuilder(name = path) - .addMigrations(AppDatabase.MIGRATION_11_12) + .addMigrations(AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.Unconfined) .build() @@ -45,12 +45,18 @@ class AppDatabaseMigration11To12Test { BundledSQLiteDriver().open(path).use { connection -> connection.prepare("PRAGMA user_version").use { statement -> assertEquals(true, statement.step()) - assertEquals(12L, statement.getLong(0)) - } - connection.prepare("SELECT identity_hash FROM room_master_table WHERE id = 42").use { statement -> - assertEquals(true, statement.step()) - assertEquals(APP_DATABASE_IDENTITY_HASH, statement.getText(0)) + assertEquals(13L, statement.getLong(0)) } + connection + .prepare( + "SELECT progress_current, progress_max, remote_post_key IS NULL " + + "FROM DbDraftTarget WHERE target_id = 'fixture-target'", + ).use { statement -> + assertEquals(true, statement.step()) + assertEquals(0L, statement.getLong(0)) + assertEquals(1L, statement.getLong(1)) + assertEquals(1L, statement.getLong(2)) + } } } finally { Files.deleteIfExists(databasePath) @@ -66,6 +72,17 @@ class AppDatabaseMigration11To12Test { connection.execSQL("PRAGMA user_version = 11") connection.execSQL("CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY, identity_hash TEXT)") connection.execSQL("INSERT OR REPLACE INTO room_master_table (id, identity_hash) VALUES(42, '$APP_DATABASE_IDENTITY_HASH')") + connection.execSQL( + "INSERT INTO DbDraftGroup(group_id, content, created_at, updated_at) " + + "VALUES ('fixture-group', '{}', 1000, 1000)", + ) + connection.execSQL( + "INSERT INTO DbDraftTarget(" + + "group_id, account_key, status, error_message, attempt_count, last_attempt_at, " + + "created_at, updated_at, target_id" + + ") VALUES ('fixture-group', 'user-0@fixture.example', 'SENDING', NULL, 1, 1000, " + + "1000, 1000, 'fixture-target')", + ) connection .prepare( "INSERT INTO DbAccount(account_key, credential_json, platform_type, last_active, sort_id) VALUES (?, ?, ?, ?, ?)", diff --git a/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt b/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt index 81406d0f67..3cb25b0001 100644 --- a/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt +++ b/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/data/datasource/bluesky/BlueskyDataSource.kt @@ -31,6 +31,7 @@ import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataS import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeResult import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.DirectMessageDataSource @@ -289,8 +290,8 @@ internal class BlueskyDataSource( override suspend fun compose( data: ComposeData, - progress: () -> Unit, - ) { + progress: suspend () -> Unit, + ): ComposeResult { require(data.medias.size <= BLUESKY_GALLERY_AUTHOR_LIMIT) { "Bluesky supports at most $BLUESKY_GALLERY_AUTHOR_LIMIT images when authoring a post" } @@ -434,14 +435,18 @@ internal class BlueskyDataSource( Language(it) }, ) - service - .createRecord( - CreateRecordRequest( - repo = Did(did = accountKey.id), - collection = Nsid("app.bsky.feed.post"), - record = post.bskyJson(), - ), - ).requireResponse() + val response = + service + .createRecord( + CreateRecordRequest( + repo = Did(did = accountKey.id), + collection = Nsid("app.bsky.feed.post"), + record = post.bskyJson(), + ), + ).requireResponse() + return ComposeResult( + remotePostKey = MicroBlogKey(response.uri.atUri, accountKey.host), + ) } private suspend fun createExternalEmbed( diff --git a/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt b/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt index e67dcfef32..914e4abbf7 100644 --- a/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt +++ b/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/data/datasource/mastodon/MastodonDataSource.kt @@ -7,6 +7,7 @@ import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataS import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeResult import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter @@ -279,8 +280,8 @@ internal open class MastodonDataSource( override suspend fun compose( data: ComposeData, - progress: () -> Unit, - ) { + progress: suspend () -> Unit, + ): ComposeResult { val inReplyToID = data.referenceStatus ?.composeStatus @@ -323,46 +324,49 @@ internal open class MastodonDataSource( }.mapNotNull { it.id } - service.post( - Uuid.random().toString(), - PostStatus( - status = data.content, - visibility = - when (data.visibility) { - UiTimelineV2.Post.Visibility.Public -> Visibility.Public - UiTimelineV2.Post.Visibility.Home -> Visibility.Unlisted - UiTimelineV2.Post.Visibility.Followers -> Visibility.Private - UiTimelineV2.Post.Visibility.Specified -> Visibility.Direct - UiTimelineV2.Post.Visibility.Channel -> Visibility.Public - }, - inReplyToID = inReplyToID, - mediaIDS = mediaIds.takeIf { it.isNotEmpty() }, - sensitive = data.sensitive.takeIf { mediaIds.isNotEmpty() }, - spoilerText = data.spoilerText.takeIf { it?.isNotEmpty() == true && it.isNotBlank() }, - poll = - data.poll?.let { poll -> - PostPoll( - options = poll.options, - expiresIn = poll.expiredAfter, - multiple = poll.multiple, - ) - }, - quoteID = - if (this is PleromaDataSource) { - quoteID - } else { - null - }, - quotedStatusID = - if (this !is PleromaDataSource) { - quoteID - } else { - null - }, - language = data.language.firstOrNull(), - ), + val status = + service.post( + Uuid.random().toString(), + PostStatus( + status = data.content, + visibility = + when (data.visibility) { + UiTimelineV2.Post.Visibility.Public -> Visibility.Public + UiTimelineV2.Post.Visibility.Home -> Visibility.Unlisted + UiTimelineV2.Post.Visibility.Followers -> Visibility.Private + UiTimelineV2.Post.Visibility.Specified -> Visibility.Direct + UiTimelineV2.Post.Visibility.Channel -> Visibility.Public + }, + inReplyToID = inReplyToID, + mediaIDS = mediaIds.takeIf { it.isNotEmpty() }, + sensitive = data.sensitive.takeIf { mediaIds.isNotEmpty() }, + spoilerText = data.spoilerText.takeIf { it?.isNotEmpty() == true && it.isNotBlank() }, + poll = + data.poll?.let { poll -> + PostPoll( + options = poll.options, + expiresIn = poll.expiredAfter, + multiple = poll.multiple, + ) + }, + quoteID = + if (this is PleromaDataSource) { + quoteID + } else { + null + }, + quotedStatusID = + if (this !is PleromaDataSource) { + quoteID + } else { + null + }, + language = data.language.firstOrNull(), + ), + ) + return ComposeResult( + remotePostKey = status.id?.let { MicroBlogKey(it, accountKey.host) }, ) -// progress(ComposeProgress(maxProgress, maxProgress)) } suspend fun like( diff --git a/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt b/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt index a1d0ec371b..383553b96f 100644 --- a/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt +++ b/social/misskey/src/commonMain/kotlin/dev/dimension/flare/data/datasource/misskey/MisskeyDataSource.kt @@ -10,6 +10,7 @@ import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataS import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeResult import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter @@ -437,8 +438,8 @@ internal class MisskeyDataSource( override suspend fun compose( data: ComposeData, - progress: () -> Unit, - ) { + progress: suspend () -> Unit, + ): ComposeResult { val renoteId = data.referenceStatus ?.composeStatus @@ -481,33 +482,36 @@ internal class MisskeyDataSource( }.mapNotNull { it?.id } - service.notesCreate( - NotesCreateRequest( - text = data.content.takeIf { it.isNotEmpty() && it.isNotBlank() }, - visibility = - when (data.visibility) { - UiTimelineV2.Post.Visibility.Public -> "public" - UiTimelineV2.Post.Visibility.Home -> "home" - UiTimelineV2.Post.Visibility.Followers -> "followers" - UiTimelineV2.Post.Visibility.Specified -> "specified" - UiTimelineV2.Post.Visibility.Channel -> "public" - }, - renoteId = renoteId, - replyId = inReplyToID, - fileIds = mediaIds.takeIf { it.isNotEmpty() }, - cw = data.spoilerText.takeIf { it?.isNotEmpty() == true && it.isNotBlank() }, - poll = - data.poll?.let { poll -> - NotesCreateRequestPoll( - choices = poll.options.toSet(), - expiredAfter = poll.expiredAfter.toInt(), - multiple = poll.multiple, - ) - }, - localOnly = data.localOnly, - ), + val response = + service.notesCreate( + NotesCreateRequest( + text = data.content.takeIf { it.isNotEmpty() && it.isNotBlank() }, + visibility = + when (data.visibility) { + UiTimelineV2.Post.Visibility.Public -> "public" + UiTimelineV2.Post.Visibility.Home -> "home" + UiTimelineV2.Post.Visibility.Followers -> "followers" + UiTimelineV2.Post.Visibility.Specified -> "specified" + UiTimelineV2.Post.Visibility.Channel -> "public" + }, + renoteId = renoteId, + replyId = inReplyToID, + fileIds = mediaIds.takeIf { it.isNotEmpty() }, + cw = data.spoilerText.takeIf { it?.isNotEmpty() == true && it.isNotBlank() }, + poll = + data.poll?.let { poll -> + NotesCreateRequestPoll( + choices = poll.options.toSet(), + expiredAfter = poll.expiredAfter.toInt(), + multiple = poll.multiple, + ) + }, + localOnly = data.localOnly, + ), + ) + return ComposeResult( + remotePostKey = MicroBlogKey(response.createdNote.id, accountKey.host), ) -// progress(ComposeProgress(maxProgress, maxProgress)) } suspend fun report( diff --git a/social/nostr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt b/social/nostr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt index 095ee921d9..5f3063a35e 100644 --- a/social/nostr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt +++ b/social/nostr/src/commonMain/kotlin/dev/dimension/flare/data/datasource/nostr/NostrDataSource.kt @@ -7,6 +7,7 @@ import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataS import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeResult import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter @@ -472,8 +473,8 @@ internal class NostrDataSource( override suspend fun compose( data: ComposeData, - progress: () -> Unit, - ) { + progress: suspend () -> Unit, + ): ComposeResult { val credential = credentialFlow.first() val medias = data.medias.map { media -> @@ -494,39 +495,43 @@ internal class NostrDataSource( progress() } } - when (val composeStatus = data.referenceStatus?.composeStatus) { - is ComposeStatus.Quote -> { - serviceManager.withService { - it.composeQuote( - statusKey = composeStatus.statusKey, - content = data.content, - media = medias, - contentWarning = data.spoilerText, - ) + val eventId = + when (val composeStatus = data.referenceStatus?.composeStatus) { + is ComposeStatus.Quote -> { + serviceManager.withService { + it.composeQuote( + statusKey = composeStatus.statusKey, + content = data.content, + media = medias, + contentWarning = data.spoilerText, + ) + } } - } - is ComposeStatus.Reply -> { - serviceManager.withService { - it.composeReply( - statusKey = composeStatus.statusKey, - content = data.content, - media = medias, - contentWarning = data.spoilerText, - ) + is ComposeStatus.Reply -> { + serviceManager.withService { + it.composeReply( + statusKey = composeStatus.statusKey, + content = data.content, + media = medias, + contentWarning = data.spoilerText, + ) + } } - } - null -> { - serviceManager.withService { - it.composeNote( - content = data.content, - media = medias, - contentWarning = data.spoilerText, - ) + null -> { + serviceManager.withService { + it.composeNote( + content = data.content, + media = medias, + contentWarning = data.spoilerText, + ) + } } } - } + return ComposeResult( + remotePostKey = MicroBlogKey(eventId, NostrService.NOSTR_HOST), + ) } override fun composeConfig(type: ComposeType): ComposeConfig = diff --git a/social/vvo/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt b/social/vvo/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt index 53861f4c72..4e80975701 100644 --- a/social/vvo/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt +++ b/social/vvo/src/commonMain/kotlin/dev/dimension/flare/data/datasource/vvo/VVODataSource.kt @@ -10,6 +10,7 @@ import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataS import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeResult import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.NotificationFilter @@ -384,8 +385,8 @@ internal class VVODataSource( override suspend fun compose( data: ComposeData, - progress: () -> Unit, - ) { + progress: suspend () -> Unit, + ): ComposeResult { val st = ensureLogin() val mediaIds = @@ -397,37 +398,49 @@ internal class VVODataSource( val mediaId = mediaIds.joinToString(",") val referenceStatus = data.referenceStatus val composeStatus = referenceStatus?.composeStatus - if (composeStatus is ComposeStatus.VVOComment) { - service.replyComment( - cid = composeStatus.statusKey.id, - reply = composeStatus.statusKey.id, - id = composeStatus.rootId, - mid = composeStatus.rootId, - content = data.content, - st = st, - picId = mediaId, - ) - } else if (composeStatus is ComposeStatus.Reply) { - service.commentStatus( - id = composeStatus.statusKey.id, - content = data.content, - st = st, - picId = mediaId, - ) - } else if (composeStatus is ComposeStatus.Quote) { - service.repostStatus( - id = composeStatus.statusKey.id, - content = data.content, - st = st, - picId = mediaId, - ) - } else { - service.updateStatus( - content = data.content, - st = st, - picId = mediaId, - ) - } + val remoteId = + if (composeStatus is ComposeStatus.VVOComment) { + service + .replyComment( + cid = composeStatus.statusKey.id, + reply = composeStatus.statusKey.id, + id = composeStatus.rootId, + mid = composeStatus.rootId, + content = data.content, + st = st, + picId = mediaId, + ).data + ?.id + } else if (composeStatus is ComposeStatus.Reply) { + service + .commentStatus( + id = composeStatus.statusKey.id, + content = data.content, + st = st, + picId = mediaId, + ).data + ?.id + } else if (composeStatus is ComposeStatus.Quote) { + service + .repostStatus( + id = composeStatus.statusKey.id, + content = data.content, + st = st, + picId = mediaId, + ).data + ?.id + } else { + service + .updateStatus( + content = data.content, + st = st, + picId = mediaId, + ).data + ?.id + } + return ComposeResult( + remotePostKey = remoteId?.let { MicroBlogKey(it, accountKey.host) }, + ) } override fun composeConfig(type: ComposeType): ComposeConfig = diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt index ab3ef4060f..d3eb23f782 100644 --- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt @@ -9,6 +9,7 @@ import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataS import dev.dimension.flare.data.datasource.microblog.ComposeConfig import dev.dimension.flare.data.datasource.microblog.ComposeData import dev.dimension.flare.data.datasource.microblog.ComposeDataSource +import dev.dimension.flare.data.datasource.microblog.ComposeResult import dev.dimension.flare.data.datasource.microblog.ComposeType import dev.dimension.flare.data.datasource.microblog.DatabaseUpdater import dev.dimension.flare.data.datasource.microblog.DirectMessageDataSource @@ -532,8 +533,8 @@ internal class XQTDataSource( override suspend fun compose( data: ComposeData, - progress: () -> Unit, - ) { + progress: suspend () -> Unit, + ): ComposeResult { val inReplyToID = data.referenceStatus ?.composeStatus @@ -599,37 +600,49 @@ internal class XQTDataSource( progress() } } - service.postCreateTweet( - postCreateTweetRequest = - PostCreateTweetRequest( - features = PostCreateTweetRequestFeatures(), - variables = - PostCreateTweetRequestVariables( - media = - PostCreateTweetRequestVariablesMedia( - mediaEntities = - mediaIds.map { - PostCreateTweetRequestVariablesMediaMediaEntitiesInner( - mediaId = it, - taggedUsers = emptyList(), - ) - }, - ), - tweetText = data.content, - reply = - inReplyToID?.let { - PostCreateTweetRequestVariablesReply( - inReplyToTweetId = it, - excludeReplyUserIds = emptyList(), - ) - }, - semanticAnnotationIds = emptyList(), - attachmentUrl = - quoteId?.let { - "https://${accountKey.host}/$quoteUserName/status/${it.id}" - }, - ), - ), + val response = + service.postCreateTweet( + postCreateTweetRequest = + PostCreateTweetRequest( + features = PostCreateTweetRequestFeatures(), + variables = + PostCreateTweetRequestVariables( + media = + PostCreateTweetRequestVariablesMedia( + mediaEntities = + mediaIds.map { + PostCreateTweetRequestVariablesMediaMediaEntitiesInner( + mediaId = it, + taggedUsers = emptyList(), + ) + }, + ), + tweetText = data.content, + reply = + inReplyToID?.let { + PostCreateTweetRequestVariablesReply( + inReplyToTweetId = it, + excludeReplyUserIds = emptyList(), + ) + }, + semanticAnnotationIds = emptyList(), + attachmentUrl = + quoteId?.let { + "https://${accountKey.host}/$quoteUserName/status/${it.id}" + }, + ), + ), + ) + return ComposeResult( + remotePostKey = + response + .body() + ?.data + ?.createTweet + ?.tweetResults + ?.result + ?.restId + ?.let { MicroBlogKey(it, accountKey.host) }, ) }