From 0d974c2e86e1d77483534261caac1a678995bb4d Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 26 Feb 2026 00:00:29 +0900 Subject: [PATCH 01/33] [WIP] add UiTimelineV2 --- .../HomeTimelineWithTabsPresenter.kt | 28 +- .../data/datasource/microblog/ActionMenu.kt | 87 +++--- .../AuthenticatedMicroblogDataSource.kt | 2 + .../data/datasource/microblog/StatusEvent.kt | 168 ++++++++++++ .../dimension/flare/ui/model/ClickContext.kt | 8 +- .../dimension/flare/ui/model/DeeplinkEvent.kt | 29 ++ .../dev/dimension/flare/ui/model/UiCard.kt | 2 + .../dev/dimension/flare/ui/model/UiIcon.kt | 39 +++ .../dev/dimension/flare/ui/model/UiMedia.kt | 6 + .../dev/dimension/flare/ui/model/UiPoll.kt | 6 +- .../dimension/flare/ui/model/UiTimelineV2.kt | 253 ++++++++++++++++++ .../ui/presenter/home/DeepLinkPresenter.kt | 19 +- .../dimension/flare/ui/render/UiDateTime.kt | 23 ++ .../dimension/flare/ui/route/DeeplinkRoute.kt | 2 + 14 files changed, 617 insertions(+), 55 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiIcon.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt index d96387d01..2c319a5f7 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/HomeTimelineWithTabsPresenter.kt @@ -108,12 +108,28 @@ public class HomeTimelineWithTabsPresenter( settingsRepository.updateTabSettings { copy( mainTabs = - mainTabs.filterNot { - it.account == - AccountType.Specific( - accountKey, - ) - }, + mainTabs + .filterNot { + it.account == + AccountType.Specific( + accountKey, + ) + }.map { + if (it is MixedTimelineTabItem) { + it.copy( + subTimelineTabItem = + it.subTimelineTabItem + .filterNot { + it.account == + AccountType.Specific( + accountKey, + ) + }.toImmutableList(), + ) + } else { + it + } + }, ) } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt index 3aade57fd..0c6b232f4 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/ActionMenu.kt @@ -3,86 +3,81 @@ package dev.dimension.flare.data.datasource.microblog import androidx.compose.runtime.Immutable import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.ClickContext +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiIcon import dev.dimension.flare.ui.model.UiNumber -import dev.dimension.flare.ui.model.launch +import dev.dimension.flare.ui.model.onClicked import dev.dimension.flare.ui.route.DeeplinkRoute import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +@Serializable @Immutable -public sealed interface ActionMenu { +public sealed class ActionMenu { @Immutable + @Serializable public data class Group internal constructor( val displayItem: Item, val actions: ImmutableList, - ) : ActionMenu + ) : ActionMenu() - @Immutable - public data class AsyncActionMenuItem internal constructor( - val flow: Flow, - ) : ActionMenu +// @Immutable +// @Serializable +// public data class AsyncActionMenuItem internal constructor( +// @Transient +// val flow: Flow = emptyFlow(), +// ) : ActionMenu() @Immutable - public data object Divider : ActionMenu + @Serializable + public data object Divider : ActionMenu() @Immutable + @Serializable public data class Item internal constructor( - val icon: Icon? = null, + val icon: UiIcon? = null, val text: Text? = null, val count: UiNumber? = null, - val onClicked: (ClickContext.() -> Unit)? = null, val color: Color? = null, - ) : ActionMenu { + private val clickEvent: ClickEvent? = null, + @Transient + private val clickAction: (ClickContext.() -> Unit)? = null, + ) : ActionMenu() { init { require(icon != null || text != null) { "icon and text cannot be both null" } } + val onClicked: (ClickContext.() -> Unit)? by lazy { + clickAction ?: clickEvent?.onClicked + } + + @Serializable public enum class Color { Red, ContentColor, PrimaryColor, } - public enum class Icon { - Like, - Unlike, - Retweet, - Unretweet, - Reply, - Comment, - Quote, - Bookmark, - Unbookmark, - More, - MoreVerticel, - Delete, - Report, - React, - UnReact, - Share, - List, - ChatMessage, - Mute, - UnMute, - Block, - UnBlock, - } - @Immutable + @Serializable public sealed interface Text { @Immutable + @Serializable public data class Raw( val text: String, ) : Text @Immutable + @Serializable public data class Localized( val type: Type, val parameters: ImmutableList = persistentListOf(), ) : Text { + @Serializable public enum class Type { Like, Unlike, @@ -121,25 +116,27 @@ internal fun userActionsMenu( ): List = listOfNotNull( ActionMenu.Item( - icon = ActionMenu.Item.Icon.Mute, + icon = UiIcon.Mute, text = ActionMenu.Item.Text.Localized( type = ActionMenu.Item.Text.Localized.Type.MuteWithHandleParameter, parameters = persistentListOf(handle), ), - onClicked = { - launcher.launch(DeeplinkRoute.MuteUser(accountKey, userKey)) - }, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.MuteUser(accountKey, userKey), + ), ), ActionMenu.Item( - icon = ActionMenu.Item.Icon.Block, + icon = UiIcon.Block, text = ActionMenu.Item.Text.Localized( type = ActionMenu.Item.Text.Localized.Type.BlockWithHandleParameter, parameters = persistentListOf(handle), ), - onClicked = { - launcher.launch(DeeplinkRoute.BlockUser(accountKey, userKey)) - }, + clickEvent = + ClickEvent.Deeplink( + DeeplinkRoute.BlockUser(accountKey, userKey), + ), ), ) 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 56f5b4326..ad266e993 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 @@ -41,6 +41,8 @@ internal interface AuthenticatedMicroblogDataSource : ) fun notificationBadgeCount(): CacheData = Cacheable({ }, { flowOf(0) }) + + fun handleEvent(event: PostEvent) } internal interface RelationDataSource { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/StatusEvent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/StatusEvent.kt index abc197f6c..6f0836e1e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/StatusEvent.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/StatusEvent.kt @@ -1,7 +1,9 @@ package dev.dimension.flare.data.datasource.microblog import dev.dimension.flare.model.MicroBlogKey +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable internal sealed interface StatusEvent { val accountKey: MicroBlogKey @@ -140,3 +142,169 @@ internal sealed interface StatusEvent { ) } } + +@Serializable +internal sealed interface PostEvent { + @Serializable + sealed interface PollEvent : PostEvent { + val options: ImmutableList + } + + @Serializable + sealed interface Mastodon : PostEvent { + @Serializable + data class Reblog( + val statusKey: MicroBlogKey, + val reblogged: Boolean, + ) : Mastodon + + @Serializable + data class Like( + val statusKey: MicroBlogKey, + val liked: Boolean, + ) : Mastodon + + @Serializable + data class Bookmark( + val statusKey: MicroBlogKey, + val bookmarked: Boolean, + ) : Mastodon + + @Serializable + data class Vote( + val statusKey: MicroBlogKey, + override val options: ImmutableList, + ) : Mastodon, + PollEvent + + @Serializable + data class AcceptFollowRequest( + val userKey: MicroBlogKey, + val notificationStatusKey: MicroBlogKey, + ) : Mastodon + + @Serializable + data class RejectFollowRequest( + val userKey: MicroBlogKey, + val notificationStatusKey: MicroBlogKey, + ) : Mastodon + } + + @Serializable + sealed interface Pleroma : PostEvent { + @Serializable + data class React( + val statusKey: MicroBlogKey, + val hasReacted: Boolean, + val reaction: String, + ) : Pleroma + } + + @Serializable + sealed interface Misskey : PostEvent { + @Serializable + data class React( + val statusKey: MicroBlogKey, + val hasReacted: Boolean, + val reaction: String, + ) : Misskey + + @Serializable + data class Renote( + val statusKey: MicroBlogKey, + ) : Misskey + + @Serializable + data class Vote( + val statusKey: MicroBlogKey, + override val options: ImmutableList, + ) : Misskey, + PollEvent + + @Serializable + data class Favourite( + val statusKey: MicroBlogKey, + val favourited: Boolean, + ) : Misskey + + @Serializable + data class AcceptFollowRequest( + val userKey: MicroBlogKey, + val notificationStatusKey: MicroBlogKey, + ) : Misskey + + @Serializable + data class RejectFollowRequest( + val userKey: MicroBlogKey, + val notificationStatusKey: MicroBlogKey, + ) : Misskey + } + + @Serializable + sealed interface Bluesky : PostEvent { + @Serializable + data class Reblog( + val statusKey: MicroBlogKey, + val reblogged: Boolean, + ) : Bluesky + + @Serializable + data class Like( + val statusKey: MicroBlogKey, + val liked: Boolean, + ) : Bluesky + + @Serializable + data class Bookmark( + val statusKey: MicroBlogKey, + val bookmarked: Boolean, + ) : Bluesky + + @Serializable + data class Unbookmark( + val statusKey: MicroBlogKey, + ) : Bluesky + } + + @Serializable + sealed interface XQT : PostEvent { + @Serializable + data class Retweet( + val statusKey: MicroBlogKey, + val retweeted: Boolean, + ) : XQT + + @Serializable + data class Like( + val statusKey: MicroBlogKey, + val liked: Boolean, + ) : XQT + + @Serializable + data class Bookmark( + val statusKey: MicroBlogKey, + val bookmarked: Boolean, + ) : XQT + } + + @Serializable + sealed interface VVO : PostEvent { + @Serializable + data class Like( + val statusKey: MicroBlogKey, + val liked: Boolean, + ) : VVO + + @Serializable + data class LikeComment( + val statusKey: MicroBlogKey, + val liked: Boolean, + ) : VVO + + @Serializable + data class Favorite( + val statusKey: MicroBlogKey, + val favorited: Boolean, + ) : VVO + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/ClickContext.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/ClickContext.kt index 807475aa9..29e274431 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/ClickContext.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/ClickContext.kt @@ -1,5 +1,7 @@ package dev.dimension.flare.ui.model +import dev.dimension.flare.ui.route.DeeplinkRoute +import dev.dimension.flare.ui.route.toUri import kotlinx.serialization.Serializable public data class ClickContext( @@ -12,9 +14,11 @@ internal sealed interface ClickEvent { data object Noop : ClickEvent @Serializable - data class Deeplink( + data class Deeplink private constructor( val url: String, - ) : ClickEvent + ) : ClickEvent { + constructor(route: DeeplinkRoute) : this(route.toUri()) + } } internal val ClickEvent.onClicked: ClickContext.() -> Unit diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt new file mode 100644 index 000000000..e187ca441 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/DeeplinkEvent.kt @@ -0,0 +1,29 @@ +package dev.dimension.flare.ui.model + +import dev.dimension.flare.data.datasource.microblog.PostEvent +import dev.dimension.flare.model.MicroBlogKey +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromHexString +import kotlinx.serialization.encodeToHexString +import kotlinx.serialization.protobuf.ProtoBuf + +@Serializable +@OptIn(ExperimentalSerializationApi::class) +internal data class DeeplinkEvent( + val accountKey: MicroBlogKey, + val postEvent: PostEvent, +) { + companion object { + const val SCHEME = "flare-event" + + fun parse(uri: String): DeeplinkEvent? = + runCatching { + ProtoBuf.decodeFromHexString(uri.removePrefix("$SCHEME://")) + }.getOrNull() + + fun isDeeplinkEvent(uri: String): Boolean = uri.startsWith("$SCHEME://") + } + + fun toUri(): String = "$SCHEME://${ProtoBuf.encodeToHexString(this)}" +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiCard.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiCard.kt index f40ea0a74..87722e36b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiCard.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiCard.kt @@ -1,7 +1,9 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable +@Serializable @Immutable public data class UiCard internal constructor( val title: String, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiIcon.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiIcon.kt new file mode 100644 index 000000000..64608f6be --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiIcon.kt @@ -0,0 +1,39 @@ +package dev.dimension.flare.ui.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +public enum class UiIcon { + Like, + Unlike, + Retweet, + Unretweet, + Reply, + Comment, + Quote, + Bookmark, + Unbookmark, + More, + MoreVerticel, + Delete, + Report, + React, + UnReact, + Share, + List, + ChatMessage, + Mute, + UnMute, + Block, + UnBlock, + + Follow, + Favourite, + Mention, + Poll, + Edit, + Info, + Pin, +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiMedia.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiMedia.kt index 98bb38cbd..e5cdf5fb3 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiMedia.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiMedia.kt @@ -2,13 +2,16 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableMap +import kotlinx.serialization.Serializable +@Serializable @Immutable public sealed interface UiMedia { public val url: String public val description: String? public val customHeaders: ImmutableMap? + @Serializable @Immutable public data class Image internal constructor( override val url: String, @@ -23,6 +26,7 @@ public sealed interface UiMedia { get() = (width / (height.takeUnless { it == 0f } ?: 1f)).takeUnless { it == 0f } ?: 1f } + @Serializable @Immutable public data class Video internal constructor( override val url: String, @@ -36,6 +40,7 @@ public sealed interface UiMedia { get() = (width / (height.takeUnless { it == 0f } ?: 1f)).takeUnless { it == 0f } ?: 1f } + @Serializable @Immutable public data class Gif internal constructor( override val url: String, @@ -49,6 +54,7 @@ public sealed interface UiMedia { get() = (width / (height.takeUnless { it == 0f } ?: 1f)).takeUnless { it == 0f } ?: 1f } + @Serializable @Immutable public data class Audio internal constructor( override val url: String, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPoll.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPoll.kt index 04d2111ba..f7dfb504b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPoll.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiPoll.kt @@ -1,20 +1,23 @@ package dev.dimension.flare.ui.model import androidx.compose.runtime.Immutable +import dev.dimension.flare.data.datasource.microblog.PostEvent import dev.dimension.flare.ui.humanizer.humanizePercentage import dev.dimension.flare.ui.render.UiDateTime import dev.dimension.flare.ui.render.toUi import kotlinx.collections.immutable.ImmutableList +import kotlinx.serialization.Serializable import kotlin.time.Clock import kotlin.time.Instant +@Serializable @Immutable public data class UiPoll internal constructor( val id: String, val options: ImmutableList