diff --git a/.gitignore b/.gitignore index 6d7023e..b31ba96 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ Test Results* Morpho/.kotlin .kotlin /.kotlin/ +Butterfly +**.log \ No newline at end of file diff --git a/Butterfly b/Butterfly new file mode 160000 index 0000000..3d39e7c --- /dev/null +++ b/Butterfly @@ -0,0 +1 @@ +Subproject commit 3d39e7ca8973b8eb26a0af787a4e67214adacdce diff --git a/Morpho/.gitignore b/Morpho/.gitignore index 6af20c0..10f8b01 100644 --- a/Morpho/.gitignore +++ b/Morpho/.gitignore @@ -19,3 +19,4 @@ captures !*.xcodeproj/project.xcworkspace/ !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings +**.log diff --git a/Morpho/build.gradle.kts b/Morpho/build.gradle.kts index c6d45d4..3922adb 100644 --- a/Morpho/build.gradle.kts +++ b/Morpho/build.gradle.kts @@ -14,6 +14,7 @@ plugins { alias(libs.plugins.kotlinParcelize).apply(false) alias(libs.plugins.androidApplication).apply(false) alias(libs.plugins.androidLibrary).apply(false) + id("com.codingfeline.buildkonfig") version "0.15.2" apply false } diff --git a/Morpho/composeApp/build.gradle.kts b/Morpho/composeApp/build.gradle.kts index 334d8c4..1c3966c 100644 --- a/Morpho/composeApp/build.gradle.kts +++ b/Morpho/composeApp/build.gradle.kts @@ -1,4 +1,6 @@ +import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi plugins { alias(libs.plugins.kotlinMultiplatform) @@ -9,13 +11,69 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.androidApplication) + id("com.codingfeline.buildkonfig") id("kotlin-parcelize") + //id("kotlin-kapt") //id("com.rickclephas.kmp.nativecoroutines") version "1.0.0-ALPHA-27" } +val versionString = "1.0.0-alpha_1" +val packageString = "com.morpho.app" + +buildkonfig { + packageName = packageString + // objectName = "YourAwesomeConfig" + // exposeObjectWithName = "YourAwesomePublicConfig" + + defaultConfigs { + buildConfigField(STRING, "versionString", versionString) + buildConfigField(STRING, "packageName", packageString) + buildConfigField(STRING, "appName", "Morpho") + buildConfigField(STRING, "versionNumber", "0.1.0") + } + defaultConfigs("dev") { + buildConfigField(STRING, "versionString", "${versionString}-dev") + buildConfigField(STRING, "packageName", packageString) + buildConfigField(STRING, "appName", "Morpho") + buildConfigField(STRING, "versionNumber", "0.1.0") + } + + targetConfigs { + create("android") { + buildConfigField(STRING, "versionString", "android-${versionString}") + } + create("desktop") { + buildConfigField(STRING, "versionString", "desktop-${versionString}") + } + create("ios") { + buildConfigField(STRING, "versionString", "ios-${versionString}") + } + + } + targetConfigs("dev") { + create("android") { + buildConfigField(STRING, "versionString", "android-${versionString}-dev") + } + create("desktop") { + buildConfigField(STRING, "versionString", "desktop-${versionString}-dev") + } + create("ios") { + buildConfigField(STRING, "versionString", "ios-${versionString}-dev") + } + + } +} + kotlin { androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.addAll( + "-P", + "plugin:org.jetbrains.kotlin.parcelize:additionalAnnotation=com.morpho.app.CommonParcelize", + ) + } compilations.all { kotlinOptions { jvmTarget = "11" @@ -74,11 +132,27 @@ kotlin { implementation(libs.ktor.client.android) implementation(libs.kotlin.jwt) + + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + + + //implementation(libs.logkmpanion) } commonMain.dependencies { implementation("com.morpho:shared") + implementation("com.russhwolf:multiplatform-settings:1.2.0") + implementation("com.russhwolf:multiplatform-settings-serialization:1.2.0") + implementation("com.russhwolf:multiplatform-settings-coroutines:1.2.0") + implementation("com.russhwolf:multiplatform-settings-datastore:1.2.0") + implementation("com.russhwolf:multiplatform-settings-no-arg:1.2.0") + implementation("androidx.datastore:datastore-preferences-core:1.1.1") + implementation("androidx.datastore:datastore-core:1.1.1") + + implementation(libs.paging.common) + implementation(libs.paging.compose.common) implementation(compose.runtime) implementation(compose.foundation) @@ -116,9 +190,6 @@ kotlin { implementation(libs.kotlinx.serialization.cbor) implementation(libs.kotlinx.serialization.json) - - - implementation(kotlin("reflect")) api(libs.logging) @@ -133,6 +204,7 @@ kotlin { implementation(libs.koin.core.coroutines) implementation(libs.koin.annotations) implementation(libs.koin.compose) + implementation("io.insert-koin:koin-logger-slf4j:3.5.3") // Enables FileKit without Compose dependencies @@ -147,6 +219,7 @@ kotlin { implementation(libs.ktor.contentnegotiation) implementation(libs.ktor.serialization.json) implementation(libs.ktor.websockets) + implementation(libs.ktor.client.encoding) implementation(libs.ktor.client.resources) implementation(libs.ktor.client.auth) @@ -155,6 +228,7 @@ kotlin { implementation(libs.voyager.navigator) // Screen Model implementation(libs.voyager.screenmodel) + implementation(libs.voyager.lifecycle.kmp) // BottomSheetNavigator implementation(libs.voyager.bottom.sheet.navigator) // TabNavigator @@ -169,9 +243,12 @@ kotlin { implementation(libs.slf4j.api) //implementation(libs.slf4j.simple) + implementation(libs.toolargetool) + api(libs.parcelize) + } nativeMain.dependencies { - + implementation(libs.paging.runtime.uikit) } desktopMain.dependencies { implementation(compose.desktop.currentOs) @@ -182,12 +259,18 @@ kotlin { implementation(libs.logback.classic) implementation(libs.nativeparameterstoreaccess) implementation(libs.kotlin.jwt) + + + //implementation(libs.logkmpanion) } commonTest.dependencies { implementation(libs.kotlin.test) @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.uiTest) + implementation(libs.paging.testing) + + } val desktopTest by getting { dependencies { @@ -195,6 +278,11 @@ kotlin { implementation(compose.desktop.currentOs) } } + getByName("commonMain") { + dependencies { + implementation(libs.kotlinx.coroutines) + } + } } } @@ -207,11 +295,11 @@ android { sourceSets["main"].resources.srcDirs("src/commonMain/resources") defaultConfig { - applicationId = "com.morpho.app" + applicationId = packageString minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = 1 - versionName = "1.0" + versionName = versionString } packaging { resources { @@ -231,7 +319,9 @@ android { applicationIdSuffix = ".debug" } } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } @@ -265,8 +355,9 @@ compose.desktop { TargetFormat.AppImage, TargetFormat.Pkg ) - packageName = "com.morpho.app" - packageVersion = "1.0.0" + packageName = packageString + + packageVersion = versionString.split("-")[0] } } } diff --git a/Morpho/composeApp/src/androidMain/kotlin/Platform.android.kt b/Morpho/composeApp/src/androidMain/kotlin/Platform.android.kt index 4f3ea05..e69de29 100644 --- a/Morpho/composeApp/src/androidMain/kotlin/Platform.android.kt +++ b/Morpho/composeApp/src/androidMain/kotlin/Platform.android.kt @@ -1,7 +0,0 @@ -import android.os.Build - -class AndroidPlatform : Platform { - override val name: String = "Android ${Build.VERSION.SDK_INT}" -} - -actual fun getPlatform(): Platform = AndroidPlatform() \ No newline at end of file diff --git a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/MorphoApplication.kt b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/MorphoApplication.kt index 7f6fbce..74b5ae7 100644 --- a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/MorphoApplication.kt +++ b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/MorphoApplication.kt @@ -3,12 +3,11 @@ package com.morpho.app import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.DefaultLifecycleObserver +import com.gu.toolargetool.TooLargeTool +import com.morpho.app.data.MorphoAgent import com.morpho.app.data.PreferencesRepository import com.morpho.app.di.appModule -import com.morpho.app.di.dataModule -import com.morpho.app.di.storageModule -import com.morpho.butterfly.AtIdentifier -import com.morpho.butterfly.Butterfly + import com.morpho.butterfly.auth.SessionRepository import com.morpho.butterfly.auth.UserRepository import org.koin.android.annotation.KoinViewModel @@ -27,29 +26,24 @@ class AndroidMainViewModel(app: Application): AndroidViewModel(app), DefaultLife val sessionRepository = app.getKoin().get() val userRepository = app.getKoin().get() - val api = app.getKoin().get() + val agent = app.getKoin().get() } class MorphoApplication : Application() { override fun onCreate() { - + TooLargeTool.startLogging(this); val koin = startKoin { androidContext(this@MorphoApplication) androidLogger() - modules(androidModule, appModule, storageModule, dataModule) + modules(androidModule, appModule)//, storageModule, dataModule) }.koin - val sessionRepository = koin.get { parametersOf(cacheDir.path.toString()) } - val userRepository = koin.get { parametersOf(cacheDir.path.toString()) } - val prefs = koin.get { parametersOf(cacheDir.path.toString()) } - val id: AtIdentifier? = if(sessionRepository.auth?.did != null) { - sessionRepository.auth?.did - } else if (sessionRepository.auth?.handle != null) { - sessionRepository.auth?.handle - } else { - userRepository.firstUser()?.id - } - val api = koin.get { parametersOf(id) } + + val storageDir = getPlatformStorageDir(filesDir.path.toString()) + koin.get { parametersOf(storageDir) } + koin.get { parametersOf(storageDir) } + koin.get { parametersOf(storageDir) } + koin.get() super.onCreate() } } diff --git a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/Platform.android.kt b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/Platform.android.kt new file mode 100644 index 0000000..46d0f5f --- /dev/null +++ b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/Platform.android.kt @@ -0,0 +1,48 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +package com.morpho.app + +import android.os.Build +import android.os.Parcel +import android.os.Parcelable +import kotlinx.datetime.LocalDateTime +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue +import kotlinx.parcelize.TypeParceler +import java.util.Locale + +actual typealias CommonParcelize = Parcelize +actual typealias CommonParcelable = Parcelable + +actual typealias CommonRawValue = RawValue +actual typealias CommonParceler = Parceler +actual typealias CommonTypeParceler = TypeParceler +actual object LocalDateTimeParceler : Parceler { + override fun create(parcel: Parcel): LocalDateTime { + val date = parcel.readString() + return date?.let { LocalDateTime.parse(it) } + ?: LocalDateTime(0, 0, 0, 0, 0) + } + + override fun LocalDateTime.write(parcel: Parcel, flags: Int) { + parcel.writeString(this.toString()) + } +} + +class AndroidPlatform : Platform { + override val name: String = "Android ${Build.VERSION.SDK_INT}" +} + +actual fun getPlatform(): Platform = AndroidPlatform() + + + +actual val myLang:String? + get() = Locale.getDefault().language + +actual val myCountry:String? + get() = Locale.getDefault().country + +actual fun getPlatformStorageDir(baseDir: String): String { + return baseDir +} \ No newline at end of file diff --git a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/Previews.kt b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/Previews.kt new file mode 100644 index 0000000..83da450 --- /dev/null +++ b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/Previews.kt @@ -0,0 +1,26 @@ +package com.morpho.app.com.morpho.app + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.morpho.app.ui.post.PlaceholderSkylineItem +import com.morpho.app.ui.post.PostFragmentRole + +@Preview +@Composable +fun PreviewPlaceholderSkylineItem() { + //MorphoTheme { + Column { + Column { + PlaceholderSkylineItem() + PlaceholderSkylineItem(role = PostFragmentRole.PrimaryThreadRoot) + PlaceholderSkylineItem(role = PostFragmentRole.ThreadBranchStart) + PlaceholderSkylineItem(role = PostFragmentRole.ThreadBranchMiddle) + PlaceholderSkylineItem(role = PostFragmentRole.ThreadBranchEnd) + PlaceholderSkylineItem(role = PostFragmentRole.ThreadRootUnfocused) + PlaceholderSkylineItem(role = PostFragmentRole.ThreadEnd) + PlaceholderSkylineItem(elevate = true) + } + } + //} +} \ No newline at end of file diff --git a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/common/BackHandler.kt b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/common/BackHandler.kt index fc71628..ddebe6e 100644 --- a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/common/BackHandler.kt +++ b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/common/BackHandler.kt @@ -4,7 +4,5 @@ import androidx.compose.runtime.Composable @Composable actual fun BackHandler(content: () -> Unit) { - BackHandler { - content() - } + } \ No newline at end of file diff --git a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.android.kt b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.android.kt index 0ac8cc1..d7a1091 100644 --- a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.android.kt +++ b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.android.kt @@ -3,44 +3,69 @@ package com.morpho.app.ui.common import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.navigationBars +import androidx.compose.material3.DrawerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.uidata.Event +import com.morpho.app.model.uistate.ContentCardState @Composable -actual fun TabbedScreenScaffold( +actual fun TabbedScreenScaffold( navBar: @Composable () -> Unit, - content: @Composable (PaddingValues) -> Unit, + content: @Composable (PaddingValues, T?) -> Unit, topContent: @Composable () -> Unit, + state: T?, modifier: Modifier, + drawerState: DrawerState, + profile: DetailedProfile? ) { - Scaffold( - contentWindowInsets = WindowInsets.navigationBars, - modifier = modifier, - topBar = { topContent() }, - bottomBar = { navBar() }, - content = content - ) + NavDrawer( + drawerState = drawerState, + profile = profile, + ) { + Scaffold( + contentWindowInsets = WindowInsets.navigationBars, + modifier = modifier, + topBar = { topContent() }, + bottomBar = { navBar() }, + content = { insets -> + content(insets, state) + } + ) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable -actual fun TabbedProfileScreenScaffold( +actual fun TabbedProfileScreenScaffold( navBar: @Composable () -> Unit, - content: @Composable (PaddingValues) -> Unit, + content: @Composable (PaddingValues, ContentCardState< out T>?) -> Unit, topContent: @Composable (TopAppBarScrollBehavior) -> Unit, + state: ContentCardState?, modifier: Modifier, scrollBehavior: TopAppBarScrollBehavior, nestedScrollConnection: NestedScrollConnection, + drawerState: DrawerState, + profile: DetailedProfile? ) { - Scaffold( - contentWindowInsets = WindowInsets.navigationBars, - modifier = modifier, - topBar = { topContent(scrollBehavior) }, - bottomBar = { navBar() }, - content = content - ) + NavDrawer( + drawerState = drawerState, + profile = profile, + ) { + Scaffold( + contentWindowInsets = WindowInsets.navigationBars, + modifier = modifier, + topBar = { topContent(scrollBehavior) }, + bottomBar = { navBar() }, + content = { insets -> + content(insets, state) + } + ) + } + } \ No newline at end of file diff --git a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.android.kt b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.android.kt index 30d216c..67fbf50 100644 --- a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.android.kt +++ b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.android.kt @@ -4,13 +4,38 @@ import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBackIosNew -import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -24,7 +49,10 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade +import com.morpho.app.model.bluesky.BskyLabelService import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.uidata.Event +import com.morpho.app.model.uidata.LabelerEvent import com.morpho.app.ui.elements.AvatarShape import com.morpho.app.ui.elements.OutlinedAvatar import com.morpho.app.ui.elements.RichTextElement @@ -48,6 +76,7 @@ public actual fun DetailedProfileFragment( isTopLevel:Boolean, scrollBehavior: TopAppBarScrollBehavior, onBackClicked: () -> Unit, + eventCallback: (Event) -> Unit, ) { val scrollState = rememberScrollState() val name = profile.displayName ?: profile.handle.handle @@ -251,7 +280,214 @@ public actual fun DetailedProfileFragment( Spacer(modifier = Modifier.height(10.dp)) SelectionContainer { - RichTextElement(profile.description.orEmpty()) + RichTextElement( + profile.description.orEmpty() + ) { facetTypes -> + + } + } + } + + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun LabelerProfileFragment( + labeler: BskyLabelService, + modifier: Modifier, + isSubscribed: Boolean, + isTopLevel: Boolean, + scrollBehavior: TopAppBarScrollBehavior, + onBackClicked: () -> Unit, + eventCallback: (Event) -> Unit, +) { + val scrollState = rememberScrollState() + val name = labeler.displayName ?: labeler.handle.handle + val bannerHeight = if (scrollBehavior.state.collapsedFraction <= .2) { + 155.dp + } else { + (155.dp - (60 * scrollBehavior.state.collapsedFraction).dp) + } + val collapsed = scrollBehavior.state.collapsedFraction > 0.5 + + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .background(MaterialTheme.colorScheme.background) + .verticalScroll(scrollState) + ) { + val (appbar, userStats, banner, labels, text, collapsedText) = createRefs() + + AsyncImage( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(labeler.creator?.banner.orEmpty()) + .crossfade(true) + .build(), + placeholder = painterResource(Res.drawable.test_banner), + contentDescription = "Profile Banner for ${labeler.displayName} ${labeler.handle}", + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + modifier = Modifier + .fillMaxWidth() + .constrainAs(banner) { + top.linkTo(parent.top) + } + .animateContentSize( + spring( + stiffness = Spring.StiffnessMediumLow, + dampingRatio = Spring.DampingRatioNoBouncy + ) + ) + .requiredHeight(bannerHeight) + ) + + LargeTopAppBar( + title = { + ConstraintLayout(//constraintSet = , + modifier = Modifier + .fillMaxWidth() + ) { + val (avatar, buttons, info) = createRefs() + val expanded = scrollBehavior.state.collapsedFraction <= 0.5 + val avatarSize = (80.dp - (30.0 * scrollBehavior.state.collapsedFraction).dp) + val centreGuideFraction = if(expanded) .6f else .5f + val avatarGuide = createGuidelineFromStart(.1f ) + val centreGuide = createGuidelineFromTop(centreGuideFraction) + + if(expanded){ + LabelerButtons( + subscribed = isSubscribed, + modifier = Modifier + .constrainAs(buttons) { + centerAround(centreGuide) + end.linkTo(parent.end, 12.dp) + }, + onSubscribeClicked = { + eventCallback(LabelerEvent.Subscribe(labeler.did)) + }, + onUnsubscribeClicked = { + eventCallback(LabelerEvent.Unsubscribe(labeler.did)) + }, + onMenuClicked = { + // TODO: add labeler menu + }, + ) + OutlinedAvatar( + url = labeler.avatar.orEmpty(), + contentDescription = "Avatar for ${labeler.displayName} ${labeler.handle}", + modifier = Modifier + .constrainAs(avatar) { + centerAround(avatarGuide) + }, + size = avatarSize, + avatarShape = AvatarShape.Rounded + ) + } else { + Surface( + color = MaterialTheme.colorScheme.background, + shape = MaterialTheme.shapes.small, + modifier = Modifier + .height(avatarSize) + .constrainAs(info) { + centerAround(centreGuide) + start.linkTo(avatarGuide, (-20).dp) + }, + ) { + Row { + OutlinedAvatar( + url = labeler.avatar.orEmpty(), + contentDescription = "Avatar for ${labeler.displayName} ${labeler.handle}", + size = avatarSize, + avatarShape = AvatarShape.Rounded + ) + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier.padding(start = 10.dp, end = 8.dp, bottom = 4.dp) + ) { + Text( + text = name, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = " @${labeler.handle}", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + } + } + + } + }, + navigationIcon = { + if (isTopLevel) { + IconButton( + onClick = { onBackClicked() }, + modifier = Modifier.size(30.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.onSurface.copy(0.6f), + contentColor = MaterialTheme.colorScheme.surface + ) + ) { + Icon( + imageVector = Icons.Default.ArrowBackIosNew, + contentDescription = "Back", + ) + } + } + }, + actions = {}, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.largeTopAppBarColors( + containerColor = Color.Transparent + ), + modifier = Modifier + .constrainAs(appbar) { + top.linkTo(parent.top) + } + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) + .wrapContentHeight(Alignment.Top) + , + windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top) + ) + if(!collapsed){ + Column( + modifier = Modifier + .constrainAs(text) { + top.linkTo(userStats.bottom, (-10).dp) + start.linkTo(parent.start) + } + .padding(start = 20.dp, end = 20.dp, top = 0.dp) + ) { + + SelectionContainer { + Text( + text = name, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + SelectionContainer { + Text( + text = " @${labeler.handle}", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium, + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + SelectionContainer { + RichTextElement(labeler.creator?.description.orEmpty()) } } diff --git a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/theme/Theme.android.kt b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/theme/Theme.android.kt index d1c2cb5..b76c121 100644 --- a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/theme/Theme.android.kt +++ b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/ui/theme/Theme.android.kt @@ -40,7 +40,7 @@ actual fun MorphoTheme( MaterialTheme( colorScheme = colorScheme, - typography = Typography, + typography = MorphoTypography(), content = content ) } \ No newline at end of file diff --git a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/util/LinkParsing.android.kt b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/util/LinkParsing.android.kt index c711706..8a6db35 100644 --- a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/util/LinkParsing.android.kt +++ b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/util/LinkParsing.android.kt @@ -3,14 +3,16 @@ package com.morpho.app.util import android.content.Intent import android.net.Uri -import com.morpho.app.MorphoApplication +import androidx.compose.ui.platform.UriHandler -actual fun openBrowser(url: String) { + +actual fun openBrowser(url: String, uriHandler: UriHandler) { val urlIntent = Intent( Intent.ACTION_VIEW, safeUrlParse(url) ) - MorphoApplication().applicationContext.startActivity(urlIntent) + + uriHandler.openUri(url) } fun safeUrlParse(uri: String): Uri? { diff --git a/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/util/Savers.android.kt b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/util/Savers.android.kt new file mode 100644 index 0000000..48368fc --- /dev/null +++ b/Morpho/composeApp/src/androidMain/kotlin/com/morpho/app/util/Savers.android.kt @@ -0,0 +1,2 @@ +package com.morpho.app.util + diff --git a/Morpho/composeApp/src/appleMain/kotlin/Platform.apple.kt b/Morpho/composeApp/src/appleMain/kotlin/Platform.apple.kt new file mode 100644 index 0000000..c42198b --- /dev/null +++ b/Morpho/composeApp/src/appleMain/kotlin/Platform.apple.kt @@ -0,0 +1,10 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +package com.morpho.app +import kotlinx.datetime.LocalDateTime + +// Note: no need to define CommonParcelize here (bc its @OptionalExpectation) +actual interface CommonParcelable // not used on iOS + +// Note: no need to define CommonTypeParceler> here (bc its @OptionalExpectation) +actual interface CommonParceler // not used on iOS +actual object LocalDateTimeParceler : CommonParceler // not used on iOS diff --git a/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/Platform.apple.kt b/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/Platform.apple.kt new file mode 100644 index 0000000..8355b95 --- /dev/null +++ b/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/Platform.apple.kt @@ -0,0 +1,16 @@ +package com.morpho.app + +// For Android @Parcelize +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.SOURCE) +actual annotation class CommonRawValue + +actual val myLang:String? + get() = NSLocale.currentLocale.languageCode + +actual val myCountry:String? + get() = NSLocale.currentLocale.countryCode + +actual fun getPlatformStorageDir(baseDir: String): String { + TODO("Not yet implemented") +} \ No newline at end of file diff --git a/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.apple.kt b/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.apple.kt index 0a0f832..b5c7b5d 100644 --- a/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.apple.kt +++ b/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.apple.kt @@ -3,37 +3,49 @@ package com.morpho.app.ui.common import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.navigationBars +import androidx.compose.material3.DrawerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.uidata.Event +import com.morpho.app.model.uistate.ContentCardState @Composable -actual fun TabbedScreenScaffold( +actual fun TabbedScreenScaffold( navBar: @Composable () -> Unit, - content: @Composable (PaddingValues) -> Unit, + content: @Composable (PaddingValues, T?) -> Unit, topContent: @Composable () -> Unit, + state: T?, modifier: Modifier, + drawerState: DrawerState, + profile: DetailedProfile? ) { Scaffold( contentWindowInsets = WindowInsets.navigationBars, modifier = modifier, topBar = { topContent() }, bottomBar = { navBar() }, - content = content + content = { insets -> + content(insets, state) + } ) } @OptIn(ExperimentalMaterial3Api::class) @Composable -actual fun TabbedProfileScreenScaffold( +actual fun TabbedProfileScreenScaffold( navBar: @Composable () -> Unit, - content: @Composable (PaddingValues) -> Unit, + content: @Composable (PaddingValues, ContentCardState?) -> Unit, topContent: @Composable (TopAppBarScrollBehavior) -> Unit, + state: ContentCardState?, modifier: Modifier, scrollBehavior: TopAppBarScrollBehavior, nestedScrollConnection: NestedScrollConnection, + drawerState: DrawerState, + profile: DetailedProfile? ) { } \ No newline at end of file diff --git a/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.apple.kt b/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.apple.kt index f49e03d..4c8a04b 100644 --- a/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.apple.kt +++ b/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.apple.kt @@ -4,7 +4,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.morpho.app.model.bluesky.BskyLabelService import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.uidata.Event @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -15,5 +17,19 @@ actual fun DetailedProfileFragment( isTopLevel: Boolean, scrollBehavior: TopAppBarScrollBehavior, onBackClicked: () -> Unit, + eventCallback: (Event) -> Unit, +) { +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun LabelerProfileFragment( + labeler: BskyLabelService, + modifier: Modifier, + isSubscribed: Boolean, + isTopLevel: Boolean, + scrollBehavior: TopAppBarScrollBehavior, + onBackClicked: () -> Unit, + eventCallback: (Event) -> Unit, ) { } \ No newline at end of file diff --git a/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/util/Savers.apple.kt b/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/util/Savers.apple.kt new file mode 100644 index 0000000..48368fc --- /dev/null +++ b/Morpho/composeApp/src/appleMain/kotlin/com/morpho/app/util/Savers.apple.kt @@ -0,0 +1,2 @@ +package com.morpho.app.util + diff --git a/Morpho/composeApp/src/commonMain/composeResources/drawable/BlueSkyKawaii.png b/Morpho/composeApp/src/commonMain/composeResources/drawable/BlueSkyKawaii.png new file mode 100644 index 0000000..79cab8e Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/drawable/BlueSkyKawaii.png differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Bold.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Bold.ttf new file mode 100644 index 0000000..e5389d8 Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Bold.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-BoldItalic.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-BoldItalic.ttf new file mode 100644 index 0000000..31cce79 Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-BoldItalic.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-ExtraLight.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-ExtraLight.ttf new file mode 100644 index 0000000..ecd3aff Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-ExtraLight.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-ExtraLightItalic.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-ExtraLightItalic.ttf new file mode 100644 index 0000000..5bbda2c Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-ExtraLightItalic.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Italic.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Italic.ttf new file mode 100644 index 0000000..46212a3 Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Italic.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Light.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Light.ttf new file mode 100644 index 0000000..b3d035d Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Light.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-LightItalic.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-LightItalic.ttf new file mode 100644 index 0000000..20bb6cf Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-LightItalic.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Medium.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Medium.ttf new file mode 100644 index 0000000..9395402 Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Medium.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-MediumItalic.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-MediumItalic.ttf new file mode 100644 index 0000000..7787ad2 Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-MediumItalic.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Regular.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Regular.ttf new file mode 100644 index 0000000..b581964 Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Regular.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-SemiBold.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-SemiBold.ttf new file mode 100644 index 0000000..a5bd9ee Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-SemiBold.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-SemiBoldItalic.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-SemiBoldItalic.ttf new file mode 100644 index 0000000..a5bcdc4 Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-SemiBoldItalic.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Thin.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Thin.ttf new file mode 100644 index 0000000..910458a Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-Thin.ttf differ diff --git a/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-ThinItalic.ttf b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-ThinItalic.ttf new file mode 100644 index 0000000..d5b4be6 Binary files /dev/null and b/Morpho/composeApp/src/commonMain/composeResources/font/IBMPlexSans-ThinItalic.ttf differ diff --git a/Morpho/composeApp/src/commonMain/kotlin/Platform.kt b/Morpho/composeApp/src/commonMain/kotlin/Platform.kt deleted file mode 100644 index 87ca3ff..0000000 --- a/Morpho/composeApp/src/commonMain/kotlin/Platform.kt +++ /dev/null @@ -1,5 +0,0 @@ -interface Platform { - val name: String -} - -expect fun getPlatform(): Platform \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/App.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/App.kt index d653110..e072c1b 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/App.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/App.kt @@ -1,45 +1,81 @@ package com.morpho.app +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi +import cafe.adriel.voyager.jetpack.ProvideNavigatorLifecycleKMPSupport import cafe.adriel.voyager.navigator.tab.CurrentTab -import cafe.adriel.voyager.navigator.tab.TabDisposable import cafe.adriel.voyager.navigator.tab.TabNavigator -import com.morpho.app.screens.base.BaseScreenModel +import com.morpho.app.data.ContentLabelService +import com.morpho.app.data.DarkModeSetting +import com.morpho.app.data.MorphoAgent import com.morpho.app.screens.base.tabbed.TabbedBaseScreen import com.morpho.app.screens.login.LoginScreen +import com.morpho.app.screens.main.tabbed.TabbedMainScreenModel +import com.morpho.app.ui.theme.MorphoTheme +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.mapLatest import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.KoinContext import org.koin.compose.koinInject +import org.koin.core.parameter.parametersOf -@OptIn(ExperimentalResourceApi::class) +@OptIn(ExperimentalResourceApi::class, ExperimentalVoyagerApi::class, + ExperimentalCoroutinesApi::class +) @Composable @Preview fun App() { KoinContext { - MaterialTheme { - val screenModel = koinInject() - val loggedIn by derivedStateOf { screenModel.isLoggedIn } + //ProvideNavigatorLifecycleKMPSupport { + val agent = koinInject() + val labelService = koinInject() + val screenModel = koinInject( + parameters = { parametersOf(agent, labelService) } + ) + val loggedIn by screenModel.isLoggedIn + .collectAsState(initial = screenModel.isLoggedIn.value) - - TabNavigator( - tab = if(loggedIn) { - TabbedBaseScreen - } else { - LoginScreen - }, - tabDisposable = { - TabDisposable( - navigator = it, - tabs = listOf(TabbedBaseScreen, LoginScreen) - ) + val morphoPrefs by derivedStateOf { agent.morphoPrefs } + val isSystemInDarkTheme = isSystemInDarkTheme() + val darkTheme by morphoPrefs.mapLatest { + when(it.darkMode){ + DarkModeSetting.SYSTEM -> isSystemInDarkTheme + DarkModeSetting.LIGHT -> false + DarkModeSetting.DARK -> true + null -> isSystemInDarkTheme + } + }.collectAsState(when(morphoPrefs.value.darkMode){ + DarkModeSetting.SYSTEM -> isSystemInDarkTheme + DarkModeSetting.LIGHT -> false + DarkModeSetting.DARK -> true + null -> isSystemInDarkTheme + }) + MorphoTheme(darkTheme = darkTheme) { + TabNavigator( + tab = if (loggedIn) { + TabbedBaseScreen + } else { + LoginScreen + }, + disposeNestedNavigators = true, + ) { + LaunchedEffect(loggedIn) { + if(loggedIn) { + it.current = TabbedBaseScreen + } else { + it.current = LoginScreen + } + } + CurrentTab() } - ) { - CurrentTab() } - } + // } } } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/Platform.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/Platform.kt new file mode 100644 index 0000000..e3fe75b --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/Platform.kt @@ -0,0 +1,45 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +package com.morpho.app + +import kotlinx.datetime.LocalDateTime + +interface Platform { + val name: String +} + +expect fun getPlatform(): Platform + +expect val myLang:String? +expect val myCountry:String? + +expect fun getPlatformStorageDir(baseDir: String = ""): String + +// For Android @Parcelize +@OptIn(ExperimentalMultiplatform::class) +@OptionalExpectation +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.BINARY) +expect annotation class CommonParcelize() + +// For Android @Parcelize +@OptIn(ExperimentalMultiplatform::class) +@OptionalExpectation +@Target(AnnotationTarget.TYPE) +expect annotation class CommonRawValue() + +// For Android Parcelable +expect interface CommonParcelable + +// For Android @TypeParceler +@OptIn(ExperimentalMultiplatform::class) +@OptionalExpectation +@Retention(AnnotationRetention.SOURCE) +@Repeatable +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +expect annotation class CommonTypeParceler>() + +// For Android Parceler +expect interface CommonParceler + +// For Android @TypeParceler to convert LocalDateTime to Parcel +expect object LocalDateTimeParceler: CommonParceler \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/BskyDataService.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/BskyDataService.kt new file mode 100644 index 0000000..2d92a6c --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/BskyDataService.kt @@ -0,0 +1,2 @@ +package com.morpho.app.data + diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/ContentLabelService.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/ContentLabelService.kt new file mode 100644 index 0000000..e6bb36d --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/ContentLabelService.kt @@ -0,0 +1,211 @@ +package com.morpho.app.data + +import app.bsky.actor.MuteTargetGroup +import app.bsky.actor.MutedWord +import app.bsky.actor.Visibility +import app.bsky.labeler.LabelerViewDetailed +import com.atproto.label.Blurs +import com.atproto.label.Severity +import com.morpho.app.model.bluesky.BskyPost +import com.morpho.app.model.bluesky.MorphoDataItem +import com.morpho.app.model.bluesky.toAtProtoLabel +import com.morpho.app.model.bluesky.toListVewBasic +import com.morpho.butterfly.AtUri +import com.morpho.butterfly.ContentHandling +import com.morpho.butterfly.Did +import com.morpho.butterfly.InterpretedLabelDefinition +import com.morpho.butterfly.LabelAction +import com.morpho.butterfly.LabelCause +import com.morpho.butterfly.LabelDescription +import com.morpho.butterfly.LabelIcon +import com.morpho.butterfly.LabelSource +import com.morpho.butterfly.LabelTarget +import com.morpho.butterfly.LabelValueDefFlag +import com.morpho.butterfly.LabelValueID +import com.morpho.butterfly.LabelerID +import com.morpho.butterfly.ModerationPreferences +import com.morpho.butterfly.MutedWordTarget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.lighthousegames.logging.logging + +class ContentLabelService: KoinComponent { + val agent: MorphoAgent = get() + val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + companion object { + val log = logging("ContentLabelService") + } + + val modPrefs: ModerationPreferences + get() = agent.prefs.modPrefs + + val hiddenPosts: List + get() = modPrefs.hiddenPosts + + val mutedWords: List + get() = modPrefs.mutedWords + + + val labelers: Map> + get() = modPrefs.labelers + + val labels: Map + get() = modPrefs.labels + + var labelDefinitions: Map> = emptyMap() + private set + + var labelerDetails: Map = emptyMap() + private set + + init { + runBlocking { + labelDefinitions = agent.getLabelDefinitions(modPrefs) + val details = agent.getLabelersDetailed(labelers.keys.map { Did(it) }).getOrNull()?.associateBy { + it.creator.did.did + } + labelerDetails = details ?: emptyMap() + } + + } + + fun shouldHideItem(item: MorphoDataItem.FeedItem): Boolean { + return when (item) { + is MorphoDataItem.Post -> { + item.post.author.mutedByMe + || item.post.author.blocking + || item.post.author.blockedBy + || hiddenPosts.any { uri -> item.containsUri(uri) } + || mutedWords.any { + item.post.text.contains(it.value, ignoreCase = true) + } || if(!modPrefs.adultContentEnabled) { + val adultLabels = item.post.labels.filter { label -> + labelDefinitions[label.creator.did]?.get(label.value)?.flags + ?.contains(LabelValueDefFlag.Adult) == true + } + adultLabels.isNotEmpty() + } else { + item.post.labels.any { label -> + labels[label.value] == Visibility.HIDE + } + } + } + is MorphoDataItem.Thread -> { + item.thread.anyMutedOrBlocked() + || hiddenPosts.any { uri -> item.containsUri(uri) } + || mutedWords.any { + item.thread.containsWord(it.value) + } || if(!modPrefs.adultContentEnabled) { + val adultLabels = item.thread.getLabels().filter { label -> + labelDefinitions[label.creator.did]?.get(label.value)?.flags + ?.contains(LabelValueDefFlag.Adult) == true + } + adultLabels.isNotEmpty() + } else { + item.thread.getLabels().all { label -> + labels[label.value] == Visibility.HIDE + } + } + } + } + } + + fun getContentHandlingForPost(post: BskyPost): List> { + val result = mutableListOf>() + val postLabels = post.labels + + if(post.author.mutedByMe) { + result.add(ContentHandling( + scope = Blurs.CONTENT, + action = LabelAction.Blur, + source = LabelDescription.YouMuted, + id = "muted", + icon = LabelIcon.EyeSlash(labelerAvatar = null), + ) to LabelCause.Muted(LabelSource.User, false)) + } + if(post.author.mutedByList != null) { + val list = post.author.mutedByList!! + result.add(ContentHandling( + scope = Blurs.CONTENT, + action = LabelAction.Blur, + source = LabelDescription.MuteList( + list.name, + list.uri, + ), + id = "muted-word", + icon = LabelIcon.EyeSlash( labelerAvatar = list.avatar), + ) to LabelCause.Muted(LabelSource.List(list.toListVewBasic()), false)) + } + val anyMutedWords = mutedWords.filter { post.text.contains(it.value, ignoreCase = true) } + if(anyMutedWords.isNotEmpty()) anyMutedWords.forEach { word -> + if(!word.targets.contains(MutedWordTarget("content"))) return@forEach + if(word.actorTarget == MuteTargetGroup.EXCLUDE_FOLLOWING && post.author.followedByMe) return@forEach + result.add(ContentHandling( + scope = Blurs.CONTENT, + action = LabelAction.Blur, + source = LabelDescription.MutedWord(word.value), + id = "muted-word", + icon = LabelIcon.EyeSlash(), + ) to LabelCause.MutedWord(LabelSource.User, false)) + } + + + if (postLabels.isNotEmpty()) { + log.verbose { "Post ${post.uri} has labels: ${postLabels.joinToString { it.value }}" } + // Adult content hiding if someone doesn't have it enabled is handled earlier, + // before rendering starts, as is Visibility.HIDE + // so we don't need to worry about it here + val relevantLabels = labels.filter { prefLabel -> + (prefLabel.value == Visibility.WARN || prefLabel.value == Visibility.HIDE) + && postLabels.any { it.value == it.value } }.toList() + .sortedBy { it.second.ordering } + val filteredPostLabels = postLabels.filter { label -> + relevantLabels.any { label.value == it.first } + } + + val possibleCauses = filteredPostLabels.mapNotNull { label -> + labelDefinitions[label.creator.did]?.get(label.value)?.let { labelDef -> + val localizedDefString = labelDef.allDescriptions.firstOrNull { + it.lang == agent.myLanguage.value + } ?: labelDef.allDescriptions.firstOrNull { it.lang.tag == "en" } + val localLabelDef = labelDef.copy( + localizedName = localizedDefString?.name ?: labelDef.localizedName, + localizedDescription = localizedDefString?.description + ?: labelDef.localizedDescription, + ) + + LabelCause.Label( + LabelSource.Labeler(labelerDetails[label.creator.did]!!), + label.toAtProtoLabel(), + localLabelDef, + localLabelDef.whatToHide, + labels[label.value] ?: labelDef.defaultSetting ?: Visibility.IGNORE, + localLabelDef.behaviours.content, + noOverride = !localLabelDef.configurable, + priority = when (localLabelDef.severity) { + Severity.INFORM -> 5 + Severity.ALERT -> 2 + Severity.NONE -> 8 + Severity.WARN -> 1 + }, + downgraded = false, + ) to localLabelDef.toContentHandling( + LabelTarget.Content, + avatar = labelerDetails[label.creator.did]?.creator?.avatar + ) + } + }.sortedBy{ it.first.priority } + possibleCauses.forEach { (cause, handling) -> + result.add(handling to cause) + } + } + + log.verbose { "Post ${post.uri} has handling: \n$result" } + return result.toList() + } + +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/FeedTuner.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/FeedTuner.kt new file mode 100644 index 0000000..c3e26b1 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/FeedTuner.kt @@ -0,0 +1,332 @@ +package com.morpho.app.data + +import com.morpho.app.model.bluesky.AuthorFilter +import com.morpho.app.model.bluesky.FeedDescriptor +import com.morpho.app.model.bluesky.MorphoDataItem +import com.morpho.app.model.bluesky.Profile +import com.morpho.app.model.bluesky.ThreadPost +import com.morpho.app.model.uidata.MorphoData +import com.morpho.app.model.uidata.areSameAuthor +import com.morpho.app.model.uistate.FeedType +import com.morpho.butterfly.AtUri +import com.morpho.butterfly.BskyPreferences +import com.morpho.butterfly.Did +import com.morpho.butterfly.Language +import com.morpho.butterfly.PagedResponse +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable + +typealias TunerFunction = (List, FeedTuner) -> List + +@Suppress("UNCHECKED_CAST") +@Serializable +data class FeedTuner(val tuners: List> = persistentListOf()) { + val seenKeys = mutableSetOf() + val seenUris = mutableSetOf() + val seenRootUris = mutableSetOf() + + companion object { + fun useFeedTuners( + userDid: Did, + prefs: BskyPreferences, + desc: FeedDescriptor, + ): List> { + if(desc is FeedDescriptor.Author) { + when(desc.filter) { + AuthorFilter.PostsNoReplies -> return listOf( + FeedTuner(tuners = persistentListOf( + Companion::removeReplies + )) as FeedTuner + ) + AuthorFilter.PostsWithReplies ->{ + return listOf() + } + AuthorFilter.PostsAuthorThreads -> { + return listOf() + } + AuthorFilter.PostsWithMedia -> { + return listOf() + } + } + } + val languages = prefs.languages + val languageTuner: TunerFunction = { f, t -> + preferredLanguageOnly(languages, f, t) + } + if(desc is FeedDescriptor.FeedGen) { + return listOf(FeedTuner(tuners = persistentListOf(languageTuner))) as List> + } + if(desc is FeedDescriptor.Home || desc is FeedDescriptor.List) { + val tuners = mutableListOf(FeedTuner(tuners = persistentListOf(Companion::removeOrphans))) + val feedPrefs = prefs.feedView ?: return tuners.toList() as List> + if(feedPrefs.hideReposts == true) tuners.add(FeedTuner(tuners = persistentListOf(Companion::removeReposts))) + if(feedPrefs.hideReplies == true) tuners.add(FeedTuner(tuners = persistentListOf(Companion::removeReplies))) + else { + val followedRepliesOnly: TunerFunction = { f, t -> + followedRepliesOnly(userDid, f, t) + } + tuners.add(FeedTuner(tuners = persistentListOf(followedRepliesOnly))) + } + if(feedPrefs.hideQuotePosts == true) tuners.add( + FeedTuner(tuners = persistentListOf( + Companion::removeQuotePosts + )) + ) + tuners.add(FeedTuner(tuners = persistentListOf(Companion::dedupThreads))) + return tuners.toList() as List> + } + return listOf() + } + + fun useFeedTuners( + prefs: BskyUserPreferences, + feed: MorphoData + ): List> { + if(feed.isProfileFeed) { + when(feed.feedType) { + FeedType.PROFILE_POSTS -> return listOf( + FeedTuner(tuners = persistentListOf( + Companion::removeReplies + )) as FeedTuner + ) + FeedType.PROFILE_USER_LISTS -> return listOf() + FeedType.PROFILE_FEEDS_LIST -> return listOf() + FeedType.PROFILE_MOD_SERVICE -> return listOf() + else -> {} + } + } + val languages = prefs.preferences.languages.toList() + val languageTuner: TunerFunction = { f, t -> + preferredLanguageOnly(languages, f, t) + } + if(feed.feedType == FeedType.OTHER) { + return listOf(FeedTuner(tuners = persistentListOf(languageTuner))) as List> + } + if(feed.feedType == FeedType.LIST_FOLLOWING || feed.feedType == FeedType.HOME) { + val tuners = mutableListOf(FeedTuner(tuners = persistentListOf(Companion::removeOrphans))) + tuners.add(FeedTuner(tuners = persistentListOf(Companion::dedupThreads))) + return tuners.toList() as List> + } + return listOf() + } + + fun removeReplies( + feed: List, + tuner: FeedTuner = FeedTuner(), + ): List { + return feed.filterNot { item -> + when(item) { + is MorphoDataItem.Post -> item.isReply && !item.isRepost && + !(item.getAuthors()?.let { areSameAuthor(it) } ?: false) + is MorphoDataItem.Thread -> !(item.getAuthors()?.let { areSameAuthor(it) } ?: false) + else -> false + } + } + + } + + fun removeReposts( + feed: List, + tuner: FeedTuner = FeedTuner(), + ): List { + return feed.filterNot { item -> + item.isRepost + } + } + + fun removeQuotePosts( + feed: List, + tuner: FeedTuner = FeedTuner(), + ): List { + return feed.filterNot { item -> + item.isQuotePost + } + } + + fun removeOrphans( + feed: List, + tuner: FeedTuner = FeedTuner(), + ): List { + return feed.filterNot { item -> + when(item) { + is MorphoDataItem.Post -> item.isOrphan + is MorphoDataItem.Thread -> false + else -> false + } + } + } + + fun dedupThreads( + feed: List, + tuner: FeedTuner = FeedTuner(), + ): List { + return feed.filterNot { item -> + val rootUri = item.rootUri + if(!item.isRepost == tuner.seenRootUris.contains(rootUri)) { + false + } else { + tuner.seenRootUris.add(rootUri) + true + } + } + } + + fun followedRepliesOnly( + userDid: Did, + feed: List, + tuner: FeedTuner = FeedTuner(), + ): List { + return feed.filterNot { item -> + item.isReply && !shouldDisplayReplyInFollowing(item, userDid) + } + } + + fun preferredLanguageOnly( + languages: List = persistentListOf(), + feed: List, + tuner: FeedTuner = FeedTuner(), + ): List { + if (languages.isEmpty()) return feed + val newFeed = feed.filter { item -> + when(item) { + is MorphoDataItem.Post -> { + item.post.langs.isEmpty() || + item.post.langs.any { languages.contains(it) } + } + is MorphoDataItem.Thread -> { + item.thread.post.langs.isEmpty() || + item.thread.post.langs.any { languages.contains(it) } + } + else -> false + } + }.map { item -> + when(item) { + is MorphoDataItem.Post -> item + is MorphoDataItem.Thread -> { + item.copy( + thread = item.thread.filterReplies { reply -> + when(reply) { + is ThreadPost.ViewablePost -> reply.post.langs.isEmpty() || + reply.post.langs.any { languages.contains(it) } + is ThreadPost.BlockedPost -> true + is ThreadPost.NotFoundPost -> true + } + + } + ) + } + else -> false + } + } + return newFeed.ifEmpty { feed } as List + } + } + fun tune( + feed: PagedResponse.Feed + ): PagedResponse.Feed { + var workingFeed = feed.items + tuners.forEach { tuner -> + workingFeed = tuner(workingFeed, this) + } + workingFeed = workingFeed.mapNotNull { item -> + if(seenKeys.contains(item.key)) null + else if(item is MorphoDataItem.Thread) { + val itemUris = item.getUris() + val seenInThisThread = itemUris.filter { seenUris.contains(it) } + if(seenInThisThread.isNotEmpty()) { + if(seenInThisThread.size == itemUris.size) { + null + } else { + item.thread.parents.filter { parent -> + when(parent) { + is ThreadPost.ViewablePost -> parent.post.uri in seenInThisThread + is ThreadPost.BlockedPost -> false + is ThreadPost.NotFoundPost -> false + } + } + val newThread = item.copy(thread = item.thread.filterReplies { reply -> + when(reply) { + is ThreadPost.ViewablePost -> reply.post.uri in seenInThisThread + is ThreadPost.BlockedPost -> false + is ThreadPost.NotFoundPost -> false + } + }.copy(parent = item.thread.parent)) + seenUris.addAll(itemUris) + if(newThread.thread.replies.isEmpty() && newThread.thread.parents.isEmpty()) { + null + } else { + newThread + } + } + } else { + seenUris.addAll(itemUris) + item + } + } else { + val disableDedup = item.isReply || item.isRepost + if(!disableDedup) seenKeys.add(item.key) + item + } + //item + } as List + return feed.copy(items = workingFeed.ifEmpty { feed.items.distinctBy { it.getUri() } }) + } + + +} + +/// Algo copied from official app +/// https://github.com/bluesky-social/social-app/blob/main/src/lib/api/feed-manip.ts#L445 +/// as of commit https://github.com/bluesky-social/social-app/commit/e2a244b99889743a8788b0c464d3e150bc8047ad +/// The algorithm is a controversial, so we may want to change it or offer more options. +fun shouldDisplayReplyInFollowing( + item: MorphoDataItem.FeedItem, + userDid: Did, +): Boolean { + val authors = item.getAuthors() + val author = authors?.author + val rootAuthor = authors?.rootAuthor + val parentAuthor = authors?.parentAuthor + val grandParentAuthor = authors?.grandParentAuthor + if (!isSelfOrFollowing(author, userDid)) + return false // Only show replies from self or people you follow. + + if(parentAuthor == null || parentAuthor.did == author?.did + && rootAuthor == null || rootAuthor?.did == author?.did + && grandParentAuthor == null || grandParentAuthor?.did == author?.did + ) return true // Always show self-threads. + + if ( + parentAuthor.did != author?.did && + rootAuthor?.did == author?.did && + item is MorphoDataItem.Thread + ) { + // If you follow A, show A -> someone[>0 likes] -> A chains too. + // This is different from cases below because you only know one person. + val parentPost = when(val p = item.thread.parents.lastOrNull()) { + is ThreadPost.ViewablePost -> p.post + else -> null + } + if(parentPost != null && parentPost.likeCount > 0) + return true + } + // From this point on we need at least one more reason to show it. + if ( + parentAuthor.did != author?.did && isSelfOrFollowing(parentAuthor, userDid) + ) return true + if ( + grandParentAuthor != null && + grandParentAuthor.did != author?.did && + isSelfOrFollowing(grandParentAuthor, userDid) + ) return true + if ( + rootAuthor != null && + rootAuthor.did != author?.did && + isSelfOrFollowing(rootAuthor, userDid) + ) return true + return false +} + +fun isSelfOrFollowing(profile: Profile?, userDid: Did): Boolean { + return profile?.did == userDid || profile?.followedByMe == true +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/MorphoAgent.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/MorphoAgent.kt new file mode 100644 index 0000000..2c9670b --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/MorphoAgent.kt @@ -0,0 +1,162 @@ +package com.morpho.app.data + +import app.bsky.actor.AdultContentPref +import app.bsky.actor.PreferencesUnion +import app.bsky.labeler.LabelerViewDetailed +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.bluesky.toProfile +import com.morpho.app.myLang +import com.morpho.butterfly.BskyPreferences +import com.morpho.butterfly.ButterflyAgent +import com.morpho.butterfly.Did +import com.morpho.butterfly.InterpretedLabelDefinition +import com.morpho.butterfly.LabelValueID +import com.morpho.butterfly.LabelerID +import com.morpho.butterfly.Language +import com.morpho.butterfly.auth.SessionRepository +import com.morpho.butterfly.auth.UserRepository +import com.morpho.butterfly.localize +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class MorphoAgent( + val localPrefs: PreferencesRepository, + userData: UserRepository, + session: SessionRepository, +): ButterflyAgent(userData, session) { + //val localPrefs: PreferencesRepository by inject() + + val morphoPrefs: MutableStateFlow = MutableStateFlow(MorphoPreferences( + kawaiiMode = true, + notificationsFilter = NotificationsFilterPref(), + accessibility = AccessibilityPreferences(), + )) + val bskyPrefs: MutableStateFlow = MutableStateFlow(prefs) + val myLanguage = MutableStateFlow(Language(myLang ?: morphoPrefs.value.uiLanguage?.tag ?: "en")) + + val labelersDetailed: Flow> = flow { + val labelers = getLabelersDetailed(labelers).getOrNull() ?: listOf() + emit(labelers) + } + + init { + serviceScope.launch { + while(!isLoggedIn) delay(10) + if(isLoggedIn) { + serviceScope.launch { + localPrefs.morphoPrefs(id!!).distinctUntilChanged().collectLatest { + if (it != null && it != MorphoPreferences()) { + morphoPrefs.value = it + } + } + } + serviceScope.launch { + localPrefs.bskyPrefs(id!!).distinctUntilChanged().collectLatest { + if (it != null && it != BskyPreferences()) { + prefs = it + } + } + } + serviceScope.launch { + localPrefs.bskyPrefs(id!!).distinctUntilChanged().collectLatest { + if (it != null && it != BskyPreferences()) { + bskyPrefs.value = it + } + } + } + serviceScope.launch { + localPrefs.writePreferences( + BskyUserPreferences( + id!!, + prefs, + morphoPrefs.value + ) + ) + } + } + } + + } + + + val kawaiiMode: Boolean + get() = morphoPrefs.value.kawaiiMode == true + + + fun setAccessibilityPrefs(prefs: AccessibilityPreferences) = serviceScope.launch { + updateMorphoPrefs { + val newPrefs = AccessibilityPreferences.update(it.accessibility ?: AccessibilityPreferences(), prefs) + it.copy(accessibility = newPrefs) + } + } + fun setNotificationsFilterPrefs(prefs: NotificationsFilterPref) = serviceScope.launch { + updateMorphoPrefs { + val newPrefs = NotificationsFilterPref.update(it.notificationsFilter ?: NotificationsFilterPref(), prefs) + it.copy(notificationsFilter = newPrefs) + } + } + + fun setDarkMode(setting: DarkModeSetting = DarkModeSetting.SYSTEM) = serviceScope.launch { + updateMorphoPrefs { + it.copy(darkMode = setting) + } + } + + fun setUILanguage(language: Language) = serviceScope.launch { + myLanguage.value = language + updateMorphoPrefs { + it.copy(uiLanguage = language) + } + } + suspend fun updateMorphoPrefs( + updateFun: (MorphoPreferences) -> MorphoPreferences? + ): Result { + val prefs = updateFun(morphoPrefs.value) + return if(prefs != null) { + localPrefs.setMorphoPrefs(id!!, prefs) + morphoPrefs.value = prefs + Result.success(prefs) + } else Result.failure(Exception("Update failed")) + } + + suspend fun localizeLabelDefinitions(prefs: BskyPreferences): Map> { + val labelDefs = getLabelDefinitions(prefs) + return labelDefs.map { labeler -> + labeler.key to labeler.value.map { entry -> + val labelDef = entry.value + + entry.key to labelDef.localize(myLanguage.value) + }.associate { it.first to it.second } + }.associate { it.first to it.second } + } + + fun toggleAdultContent(enabled: Boolean) = serviceScope.launch { + updatePreferences { prefs -> + val newPref = if(enabled) AdultContentPref(true) else AdultContentPref(false) + val updatedPrefs = prefs.filter { it !is PreferencesUnion.AdultContentPref }.plus( + PreferencesUnion.AdultContentPref(newPref) + ) + return@updatePreferences updatedPrefs + } + } + + fun getAccounts(): Flow> { + return userData.users().map { users -> + users.mapNotNull { + getProfile(it.id).getOrNull()?.toProfile() + } + }.distinctUntilChanged() + } + + fun removeAccount(did: Did) = serviceScope.launch { + userData.removeUser(did) + } + + +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/MorphoDataSource.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/MorphoDataSource.kt new file mode 100644 index 0000000..6f95ffc --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/MorphoDataSource.kt @@ -0,0 +1,355 @@ +package com.morpho.app.data + +import app.cash.paging.PagingSource +import app.cash.paging.PagingState +import com.morpho.app.model.bluesky.BskyPostReason +import com.morpho.app.model.bluesky.BskyPostThread +import com.morpho.app.model.bluesky.MorphoDataItem +import com.morpho.app.model.bluesky.ThreadPost +import com.morpho.app.model.bluesky.toPost +import com.morpho.app.model.bluesky.toThreadPost +import com.morpho.app.model.uidata.Delta +import com.morpho.app.model.uidata.Moment +import com.morpho.butterfly.ButterflyAgent +import com.morpho.butterfly.Cursor +import com.morpho.butterfly.FeedRequest +import com.morpho.butterfly.PagedResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.datetime.Instant +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.lighthousegames.logging.logging +import kotlin.time.Duration + + +abstract class MorphoDataSource: PagingSource(), KoinComponent { + val agent: MorphoAgent = get() + val moderator: ContentLabelService = get() + //override val keyReuseSupported: Boolean = true + + override fun getRefreshKey(state: PagingState): Cursor? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + if(anchorPage?.prevKey == null && anchorPage?.nextKey == null) null + else if (anchorPage.prevKey == null) anchorPage.nextKey // First page + else if (anchorPage.nextKey == null) anchorPage.prevKey // Last page + else anchorPage.nextKey // Initial page + } + } + + companion object { + val defaultConfig = app.cash.paging.PagingConfig( + pageSize = 50, + prefetchDistance = 20, + initialLoadSize = 100, + enablePlaceholders = false, + ) + } +} + + +data class MorphoFeedSource( + val request: FeedRequest, + val tuners: List> = listOf(), + val repliesBumpThreads: Boolean = false, + val collectThreads: Boolean = true, +): MorphoDataSource() { + + companion object { + val log = logging("MorphoFeedSource") + } + + override suspend fun load( + params: LoadParams + ): LoadResult { + try { + val limit = params.loadSize + val loadCursor = when(params) { + is LoadParams.Append -> params.key + is LoadParams.Prepend -> Cursor.Empty + is LoadParams.Refresh -> Cursor.Empty + } + return request(loadCursor, limit.toLong()).map { + pagedList -> + + + val tunedList = when(pagedList) { + is PagedResponse.Feed -> { +// val jsonToLog = json.encodeToString(pagedList.copy() as PagedResponse.Feed) +// log.d { +// "Feed reponse:\n$jsonToLog" +// } + var tunedFeed = pagedList.copy( + items = if(collectThreads) { + pagedList.items.filterNot { moderator.shouldHideItem(it) } + .collectThreads( + repliesBumpThreads = repliesBumpThreads, + agent = agent + ).getOrNull() ?: pagedList.items + } else pagedList.items.filterNot { moderator.shouldHideItem(it) } + ) + tuners.forEach { tuner -> + tunedFeed = tuner.tune(tunedFeed) + } +// val tunedJson = json.encodeToString(tunedFeed.copy() as PagedResponse.Feed) +// log.d { +// "Feed reponse after tuning:\n$tunedJson" +// } + tunedFeed + } + is PagedResponse.FromRecord -> pagedList.items + is PagedResponse.Profile -> pagedList.items + } + LoadResult.Page( + data = tunedList.toList(), + prevKey = when(params) { + is LoadParams.Append -> loadCursor + is LoadParams.Prepend -> null + is LoadParams.Refresh -> Cursor.Empty + }, + nextKey = pagedList.cursor, + ) + }.onFailure { + return LoadResult.Error(it) + }.getOrDefault(LoadResult.Error(Exception("Load failed"))) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } + + fun updates(): Flow = flow { + while (true) { + val newData = peek().getOrNull() + if(newData != null) { + emit(newData) + } + } + }.distinctUntilChanged().flowOn(Dispatchers.Default) + + suspend fun hasNew(): Boolean { + return peek().getOrNull() != null + } + + suspend fun peek(): Result { + try { + request(Cursor.Empty, 1).onSuccess { pagedList -> + return Result.success(pagedList.items.firstOrNull()) + }.onFailure { + return Result.failure(it) + } + } catch (e: Exception) { + return Result.failure(e) + } + return Result.failure(Exception("Should not be reached")) + } +} + +suspend fun List.collectThreads( + depth: Int = 3, height: Int = 80, + timeRange: Delta = Delta(Duration.parse("4h")), + repliesBumpThreads: Boolean = true, + agent: ButterflyAgent? = null, // allows to just use local data +): Result> { + val threads = mutableListOf() + val replies = mutableListOf() + val posts = mutableListOf() + val threadCandidates = mutableListOf() + this.forEach { item -> + when(item) { + is MorphoDataItem.Post -> { + if (item.isReply) replies.add(item) + else if (item.isOrphan) posts.add(item) + else posts.add(item) + } + is MorphoDataItem.Thread -> { + if (!item.isIncompleteThread) threads.add(item) + else threadCandidates.add(item) + } + else -> return Result.failure(Exception("Invalid feed item type")) + } + } + replies.forEachIndexed { index, reply -> + if (reply == null) return@forEachIndexed + if (reply.isOrphan) { + val parent = reply.post.reply?.parentPost + ?: reply.post.reply?.replyRef?.parent?.uri?.let { + agent?.getPosts(listOf(it))?.getOrNull()?.firstOrNull()?.toPost() + } + val root = reply.post.reply?.rootPost + ?: reply.post.reply?.replyRef?.root?.uri?.let { + agent?.getPosts(listOf(it))?.getOrNull()?.firstOrNull()?.toPost() + } + replies[index] = MorphoDataItem.Post( + reply.post.copy(reply = reply.post.reply?.copy(parentPost = parent, rootPost = root)), + reply.reason, + isOrphan = root != null && parent != null, + ) + } + val newReply = replies[index] // Update in case we changed it above + val replyRef = newReply?.post?.reply?.replyRef ?: return@forEachIndexed + val parent = replyRef.parent.uri + val root = replyRef.root.uri + val inThread = threads.indexOfFirst { it?.containsUri(parent) == true || it?.containsUri(root) == true } + if (inThread != -1) { + val thread = threads.getOrNull(inThread) ?: return@forEachIndexed + threads[inThread] = thread.addReply(newReply.post).copy(isIncompleteThread = false) + replies[index] = null + return@forEachIndexed + } + val inCandidates = threadCandidates.indexOfFirst { it?.containsUri(parent) == false || it?.containsUri(root) == false } + if (inCandidates != -1) { + val thread = threadCandidates.getOrNull(inCandidates) ?: return@forEachIndexed + threadCandidates[inCandidates] = thread.addReply(newReply.post).copy(isIncompleteThread = false) + replies[index] = null + return@forEachIndexed + } + threadCandidates.add(MorphoDataItem.Thread(BskyPostThread( + post = newReply.post, + parent = newReply.post.reply.parentPost?.toThreadPost(), + replies = listOf(), + + ), isIncompleteThread = true)) + } + threadCandidates.forEachIndexed { index, thread -> + if (thread == null) return@forEachIndexed + val rootInThreads = threads.indexOfFirst { t -> t?.containsUri(thread.rootUri) == true } + if (rootInThreads == - 1) { + val threadToSplice = threads.getOrNull(rootInThreads) + if(threadToSplice == null) { + threads.add(thread.copy(isIncompleteThread = false)) + threadCandidates[index] = null + return@forEachIndexed + } + if( + thread.thread.parents.firstOrNull() is ThreadPost.ViewablePost + && threadToSplice.thread.parents.firstOrNull() is ThreadPost.ViewablePost + && thread.rootUri == threadToSplice.rootUri + ) { + if(thread.thread.parents.size == 1 && threadToSplice.thread.parents.size == 1) { + // Both threads have the same, viewable root post and are only one level deep in terms of parents + val newEntry = thread.thread.parents.first() as ThreadPost.ViewablePost + val oldEntry = threadToSplice.thread.parents.first() as ThreadPost.ViewablePost + + val newReplies = (newEntry.replies + oldEntry.replies).distinctBy { it.uri }.toMutableList() + newReplies.add(ThreadPost.ViewablePost(thread.thread.post, thread.thread.parents.last(), thread.thread.replies)) + if( thread.getUri() != threadToSplice.getUri() ) + newReplies.add(ThreadPost.ViewablePost(threadToSplice.thread.post, threadToSplice.thread.parents.last(),threadToSplice.thread.replies)) + val newThread = BskyPostThread( + post = newEntry.post, + parent = newEntry.parent, + replies = newReplies.distinctBy { it.uri }, + ) + threads[rootInThreads] = threadToSplice.copy(thread = newThread, isIncompleteThread = false) + threadCandidates[index] = null + } else if(thread.thread.parents.size == 2 && threadToSplice.thread.parents.size == 2) { + // Both threads have the same, viewable root post and parent chains are both length 2 + val newEntry = thread.thread.parents.first() as ThreadPost.ViewablePost + + val newReplies = mutableListOf() + if(thread.thread.parents.lastOrNull() !is ThreadPost.ViewablePost) return@forEachIndexed + if(threadToSplice.thread.parents.lastOrNull() !is ThreadPost.ViewablePost) return@forEachIndexed + val newParent = thread.thread.parents.last() as ThreadPost.ViewablePost + val oldParent = threadToSplice.thread.parents.last() as ThreadPost.ViewablePost + val newReply = ThreadPost.ViewablePost(thread.thread.post, thread.thread.parents.last(), thread.thread.replies) + val oldReply = ThreadPost.ViewablePost(threadToSplice.thread.post, threadToSplice.thread.parents.last(), threadToSplice.thread.replies) + newParent.addReply(newReply) + oldParent.addReply(oldReply) + newReplies.add(newReply) + newReplies.add(oldReply) + val newThread = BskyPostThread( + post = newEntry.post, + parent = newParent, + replies = newReplies.distinctBy { it.uri }, + ) + threads[rootInThreads] = threadToSplice.copy(thread = newThread, isIncompleteThread = false) + threadCandidates[index] = null + } + + } + } else { + val inThreads = threads.indexOfFirst { t -> t?.containsUri(thread.thread.post.uri) == true } + if (inThreads == - 1) { + val threadToSplice = threads.getOrNull(index) ?: return@forEachIndexed + threads[index] = threadToSplice.addReply(ThreadPost.ViewablePost(thread.thread.post, thread.thread.parents.last(), thread.thread.replies)) + threadCandidates[index] = null + } + } + } + threadCandidates.filterNotNull().filterNot { it.isIncompleteThread } + if (threadCandidates.isNotEmpty()) threads.addAll(threadCandidates) + val newReplies = replies.filterNotNull() + //.distinctBy { it.getUri() } + .filterNot { reply -> + if(reply.isRepost) false + else if(reply.isQuotePost) false + else reply.getUris().any { uri -> threads.any { it?.containsUri(uri) == true } } + } +// .sortedByDescending { when(it.reason) { +// is BskyPostReason.BskyPostRepost -> it.reason.indexedAt +// else -> it.post.createdAt +// } } + var newPosts = posts.toList().filterNotNull() + //newPosts = newPosts.distinctBy { it.getUri() } + newPosts = newPosts.filterNot { post -> + if(post.isRepost) false + else if(post.isQuotePost) false + else post.getUris().any { uri -> threads.any { it?.containsUri(uri) == true } } + } +// .sortedByDescending { when(it.reason) { +// is BskyPostReason.BskyPostRepost -> it.reason.indexedAt +// else -> it.post.createdAt +// } } + var newThreads = threads.toList().filterNotNull() + newThreads = newThreads.sortedByDescending { if(!repliesBumpThreads) { + it.rootAccessiblePost.createdAt + } else { + maxOf(it.thread.post.createdAt, + it.thread.replies.fold(it.thread.post.createdAt) { acc, post -> + val postTime = when(post) { + is ThreadPost.ViewablePost -> post.post.createdAt + is ThreadPost.BlockedPost -> Moment(Instant.DISTANT_PAST) + is ThreadPost.NotFoundPost -> Moment(Instant.DISTANT_PAST) + } + maxOf(acc, postTime) + }) + } } + // newThreads = newThreads.distinctBy { it.getUri() } +// .filterNot { thread -> +// thread.getUris().filterNot { uri -> +// newThreads.fastAny { it.getUri() == uri } }.size > 1 +// } + val newFeed = mutableListOf() + newFeed.addAll(newPosts) + newFeed.addAll(newThreads) + newFeed.addAll(newReplies) + val sortedFeed = newFeed.sortedByDescending { + when(it) { + is MorphoDataItem.Post -> when(it.reason) { + is BskyPostReason.BskyPostFeedPost -> it.post.createdAt + is BskyPostReason.BskyPostRepost -> it.reason.indexedAt + is BskyPostReason.SourceFeed -> it.post.createdAt + null -> it.post.createdAt + } + is MorphoDataItem.Thread -> if(!repliesBumpThreads) { + it.rootAccessiblePost.createdAt + } else { + maxOf(it.thread.post.createdAt, + it.thread.replies.fold(it.thread.post.createdAt) { acc, post -> + val postTime = when(post) { + is ThreadPost.ViewablePost -> post.post.createdAt + is ThreadPost.BlockedPost -> Moment(Instant.DISTANT_PAST) + is ThreadPost.NotFoundPost -> Moment(Instant.DISTANT_PAST) + } + maxOf(acc, postTime) + }) + } + + } + } + return Result.success(sortedFeed as List) +} + diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/NotificationsSource.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/NotificationsSource.kt new file mode 100644 index 0000000..855f3d3 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/NotificationsSource.kt @@ -0,0 +1,193 @@ +package com.morpho.app.data + +import app.bsky.notification.ListNotificationsReason +import app.cash.paging.PagingConfig +import app.cash.paging.compose.LazyPagingItems +import com.morpho.app.model.bluesky.BskyNotification +import com.morpho.app.model.bluesky.toBskyNotification +import com.morpho.app.model.uistate.NotificationsFilterState +import com.morpho.butterfly.AtUri +import com.morpho.butterfly.Cursor +import kotlinx.serialization.Serializable +import org.lighthousegames.logging.logging + +class NotificationsSource: MorphoDataSource() { + companion object { + val log = logging() + val defaultConfig = PagingConfig( + pageSize = 20, + prefetchDistance = 20, + initialLoadSize = 50, + enablePlaceholders = false, + ) + } + + override suspend fun load(params: LoadParams): LoadResult { + try { + val limit = params.loadSize + val loadCursor = when(params) { + is LoadParams.Append -> params.key + is LoadParams.Prepend -> Cursor.Empty + is LoadParams.Refresh -> Cursor.Empty + } + return agent.listNotifications(limit.toLong(), loadCursor.value).map { response -> + val newCursor = response.cursor + val items = response.items.map { it.toBskyNotification()} + LoadResult.Page( + data = items, + prevKey = when(params) { + is LoadParams.Append -> loadCursor + is LoadParams.Prepend -> Cursor.Empty + is LoadParams.Refresh -> Cursor.Empty + }, + nextKey = newCursor, + ) + }.onFailure { + return LoadResult.Error(it) + }.getOrDefault(LoadResult.Error(Exception("Load failed"))) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } +} + +fun LazyPagingItems.collectNotifications( + toMark: List = listOf() +) : List { + val seen = mutableListOf() + val workList = mutableListOf() + this.itemSnapshotList.map { notif -> + if (notif == null) return@map NotificationsListItem( + notifications = listOf(), + reason = ListNotificationsReason.PLACEHOLDER, + isRead = false, + reasonSubject = null, + ) + if(notif.reasonSubject != null && seen.contains(notif.reasonSubject)) { + val index = workList.indexOfFirst { + it.reasonSubject == notif.reasonSubject + } + if (index >= 0 && notif.reason == workList[index].reason) { + workList[index].notifications.add(notif) + workList[index].isRead = if (notif.isRead) true else workList[index].isRead + } else { + workList.add( + MutableNotificationsListItem( + notifications = mutableListOf(notif), + reason = notif.reason, + isRead = notif.isRead, + reasonSubject = notif.reasonSubject + ) + ) + } + } else if (notif.reasonSubject != null) { + seen.add(notif.reasonSubject!!) + workList.add( + MutableNotificationsListItem( + notifications = mutableListOf(notif), + reason = notif.reason, + isRead = notif.isRead, + reasonSubject = notif.reasonSubject + ) + ) + } else { + val index = workList.indexOfFirst { item-> + item.reason == notif.reason + } + if (index >= 0) { + workList[index].notifications.add(notif) + workList[index].isRead = if (notif.isRead) true else workList[index].isRead + } else { + workList.add( + MutableNotificationsListItem( + notifications = mutableListOf(notif), + reason = notif.reason, + isRead = notif.isRead, + reasonSubject = notif.reasonSubject + ) + ) + } + } + } + return workList.map { it.toImmutable() } +} + +fun List.filterNotifications( + filter: NotificationsFilterState, +): List { + return this.filter { + (if(it.isRead) filter.showAlreadyRead else true) && + when(it.reason) { + ListNotificationsReason.LIKE -> filter.showLikes + ListNotificationsReason.REPOST -> filter.showReposts + ListNotificationsReason.FOLLOW -> filter.showFollows + ListNotificationsReason.MENTION -> filter.showMentions + ListNotificationsReason.REPLY -> filter.showReplies + ListNotificationsReason.QUOTE -> filter.showQuotes + else -> true + } + }.toList() +} + +@Serializable +data class MutableNotificationsListItem( + val notifications: MutableList = mutableListOf(), + val reason: ListNotificationsReason, + var isRead: Boolean = false, + val reasonSubject: AtUri? = null, +) { + companion object { + fun fromImmutable(item: NotificationsListItem): MutableNotificationsListItem { + return MutableNotificationsListItem( + notifications = item.notifications.distinctBy { it.author.did }.toMutableList(), + reason = item.reason, + isRead = item.isRead, + reasonSubject = item.reasonSubject + ) + } + } + fun toImmutable(): NotificationsListItem { + return NotificationsListItem( + notifications = notifications.distinctBy { it.author.did }, + reason = reason, + isRead = isRead, + reasonSubject = reasonSubject + ) + } +} + +@Serializable +data class NotificationsListItem( + val notifications: List, + val reason: ListNotificationsReason, + val isRead: Boolean, + val reasonSubject: AtUri?, +) { + companion object { + fun fromMutable(item: MutableNotificationsListItem) { + NotificationsListItem( + notifications = item.notifications.distinctBy { it.author.did }, + reason = item.reason, + isRead = item.isRead, + reasonSubject = item.reasonSubject + ) + } + } + + override fun hashCode(): Int { + return notifications.hashCode() + reason.hashCode() + reasonSubject.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as NotificationsListItem + + if (reason != other.reason) return false + if (reasonSubject != other.reasonSubject) return false + if (notifications != other.notifications) return false + + return true + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/PreferencesRepository.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/PreferencesRepository.kt index f995c6d..c10bf68 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/PreferencesRepository.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/PreferencesRepository.kt @@ -1,48 +1,198 @@ package com.morpho.app.data -import app.bsky.actor.GetProfileQuery -import app.bsky.actor.PutPreferencesRequest -import com.morpho.app.model.bluesky.BskyPreferences -import com.morpho.app.model.bluesky.BskyUser -import com.morpho.app.model.bluesky.toPreferences -import com.morpho.app.model.bluesky.toProfile +import app.bsky.actor.PreferencesUnion import com.morpho.app.model.uistate.NotificationsFilterState -import com.morpho.butterfly.AtIdentifier -import com.morpho.butterfly.Butterfly +import com.morpho.butterfly.BskyPreferences +import com.morpho.butterfly.Did +import com.morpho.butterfly.Language import io.github.xxfast.kstore.KStore import io.github.xxfast.kstore.extensions.updatesOrEmpty import io.github.xxfast.kstore.file.extensions.listStoreOf -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import okio.Path.Companion.toPath import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import org.lighthousegames.logging.logging @Serializable data class BskyUserPreferences( - val user: BskyUser, + val did: Did, val preferences: BskyPreferences, val morphoPrefs: MorphoPreferences, ) +@Serializable +data class AccessibilityPreferences( + val requireAltText: Boolean? = false, + val displayLargerAltBadge: Boolean? = false, + val reduceMotion: Boolean? = false, + val disableAutoplay: Boolean? = false, + val disableHaptics: Boolean? = false, + val simpleUI: Boolean? = false, +) { + companion object { + fun update( + existing: AccessibilityPreferences, + new: AccessibilityPreferences, + ) : AccessibilityPreferences { + return AccessibilityPreferences( + requireAltText = new.requireAltText ?: existing.requireAltText, + displayLargerAltBadge = new.displayLargerAltBadge ?: existing.displayLargerAltBadge, + reduceMotion = new.reduceMotion ?: existing.reduceMotion, + disableAutoplay = new.disableAutoplay ?: existing.disableAutoplay, + disableHaptics = new.disableHaptics ?: existing.disableHaptics, + simpleUI = new.simpleUI ?: existing.simpleUI, + ) + } + fun toUpdate( + requireAltText: Boolean? = null, + displayLargerAltBadge: Boolean? = null, + reduceMotion: Boolean? = null, + disableAutoplay: Boolean? = null, + disableHaptics: Boolean? = null, + simpleUI: Boolean? = null, + ): AccessibilityPreferences { + return AccessibilityPreferences( + requireAltText = requireAltText, + displayLargerAltBadge = displayLargerAltBadge, + reduceMotion = reduceMotion, + disableAutoplay = disableAutoplay, + disableHaptics = disableHaptics, + simpleUI = simpleUI, + ) + } + } +} + +enum class DarkModeSetting { + SYSTEM, + LIGHT, + DARK, +} @Serializable data class MorphoPreferences( - val tabbed: Boolean = true, - val undecorated: Boolean = true, - val notificationsFilter: NotificationsFilterState = NotificationsFilterState(), -) + val tabbed: Boolean? = true, + val undecorated: Boolean? = true, + val kawaiiMode: Boolean? = true, + val uiLanguage: Language? = Language("en"), + val darkMode: DarkModeSetting? = DarkModeSetting.SYSTEM, + val notificationsFilter: NotificationsFilterPref? = NotificationsFilterPref(), + val accessibility: AccessibilityPreferences? = AccessibilityPreferences(), +): PreferencesUnion.ButterflyPreference() { + companion object { + fun update( + existing: MorphoPreferences, + new: MorphoPreferences, + ) : MorphoPreferences { + return MorphoPreferences( + tabbed = new.tabbed ?: existing.tabbed, + undecorated = new.undecorated ?: existing.undecorated, + kawaiiMode = new.kawaiiMode ?: existing.kawaiiMode, + notificationsFilter = new.notificationsFilter ?: existing.notificationsFilter, + accessibility = new.accessibility ?: existing.accessibility, + darkMode = new.darkMode ?: existing.darkMode, + uiLanguage = new.uiLanguage ?: existing.uiLanguage, + ) + } + + fun toUpdate( + tabbed: Boolean? = null, + undecorated: Boolean? = null, + kawaiiMode: Boolean? = null, + notificationsFilter: NotificationsFilterPref? = null, + accessibility: AccessibilityPreferences? = null, + darkMode: DarkModeSetting? = null, + uiLanguage: Language? = null, + ): MorphoPreferences { + return MorphoPreferences( + tabbed = tabbed, + undecorated = undecorated, + kawaiiMode = kawaiiMode, + notificationsFilter = notificationsFilter, + accessibility = accessibility, + darkMode = darkMode, + uiLanguage = uiLanguage, + ) + } + } +} + +@Serializable +data class NotificationsFilterPref( + val showAlreadyRead: Boolean? = true, + val showLikes: Boolean? = true, + val showReposts: Boolean? = true, + val showFollows: Boolean? = true, + val showMentions: Boolean? = true, + val showQuotes: Boolean? = true, + val showReplies: Boolean? = true, +) { + companion object { + fun update( + existing: NotificationsFilterPref, + new: NotificationsFilterPref, + ) : NotificationsFilterPref { + return NotificationsFilterPref( + showAlreadyRead = new.showAlreadyRead ?: existing.showAlreadyRead, + showLikes = new.showLikes ?: existing.showLikes, + showReposts = new.showReposts ?: existing.showReposts, + showFollows = new.showFollows ?: existing.showFollows, + showMentions = new.showMentions ?: existing.showMentions, + showQuotes = new.showQuotes ?: existing.showQuotes, + showReplies = new.showReplies ?: existing.showReplies, + ) + } + + fun toUpdate( + showAlreadyRead: Boolean? = null, + showLikes: Boolean? = null, + showReposts: Boolean? = null, + showFollows: Boolean? = null, + showMentions: Boolean? = null, + showQuotes: Boolean? = null, + showReplies: Boolean? = null, + ): NotificationsFilterPref { + return NotificationsFilterPref( + showAlreadyRead = showAlreadyRead, + showLikes = showLikes, + showReposts = showReposts, + showFollows = showFollows, + showMentions = showMentions, + showQuotes = showQuotes, + showReplies = showReplies, + ) + } + } + fun toNotificationsFilterState(): NotificationsFilterState { + return NotificationsFilterState( + showAlreadyRead = showAlreadyRead == true, + showLikes = showLikes == true, + showReposts = showReposts == true, + showFollows = showFollows == true, + showMentions = showMentions == true, + showQuotes = showQuotes == true, + showReplies = showReplies == true, + ) + } +} + class PreferencesRepository(storageDir: String): KoinComponent { - private val api: Butterfly by inject() private val _prefsStore: KStore> = listStoreOf( file = "$storageDir/preferences.json".toPath(), enableCache = true ) + val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + companion object { val log = logging() } @@ -50,123 +200,58 @@ class PreferencesRepository(storageDir: String): KoinComponent { val prefs: Flow?> get() = _prefsStore.updatesOrEmpty.distinctUntilChanged() - fun userPrefs(id: AtIdentifier): Flow = flow { - prefs.onEach { preferencesList -> - emit(preferencesList?.firstOrNull { p -> - (p.user.userDid == id.toString()) || (p.user.handle == id.toString()) - }) - } - }.distinctUntilChanged() - - - - //@NativeCoroutines - suspend fun getPreferences(id: AtIdentifier, pullRemote: Boolean = false): Result { - val result: Result = getFullPrefsLocal(id) - val newPrefs = if (result.isSuccess && pullRemote) { - val prefs = result.getOrNull() - if (prefs != null) { - pullPreferences(prefs.preferences) - } else { - pullPreferences(null) - } - } else if(pullRemote) { - pullPreferences(null) - } else { - result.map { it.preferences } - }.onSuccess { - val user = getUser(id).getOrNull() - if(result.isFailure) { - val profile = api.api.getProfile(GetProfileQuery(id)) - .getOrNull()?.toProfile() - if(profile != null) { - setPreferences(BskyUser.makeUser(profile), it) - } - } else { - setPreferences(user!!, it, result.getOrNull()!!.morphoPrefs) - } - } - return newPrefs + fun morphoPrefs(did: Did): Flow = userPrefs(did).map { + it?.morphoPrefs } - suspend fun getFullPrefs( - id: AtIdentifier, remote: Boolean = true - ): Result { - return if (remote) getFullPrefsRemote(id) - else getFullPrefsLocal(id) + fun userPrefs(did: Did): Flow = prefs.map { + it?.firstOrNull { prefs -> prefs.did == did } } - suspend fun getFullPrefsLocal(id: AtIdentifier): Result { - val prefs = prefs.firstOrNull()?.firstOrNull { - (it.user.userDid == id.toString()) || (it.user.handle == id.toString()) - } - return if (prefs != null) { - Result.success(prefs) - } else { - Result.failure(Exception("No preferences found for user $id")) - } + fun bskyPrefs(did: Did): Flow = userPrefs(did).map { + it?.preferences } - suspend fun getFullPrefsRemote(id: AtIdentifier): Result { - val prefs = prefs.firstOrNull()?.firstOrNull { - (it.user.userDid == id.toString()) || (it.user.handle == id.toString()) - } - return if (prefs != null) { - pullPreferences(prefs.preferences).map { - BskyUserPreferences(prefs.user, it, prefs.morphoPrefs) + suspend fun setMorphoPrefs(did: Did, prefs: MorphoPreferences) { + _prefsStore.update { + it?.toMutableList()?.apply { + val prefsIndex = it.indexOfFirst { user -> user.did == did } + if (prefsIndex != -1) { + val currentPrefs = this[prefsIndex] + this[prefsIndex] = currentPrefs.copy(morphoPrefs = prefs) + } else { + add(BskyUserPreferences(did, BskyPreferences(), prefs)) + } } - } else { - Result.failure(Exception("No preferences found for user $id")) - } - } - - suspend fun pullPreferences(p: BskyPreferences?): Result { - return if(p != null) api.api.getPreferences().map { it.toPreferences(p) } - else api.api.getPreferences().map { it.toPreferences() } - } - - - //@NativeCoroutines - suspend fun getPrefsLocal(id: AtIdentifier): Result { - val prefs = prefs.firstOrNull()?.firstOrNull { - (it.user.userDid == id.toString()) || (it.user.handle == id.toString()) - } - return if (prefs != null) { - Result.success(prefs.preferences) - } else { - Result.failure(Exception("No preferences found for user $id")) } } - //@NativeCoroutines - suspend fun getUser(id: AtIdentifier): Result { - val user = prefs.firstOrNull()?.firstOrNull { - (it.user.userDid == id.toString()) || (it.user.handle == id.toString()) - }?.user - return if (user != null) { - Result.success(user) - } else { - Result.failure(Exception("No user found for id $id")) + fun writePreferences(prefs: BskyUserPreferences) { + serviceScope.launch { + _prefsStore.update { + it?.toMutableList()?.apply { + val prefsIndex = it.indexOfFirst { user -> user.did == prefs.did } + if (prefsIndex != -1) { + this[prefsIndex] = prefs + } else { + add(prefs) + } + } + } } } - //@NativeCoroutines - suspend fun setPreferences(user: BskyUser, pref: BskyPreferences, morphoPrefs: MorphoPreferences = MorphoPreferences()) = coroutineScope { + suspend fun setBskyPreferences(did: Did, prefs: BskyPreferences) { _prefsStore.update { it?.toMutableList()?.apply { - val prefsIndex = it.indexOfFirst { user.userDid == user.userDid } + val prefsIndex = it.indexOfFirst { user -> user.did == did } if (prefsIndex != -1) { - this[prefsIndex] = BskyUserPreferences(user, pref, morphoPrefs) + val currentPrefs = this[prefsIndex] + this[prefsIndex] = currentPrefs.copy(preferences = prefs) } else { - add(BskyUserPreferences(user, pref, morphoPrefs)) + add(BskyUserPreferences(did, prefs, MorphoPreferences())) } } } } - - //@NativeCoroutines - suspend fun setPreferencesRemote(user: BskyUser, pref: BskyPreferences, morphoPrefs: MorphoPreferences = MorphoPreferences()) = coroutineScope { - setPreferences(user, pref, morphoPrefs) - api.api.putPreferences(PutPreferencesRequest(pref.toRemotePrefs())) - } } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/SharedImage.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/SharedImage.kt index 849d6a9..997672f 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/SharedImage.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/data/SharedImage.kt @@ -2,8 +2,7 @@ package com.morpho.app.data import androidx.compose.ui.graphics.ImageBitmap import app.bsky.embed.AspectRatio -import com.morpho.app.util.deserialize -import com.morpho.butterfly.Butterfly +import com.morpho.butterfly.ButterflyAgent import com.morpho.butterfly.model.Blob import io.github.vinceglb.filekit.core.PlatformFile import io.ktor.util.encodeBase64 @@ -56,10 +55,9 @@ constructor(override val descriptor: SerialDescriptor = PrimitiveSerialDescripto } -suspend fun imageToBlob(image: SharedImage, api: Butterfly): Blob? { +suspend fun imageToBlob(image: SharedImage, agent: ButterflyAgent): Blob? { val byteArray = image.toByteArray(targetSize = MAX_SIZE) ?: return null - val resp = api.api.uploadBlob(byteArray, image.mimeType).getOrNull() ?: return null - return Blob.serializer().deserialize(resp.blob) + return agent.uploadBlob(byteArray, image.mimeType).getOrNull() } fun fileExtToMimeType(filename: String): String { diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/di/AppModule.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/di/AppModule.kt index 1869ba1..935bfe9 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/di/AppModule.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/di/AppModule.kt @@ -1,18 +1,14 @@ package com.morpho.app.di +import com.morpho.app.data.ContentLabelService +import com.morpho.app.data.MorphoAgent import com.morpho.app.data.PollBlueService import com.morpho.app.data.PreferencesRepository -import com.morpho.app.model.uidata.BskyDataService -import com.morpho.app.model.uidata.BskyNotificationService -import com.morpho.app.model.uidata.ContentLabelService import com.morpho.app.screens.base.BaseScreenModel import com.morpho.app.screens.login.LoginScreenModel import com.morpho.app.screens.main.MainScreenModel import com.morpho.app.screens.main.tabbed.TabbedMainScreenModel -import com.morpho.app.screens.notifications.TabbedNotificationScreenModel -import com.morpho.app.screens.profile.TabbedProfileViewModel import com.morpho.app.util.ClipboardManager -import com.morpho.butterfly.Butterfly import com.morpho.butterfly.auth.SessionRepository import com.morpho.butterfly.auth.UserRepository import com.morpho.butterfly.auth.UserRepositoryImpl @@ -20,34 +16,45 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf -import org.koin.dsl.bind import org.koin.dsl.module val appModule = module { - single { BaseScreenModel() } - factory { MainScreenModel() } - factory { TabbedMainScreenModel() } - factory { TabbedProfileViewModel() } - factory { TabbedNotificationScreenModel() } - factory { LoginScreenModel() } + singleOf(::BaseScreenModel) + singleOf(::MainScreenModel) + singleOf(::TabbedMainScreenModel) + factoryOf(::LoginScreenModel) factory { p-> UpdateTick(p.get()) } + singleOf(::MorphoAgent) + singleOf(::ContentLabelService) + singleOf(::PollBlueService) + singleOf(::SessionRepository) + singleOf(::PreferencesRepository) + singleOf(::UserRepositoryImpl) { + bind() + } single { ClipboardManager } -} -val storageModule = module { - single { p-> SessionRepository(p.get()) } - single { p-> PreferencesRepository(p.get())} - singleOf(::UserRepositoryImpl) bind UserRepository::class } -val dataModule = module { - single { Butterfly() } - single { BskyDataService() } - single { BskyNotificationService() } - single { ContentLabelService() } - single { PollBlueService() } -} +//val storageModule = module { +// single { p-> SessionRepository(p.get()) } +// single { p-> PreferencesRepository(p.get())} +// singleOf(::UserRepositoryImpl) bind UserRepository::class +//} + +//val dataModule = module { +//// single { AtpAgent() } +//// single { ButterflyAgent() } +// single { MorphoAgent() } +// single { ContentLabelService() } +// single { PollBlueService() } +// //factory { p -> UserListPresenter(p.get()) } +// //factory { p -> UserFeedsPresenter(p.get()) } +// //factory> { p -> FeedPresenter(p.get()) } +//} @Suppress("MemberVisibilityCanBePrivate") public class UpdateTick(val millis: Long) { diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyFacet.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyFacet.kt index c01343a..b7ba40e 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyFacet.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyFacet.kt @@ -1,5 +1,6 @@ package com.morpho.app.model.bluesky +import androidx.compose.runtime.Immutable import app.bsky.richtext.* import com.atproto.label.SelfLabels import com.morpho.app.util.didCidToImageLink @@ -8,53 +9,66 @@ import com.morpho.butterfly.Did import com.morpho.butterfly.Handle import com.morpho.butterfly.Uri import com.morpho.butterfly.model.ReadOnlyList +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize import kotlinx.collections.immutable.toImmutableList import kotlinx.serialization.Serializable - +@Parcelize +@Immutable @Serializable data class BskyFacet( val start: Int, val end: Int, val facetType: List, -) +): Parcelable + +@Parcelize +@Immutable @Serializable -sealed interface FacetType { +sealed interface FacetType: Parcelable { + @Immutable @Serializable data class UserHandleMention( val handle: Handle, ) : FacetType + @Immutable @Serializable data class UserDidMention( val did: Did, ) : FacetType + @Immutable @Serializable data class ExternalLink( val uri: Uri, ) : FacetType - + @Immutable @Serializable data class Tag( val tag: String, ) : FacetType + @Immutable @Serializable data class PollBlueOption( val number: Int, ) : FacetType + @Immutable @Serializable data object PollBlueQuestion : FacetType + @Immutable @Serializable data class Format( val format: RichTextFormat ) : FacetType + @Immutable @Serializable data class BlueMoji( val did: Did, @@ -65,6 +79,7 @@ sealed interface FacetType { val labels: List? = null, ) : FacetType + @Immutable @Serializable data class UnknownFacet( val value: String, @@ -72,22 +87,32 @@ sealed interface FacetType { } +@Parcelize +@Immutable @Serializable -sealed interface BlueMojiImageLink { +sealed interface BlueMojiImageLink: Parcelable { val url: String val apng: Boolean val lottie: Boolean + @Immutable + @Serializable data class Png( override val url: String, override val apng: Boolean = false, override val lottie: Boolean = false ) : BlueMojiImageLink + + @Immutable + @Serializable data class Webp( override val url: String, override val apng: Boolean = false, override val lottie: Boolean = false ) : BlueMojiImageLink + + @Immutable + @Serializable data class Gif( override val url: String, override val apng: Boolean = false, @@ -96,6 +121,8 @@ sealed interface BlueMojiImageLink { } + +@Immutable @Serializable enum class RichTextFormat { BOLD, diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyLabel.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyLabel.kt index 7745bc6..aec1c8d 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyLabel.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyLabel.kt @@ -5,12 +5,23 @@ package com.morpho.app.model.bluesky import androidx.compose.runtime.Immutable import androidx.compose.ui.text.intl.Locale import app.bsky.actor.Visibility -import com.atproto.label.* +import com.atproto.label.Blurs +import com.atproto.label.DefaultSetting +import com.atproto.label.Label +import com.atproto.label.LabelValueDefinition +import com.atproto.label.LabelValues +import com.atproto.label.SelfLabel +import com.atproto.label.Severity +import com.morpho.app.model.uidata.MaybeMomentParceler import com.morpho.app.model.uidata.Moment +import com.morpho.app.model.uidata.MomentParceler import com.morpho.butterfly.AtUri import com.morpho.butterfly.Cid import com.morpho.butterfly.Did import com.morpho.butterfly.Language +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import dev.icerock.moko.parcelize.TypeParceler import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableMap import kotlinx.datetime.Clock @@ -20,6 +31,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.cbor.ByteString @OptIn(ExperimentalSerializationApi::class) +@Parcelize @Serializable @Immutable data class BskyLabel( @@ -29,12 +41,14 @@ data class BskyLabel( val cid: Cid?, val value: String, val overwritesPrevious: Boolean?, + @TypeParceler() val createdTimestamp: Moment, + @TypeParceler() val expirationTimestamp: Moment?, @OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) @ByteString val signature: ByteArray?, -) { +): Parcelable { override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false @@ -70,221 +84,26 @@ data class BskyLabel( return result } - fun getLabelValue(): LabelValue? { + fun getLabelValue(): LabelValues? { return when (value) { - LabelValue.PORN.value -> LabelValue.PORN - LabelValue.GORE.value -> LabelValue.GORE - LabelValue.NSFL.value -> LabelValue.NSFL - LabelValue.SEXUAL.value -> LabelValue.SEXUAL - LabelValue.GRAPHIC_MEDIA.value -> LabelValue.GRAPHIC_MEDIA - LabelValue.NUDITY.value -> LabelValue.NUDITY - LabelValue.DOXXING.value -> LabelValue.DOXXING - LabelValue.DMCA_VIOLATION.value -> LabelValue.DMCA_VIOLATION - LabelValue.NO_PROMOTE.value -> LabelValue.NO_PROMOTE - LabelValue.NO_UNAUTHENTICATED.value -> LabelValue.NO_UNAUTHENTICATED - LabelValue.WARN.value -> LabelValue.WARN - LabelValue.HIDE.value -> LabelValue.HIDE + LabelValues.PORN.value -> LabelValues.PORN + LabelValues.GORE.value -> LabelValues.GORE + LabelValues.NSFL.value -> LabelValues.NSFL + LabelValues.SEXUAL.value -> LabelValues.SEXUAL + LabelValues.GRAPHIC_MEDIA.value -> LabelValues.GRAPHIC_MEDIA + LabelValues.NUDITY.value -> LabelValues.NUDITY + LabelValues.DOXXING.value -> LabelValues.DOXXING + LabelValues.DMCA_VIOLATION.value -> LabelValues.DMCA_VIOLATION + LabelValues.NO_PROMOTE.value -> LabelValues.NO_PROMOTE + LabelValues.NO_UNAUTHENTICATED.value -> LabelValues.NO_UNAUTHENTICATED + LabelValues.WARN.value -> LabelValues.WARN + LabelValues.HIDE.value -> LabelValues.HIDE else -> null } } } -@Serializable -enum class LabelScope { - Content, - Media, - None, -} - -fun Blurs.toScope(): LabelScope { - return when (this) { - Blurs.CONTENT -> LabelScope.Content - Blurs.MEDIA -> LabelScope.Media - Blurs.NONE -> LabelScope.None - } -} - -@Serializable -enum class LabelAction { - Blur, - Alert, - Inform, - None -} - -@Serializable -enum class LabelTarget { - Account, - Profile, - Content -} - -@Serializable -open class ModBehaviour( - val profileList: LabelAction = LabelAction.None, - val profileView: LabelAction = LabelAction.None, - val avatar: LabelAction = LabelAction.None, - val banner: LabelAction = LabelAction.None, - val displayName: LabelAction = LabelAction.None, - val contentList: LabelAction = LabelAction.None, - val contentView: LabelAction = LabelAction.None, - val contentMedia: LabelAction = LabelAction.None, -) { - init { - require(avatar != LabelAction.Inform) - require(banner != LabelAction.Inform && banner != LabelAction.Alert) - require(displayName != LabelAction.Inform && displayName != LabelAction.Alert) - require(contentMedia != LabelAction.Inform && contentMedia != LabelAction.Alert) - } - - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as ModBehaviour - - if (profileList != other.profileList) return false - if (profileView != other.profileView) return false - if (avatar != other.avatar) return false - if (banner != other.banner) return false - if (displayName != other.displayName) return false - if (contentList != other.contentList) return false - if (contentView != other.contentView) return false - if (contentMedia != other.contentMedia) return false - - return true - } - - override fun hashCode(): Int { - var result = profileList.hashCode() - result = 31 * result + profileView.hashCode() - result = 31 * result + avatar.hashCode() - result = 31 * result + banner.hashCode() - result = 31 * result + displayName.hashCode() - result = 31 * result + contentList.hashCode() - result = 31 * result + contentView.hashCode() - result = 31 * result + contentMedia.hashCode() - return result - } -} - -@Serializable -data class ModBehaviours( - val account: ModBehaviour = ModBehaviour(), - val profile: ModBehaviour = ModBehaviour(), - val content: ModBehaviour = ModBehaviour(), -) { - fun forScope(scope: LabelScope, target: LabelTarget): List { - return when (target) { - LabelTarget.Account -> when (scope) { - LabelScope.Content -> listOf( - account.contentList, account.contentView, account.avatar, - account.banner, account.profileList, account.profileView, - account.displayName - ) - LabelScope.Media -> listOf(account.contentMedia, account.avatar, account.banner) - LabelScope.None -> listOf() - } - LabelTarget.Profile -> when (scope) { - LabelScope.Content -> listOf(profile.contentList, profile.contentView, profile.displayName) - LabelScope.Media -> listOf(profile.avatar, profile.banner, profile.contentMedia) - LabelScope.None -> listOf() - } - LabelTarget.Content -> when (scope) { - LabelScope.Content -> listOf(content.contentList, content.contentView) - LabelScope.Media -> listOf( - content.contentMedia, - content.avatar, - content.banner - ) - - LabelScope.None -> listOf() - } - } - } -} - @Immutable -@Serializable -open class DescribedBehaviours( - val behaviours: ModBehaviours, - val label: String, - val description: String, -){ - -} - - -@Serializable -data object BlockBehaviour: ModBehaviour( - profileList = LabelAction.Blur, - profileView = LabelAction.Blur, - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, -) - -data object MuteBehaviour: ModBehaviour( - profileList = LabelAction.Inform, - profileView = LabelAction.Alert, - contentList = LabelAction.Blur, - contentView = LabelAction.Inform, -) - -data object MuteWordBehaviour: ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, -) - -data object HideBehaviour: ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, -) - -data object InappropriateMediaBehaviour: ModBehaviour( - contentMedia = LabelAction.Blur, -) - -data object InappropriateAvatarBehaviour: ModBehaviour( - avatar = LabelAction.Blur, -) - -data object InappropriateBannerBehaviour: ModBehaviour( - banner = LabelAction.Blur, -) - -data object InappropriateDisplayNameBehaviour: ModBehaviour( - displayName = LabelAction.Blur, -) - -val BlurAllMedia = ModBehaviours( - content = InappropriateMediaBehaviour, - profile = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - contentMedia = LabelAction.Blur, - ), - account = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - contentMedia = LabelAction.Blur, - ), -) - - - -data object NoopBehaviour: ModBehaviour() - -@Serializable -enum class LabelValueDefFlag { - NoOverride, - Adult, - Unauthed, - NoSelf, -} - @Serializable enum class LabelSetting { @SerialName("ignore") @@ -293,6 +112,10 @@ enum class LabelSetting { WARN, @SerialName("hide") HIDE, + @SerialName("show") + SHOW, + @SerialName("inform") + INFORM, } fun DefaultSetting.toLabelSetting(): LabelSetting { @@ -300,37 +123,42 @@ fun DefaultSetting.toLabelSetting(): LabelSetting { DefaultSetting.IGNORE -> LabelSetting.IGNORE DefaultSetting.WARN -> LabelSetting.WARN DefaultSetting.HIDE -> LabelSetting.HIDE + DefaultSetting.SHOW -> LabelSetting.SHOW + DefaultSetting.INFORM -> LabelSetting.INFORM } } fun Visibility.toLabelSetting(): LabelSetting { return when (this) { - Visibility.SHOW -> LabelSetting.IGNORE + Visibility.SHOW -> LabelSetting.SHOW Visibility.WARN -> LabelSetting.WARN Visibility.HIDE -> LabelSetting.HIDE Visibility.IGNORE -> LabelSetting.IGNORE + Visibility.INFORM -> LabelSetting.INFORM } - } +@Parcelize @Serializable @Immutable data class BskyLabelDefinition( val identifier: String, val severity: Severity, - val whatToHide: LabelScope, + val whatToHide: Blurs, val defaultSetting: LabelSetting?, val adultOnly: Boolean?, val localizedName: String, val localizedDescription: String, val allDescriptions: ImmutableMap -) { +): Parcelable { fun getVisibility(): Visibility { return when(defaultSetting) { - LabelSetting.IGNORE -> Visibility.SHOW + LabelSetting.IGNORE -> Visibility.IGNORE LabelSetting.WARN -> Visibility.WARN LabelSetting.HIDE -> Visibility.HIDE + LabelSetting.SHOW -> Visibility.SHOW + LabelSetting.INFORM -> Visibility.INFORM null -> Visibility.IGNORE } } @@ -350,7 +178,7 @@ fun LabelValueDefinition.toModLabelDef() :BskyLabelDefinition { return BskyLabelDefinition( identifier = identifier, severity = severity, - whatToHide = blurs.toScope(), + whatToHide = blurs, defaultSetting = defaultSetting?.toLabelSetting(), adultOnly = adultOnly, localizedName = localizedDefString.name, @@ -360,12 +188,13 @@ fun LabelValueDefinition.toModLabelDef() :BskyLabelDefinition { } +@Parcelize @Serializable @Immutable data class LocalizedLabelDescription( val localizedName: String, val localizedDescription: String, -) +): Parcelable @Suppress("unused") fun Label.toLabel(): BskyLabel { diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyLabelService.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyLabelService.kt index ee9e359..776cc09 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyLabelService.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyLabelService.kt @@ -1,85 +1,78 @@ package com.morpho.app.model.bluesky import androidx.compose.runtime.Immutable -import app.bsky.actor.ProfileAssociated import app.bsky.labeler.LabelerView import app.bsky.labeler.LabelerViewDetailed +import com.morpho.app.data.MorphoAgent import com.morpho.app.model.uidata.Moment +import com.morpho.app.model.uidata.MomentParceler import com.morpho.app.util.mapImmutable import com.morpho.butterfly.AtUri import com.morpho.butterfly.Cid import com.morpho.butterfly.Did import com.morpho.butterfly.Handle +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import dev.icerock.moko.parcelize.TypeParceler import kotlinx.collections.immutable.persistentListOf -import kotlinx.datetime.Clock import kotlinx.serialization.Serializable +@Parcelize @Serializable @Immutable open class BskyLabelService( val uri: AtUri, val cid: Cid, - val creator: Profile?, + val creator: DetailedProfile?, val likeCount: Long?, val liked: Boolean, val likeUri: AtUri?, - override val indexedAt: Moment, + @TypeParceler() + val indexedAt: Moment, val policies: List, - override val labels: List, -): Profile { - override val did: Did + val labels: List, +): Parcelable { + val did: Did get() = creator?.did ?: Did("did:blank:did") - override val handle: Handle + val handle: Handle get() = creator?.handle ?: Handle("blank.handle") - override val displayName: String? + val displayName: String? get() = creator?.displayName - override val avatar: String? + val avatar: String? get() = creator?.avatar - override val mutedByMe: Boolean - get() = creator?.mutedByMe ?: false - override val mutedByList: UserListBasic? - get() = null - override val block: BlockRecord? - get() = null - override val blockedBy: Boolean - get() = false - override val blockingByList: UserListBasic? - get() = null - override val following: FollowRecord? - get() = null - override val followedBy: FollowRecord? - get() = null - override val numKnownFollowers: Long - get() = 0 - override val knownFollowers: List - get() = listOf() - override val associated: ProfileAssociated? - get() = null - override val createdAt: Moment? - get() = null - override val followingMe: Boolean - get() = false - override val followedByMe: Boolean - get() = false } -public data object BlueskyHardcodedLabeler: BskyLabelService( - uri = AtUri("at://morpho/builtin-labeler"), - cid = Cid("builtin-labeler"), - creator = null, - likeCount = 0, - liked = false, - likeUri = null, - indexedAt = Moment(Clock.System.now()), - policies = persistentListOf(), - labels = persistentListOf(), -) - -fun LabelerViewDetailed.toLabelService(): BskyLabelService { +suspend fun LabelerViewDetailed.toLabelService( + agent: MorphoAgent, +): BskyLabelService { + val fullProfile = agent.getProfile(this.creator.did).getOrNull()?.toProfile() ?: DetailedProfile( + did = this.creator.did, + handle = this.creator.handle, + displayName = this.creator.displayName, + description = this.creator.description, + avatar = this.creator.avatar, + banner = null, + followersCount = 0, + followsCount = 0, + postsCount = 0, + labels = this.creator.labels.map { it.toLabel() }, + indexedAt = this.creator.indexedAt?.let { Moment(it) }, + mutedByMe = false, + following = this.creator.viewer?.following?.let { FollowRecord(it) }, + followedBy = this.creator.viewer?.followedBy?.let { FollowRecord(it) }, + numKnownFollowers = 0, + knownFollowers = persistentListOf(), + associated = this.creator.associated?.toBskyProfileAssociated(), + createdAt = this.creator.createdAt?.let { Moment(it) }, + mutedByList = this.creator.viewer?.mutedByList?.toList(), + block = this.creator.viewer?.blocking?.let { BlockRecord(it) }, + blockedBy = this.creator.viewer?.blockedBy == true, + blockingByList = this.creator.viewer?.blockingByList?.toList(), + ) return BskyLabelService( uri = this.uri, cid = this.cid, - creator = this.creator.toProfile(), + creator = fullProfile, likeCount = this.likeCount, liked = (this.viewer?.like != null), likeUri = this.viewer?.like, @@ -89,11 +82,113 @@ fun LabelerViewDetailed.toLabelService(): BskyLabelService { ) } -fun LabelerView.toLabelService(): BskyLabelService { +suspend fun LabelerView.toLabelService( + agent: MorphoAgent? = null, +): BskyLabelService { + val fullProfile = agent?.getProfile(this.creator.did)?.getOrNull()?.toProfile() ?: DetailedProfile( + did = this.creator.did, + handle = this.creator.handle, + displayName = this.creator.displayName, + description = this.creator.description, + avatar = this.creator.avatar, + banner = null, + followersCount = 0, + followsCount = 0, + postsCount = 0, + labels = this.creator.labels.map { it.toLabel() }, + indexedAt = this.creator.indexedAt?.let { Moment(it) }, + mutedByMe = false, + following = this.creator.viewer?.following?.let { FollowRecord(it) }, + followedBy = this.creator.viewer?.followedBy?.let { FollowRecord(it) }, + numKnownFollowers = 0, + knownFollowers = persistentListOf(), + associated = this.creator.associated?.toBskyProfileAssociated(), + createdAt = this.creator.createdAt?.let { Moment(it) }, + mutedByList = this.creator.viewer?.mutedByList?.toList(), + block = this.creator.viewer?.blocking?.let { BlockRecord(it) }, + blockedBy = this.creator.viewer?.blockedBy == true, + blockingByList = this.creator.viewer?.blockingByList?.toList(), + ) + return BskyLabelService( + uri = this.uri, + cid = this.cid, + creator = fullProfile, + likeCount = this.likeCount, + liked = (this.viewer?.like != null), + likeUri = this.viewer?.like, + indexedAt = Moment(this.indexedAt), + policies = persistentListOf(), + labels = this.labels.mapImmutable { it.toLabel() }, + ) +} + +fun LabelerViewDetailed.toLabelServiceLocal(): BskyLabelService { + val fullProfile = DetailedProfile( + did = this.creator.did, + handle = this.creator.handle, + displayName = this.creator.displayName, + description = this.creator.description, + avatar = this.creator.avatar, + banner = null, + followersCount = 0, + followsCount = 0, + postsCount = 0, + labels = this.creator.labels.map { it.toLabel() }, + indexedAt = this.creator.indexedAt?.let { Moment(it) }, + mutedByMe = false, + following = this.creator.viewer?.following?.let { FollowRecord(it) }, + followedBy = this.creator.viewer?.followedBy?.let { FollowRecord(it) }, + numKnownFollowers = 0, + knownFollowers = persistentListOf(), + associated = this.creator.associated?.toBskyProfileAssociated(), + createdAt = this.creator.createdAt?.let { Moment(it) }, + mutedByList = this.creator.viewer?.mutedByList?.toList(), + block = this.creator.viewer?.blocking?.let { BlockRecord(it) }, + blockedBy = this.creator.viewer?.blockedBy == true, + blockingByList = this.creator.viewer?.blockingByList?.toList(), + ) + return BskyLabelService( + uri = this.uri, + cid = this.cid, + creator = fullProfile, + likeCount = this.likeCount, + liked = (this.viewer?.like != null), + likeUri = this.viewer?.like, + indexedAt = Moment(this.indexedAt), + policies = persistentListOf(), + labels = this.labels.mapImmutable { it.toLabel() }, + ) +} + +fun LabelerView.toLabelServiceLocal(): BskyLabelService { + val fullProfile = DetailedProfile( + did = this.creator.did, + handle = this.creator.handle, + displayName = this.creator.displayName, + description = this.creator.description, + avatar = this.creator.avatar, + banner = null, + followersCount = 0, + followsCount = 0, + postsCount = 0, + labels = this.creator.labels.map { it.toLabel() }, + indexedAt = this.creator.indexedAt?.let { Moment(it) }, + mutedByMe = false, + following = this.creator.viewer?.following?.let { FollowRecord(it) }, + followedBy = this.creator.viewer?.followedBy?.let { FollowRecord(it) }, + numKnownFollowers = 0, + knownFollowers = persistentListOf(), + associated = this.creator.associated?.toBskyProfileAssociated(), + createdAt = this.creator.createdAt?.let { Moment(it) }, + mutedByList = this.creator.viewer?.mutedByList?.toList(), + block = this.creator.viewer?.blocking?.let { BlockRecord(it) }, + blockedBy = this.creator.viewer?.blockedBy == true, + blockingByList = this.creator.viewer?.blockingByList?.toList(), + ) return BskyLabelService( uri = this.uri, cid = this.cid, - creator = this.creator.toProfile(), + creator = fullProfile, likeCount = this.likeCount, liked = (this.viewer?.like != null), likeUri = this.viewer?.like, diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyList.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyList.kt index 684add7..4459b64 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyList.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyList.kt @@ -6,16 +6,21 @@ import app.bsky.graph.ListView import app.bsky.graph.ListViewBasic import app.bsky.graph.ListViewerState import com.morpho.app.model.uidata.Moment +import com.morpho.app.model.uidata.MomentParceler import com.morpho.app.util.mapImmutable import com.morpho.butterfly.* import com.morpho.butterfly.model.ReadOnlyList +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import dev.icerock.moko.parcelize.TypeParceler import kotlinx.collections.immutable.toImmutableList import kotlinx.datetime.Clock import kotlinx.serialization.Serializable +@Parcelize @Serializable @Immutable -sealed interface BskyList { +sealed interface BskyList: Parcelable { val uri: AtUri val cid: Cid val purpose: ListType @@ -28,7 +33,7 @@ sealed interface BskyList { - +@Parcelize @Serializable @Immutable data class UserList( @@ -42,6 +47,7 @@ data class UserList( override val avatar: String? = null, override val viewerMuted: Boolean, override val viewerBlocked: AtUri? = null, + @TypeParceler() override val indexedAt: Moment, val labels: List = listOf(), val listItems: List = listOf(), @@ -86,6 +92,7 @@ data class UserListBasic( override val avatar: String? = null, override val viewerMuted: Boolean, override val viewerBlocked: AtUri? = null, + @TypeParceler() override val indexedAt: Moment, ): BskyList { override fun equals(other: Any?) : Boolean { diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyNotification.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyNotification.kt index 22cc5ab..7296066 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyNotification.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyNotification.kt @@ -3,7 +3,6 @@ package com.morpho.app.model.bluesky import androidx.compose.runtime.Immutable import app.bsky.feed.Like import app.bsky.feed.Post -import app.bsky.feed.PostReplyRef import app.bsky.feed.Repost import app.bsky.graph.Follow import app.bsky.notification.ListNotificationsNotification @@ -207,6 +206,3 @@ fun ListNotificationsNotification.toBskyNotification() : BskyNotification { } } -fun PostReplyRef.toReply() { - -} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPost.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPost.kt index eebf817..0cb6c88 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPost.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPost.kt @@ -3,23 +3,20 @@ package com.morpho.app.model.bluesky import androidx.compose.runtime.Immutable import app.bsky.feed.* import com.morpho.app.model.uidata.Moment +import com.morpho.app.model.uidata.MomentParceler import com.morpho.app.util.deserialize import com.morpho.app.util.mapImmutable import com.morpho.butterfly.AtUri import com.morpho.butterfly.Cid import com.morpho.butterfly.Language +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import dev.icerock.moko.parcelize.TypeParceler import kotlinx.collections.immutable.persistentListOf import kotlinx.datetime.Clock import kotlinx.serialization.Serializable -@Serializable -enum class PostType { - BlockedThread, - NotFoundThread, - VisibleThread, - BskyPost, -} - +@Parcelize @Serializable @Immutable data class BskyPost ( @@ -31,12 +28,14 @@ data class BskyPost ( val facets: List = listOf(), @Serializable val tags: List = listOf(), + @TypeParceler() val createdAt: Moment, @Serializable val feature: BskyPostFeature? = null, val replyCount: Long, val repostCount: Long, val likeCount: Long, + @TypeParceler() val indexedAt: Moment, val reposted: Boolean, val repostUri: AtUri? = null, @@ -48,8 +47,8 @@ data class BskyPost ( val reason: BskyPostReason? = null, @Serializable val langs: List = listOf(), -) { - override operator fun equals(other: Any?) : Boolean { +): Parcelable { + override fun equals(other: Any?) : Boolean { return when(other) { null -> false is Cid -> other == cid @@ -80,7 +79,7 @@ data class BskyPost ( is Cid -> other == cid is AtUri -> other == uri is BskyPost -> other.cid == cid - else -> reply?.parent?.contains(other) == true + else -> reply?.parentPost?.contains(other) == true } } @@ -116,17 +115,7 @@ fun PostView.toPost(): BskyPost { } fun ThreadViewPost.toPost() : BskyPost { - val replyRef = when (parent) { - is ThreadViewPostParentUnion.BlockedPost -> null - is ThreadViewPostParentUnion.NotFoundPost -> null - is ThreadViewPostParentUnion.ThreadViewPost -> { - val parentPost = (parent as ThreadViewPostParentUnion.ThreadViewPost).value.toPost() - val rootPost = findRootPost()?.toPost() ?: parentPost - BskyPostReply(root = rootPost, parent = parentPost, grandparentAuthor = parentPost.reply?.parent?.author) - } - null -> null - } - return post.toPost(reply = replyRef, reason = null) + return post.toPost() } fun ThreadViewPost.findRootPost(): ThreadViewPost? { @@ -156,6 +145,7 @@ fun PostView.toPost( Post.serializer().deserialize(record) } catch (e: Exception) { Post( + text = "Error deserializing post: $e\n" + "Record: $record", facets = persistentListOf(), @@ -164,6 +154,12 @@ fun PostView.toPost( langs = persistentListOf(), ) } + // copy in the replyRef if it's not already there + val replyRef = reply?.copy( + replyRef = postRecord.reply?.toReplyRef(), + grandParentAuthor = reply.grandParentAuthor ?: + postRecord.reply?.grandParentAuthor?.toProfile() + ) ?: postRecord.reply?.toReply() return BskyPost( uri = uri, @@ -184,7 +180,7 @@ fun PostView.toPost( likeUri = viewer?.like, labels = labels.mapImmutable { it.toLabel() }, langs = postRecord.langs.mapImmutable { it }, - reply = reply, + reply = replyRef, reason = reason, ) } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostFeature.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostFeature.kt index 362fb8c..de81e01 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostFeature.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostFeature.kt @@ -1,30 +1,55 @@ package com.morpho.app.model.bluesky -import app.bsky.embed.* +import androidx.compose.runtime.Immutable +import app.bsky.embed.AspectRatio +import app.bsky.embed.ExternalView +import app.bsky.embed.ImagesView +import app.bsky.embed.RecordViewRecordUnion +import app.bsky.embed.RecordWithMediaViewMediaUnion +import app.bsky.embed.VideoCaption +import app.bsky.embed.VideoView +import app.bsky.embed.VideoViewVideo import app.bsky.feed.Post import app.bsky.feed.PostEmbedUnion import app.bsky.feed.PostViewEmbedUnion +import com.morpho.app.CommonRawValue import com.morpho.app.model.uidata.Moment +import com.morpho.app.model.uidata.MomentParceler import com.morpho.app.util.mapImmutable -import com.morpho.butterfly.* +import com.morpho.butterfly.AtUri +import com.morpho.butterfly.Cid +import com.morpho.butterfly.Did +import com.morpho.butterfly.Uri +import com.morpho.butterfly.deserialize import com.morpho.butterfly.model.Blob +import dev.icerock.moko.parcelize.Parcel +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parceler +import dev.icerock.moko.parcelize.Parcelize +import dev.icerock.moko.parcelize.TypeParceler import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement +@Parcelize +@Immutable @Serializable -sealed interface BskyPostFeature { +sealed interface BskyPostFeature: Parcelable { + @Immutable @Serializable data class ImagesFeature( val images: List, ) : BskyPostFeature, TimelinePostMedia + @Immutable @Serializable data class VideoFeature( val video: VideoEmbed, val alt: String, + @TypeParceler() val aspectRatio: AspectRatio?, ) : BskyPostFeature, TimelinePostMedia + @Immutable @Serializable data class ExternalFeature( val uri: Uri, @@ -33,55 +58,69 @@ sealed interface BskyPostFeature { val thumb: String?, ) : BskyPostFeature, TimelinePostMedia + @Immutable @Serializable data class RecordFeature( val record: EmbedRecord, ) : BskyPostFeature + @Immutable @Serializable data class MediaRecordFeature( val record: EmbedRecord, val media: TimelinePostMedia, ) : BskyPostFeature + @Immutable @Serializable data class UnknownEmbed( val value: String, ) : BskyPostFeature, TimelinePostMedia } +@Parcelize +@Immutable @Serializable -sealed interface TimelinePostMedia +sealed interface TimelinePostMedia: Parcelable +@Parcelize +@Immutable @Serializable -sealed interface VideoEmbed +sealed interface VideoEmbed: Parcelable +@Immutable @Serializable data class EmbedVideoView( - val cid: Cid, + val cid: Cid, val playlist: AtUri, val thumbnail: AtUri, ): VideoEmbed +@Immutable @Serializable data class EmbedVideo( val blob: Blob, val captions: List?, ): VideoEmbed +@Parcelize +@Immutable @Serializable data class EmbedImage( val thumb: String, val fullsize: String, val alt: String, val aspectRatio: AspectRatio? = null, -) +): Parcelable +@Parcelize +@Immutable @Serializable -sealed interface EmbedRecord { +sealed interface EmbedRecord: Parcelable { + @Immutable @Serializable data class VisibleEmbedPost( val uri: AtUri, @@ -92,6 +131,7 @@ sealed interface EmbedRecord { val reference: Reference = Reference(uri, cid) } + @Immutable @Serializable data class EmbedFeed( val uri: AtUri, @@ -101,6 +141,7 @@ sealed interface EmbedRecord { val feed: FeedGenerator, ) : EmbedRecord + @Immutable @Serializable data class EmbedList( val uri: AtUri, @@ -109,6 +150,7 @@ sealed interface EmbedRecord { val list: BskyList, ) : EmbedRecord + @Immutable @Serializable data class EmbedLabelService( val uri: AtUri, @@ -118,21 +160,25 @@ sealed interface EmbedRecord { ) : EmbedRecord + @Immutable @Serializable data class InvisibleEmbedPost( val uri: AtUri, ) : EmbedRecord + @Immutable @Serializable data class BlockedEmbedPost( val uri: AtUri, ) : EmbedRecord + @Immutable @Serializable data class DetachedQuotePost( val uri: AtUri, ) : EmbedRecord + @Immutable @Serializable data class EmbedVideo( val video: VideoEmbed, @@ -140,16 +186,20 @@ sealed interface EmbedRecord { val aspectRatio: AspectRatio?, ) : EmbedRecord + @Immutable @Serializable data class UnknownEmbed( val value: String, ) : EmbedRecord + @Immutable + @Serializable data class StarterPack( val uri: AtUri, val cid: Cid, - val record: JsonElement, + val record: @CommonRawValue JsonElement, val creator: Profile, + @TypeParceler() val indexedAt: Moment, val labels: List, ) : EmbedRecord @@ -294,7 +344,7 @@ private fun RecordViewRecordUnion.toEmbedRecord(): EmbedRecord { uri = value.uri, cid = value.cid, author = value.creator.toProfile(), - labelService = value.toLabelService(), + labelService = value.toLabelServiceLocal(), ) } @@ -476,4 +526,22 @@ private fun VideoViewVideo.toEmbedVideoFeature(): BskyPostFeature.VideoFeature { alt = this.alt?:"", aspectRatio = this.aspectRatio, ) +} + +object MaybeAspectRatioParceler : Parceler { + override fun create(parcel: Parcel): AspectRatio? { + val moment = parcel.readString() + val width = moment?.substringAfter("w:")?.substringBefore("h:")?.toLongOrNull() + val height = moment?.substringAfter("h:")?.substringBefore("w:")?.toLongOrNull() + return if(width != null && height != null) { + AspectRatio(width, height) + } else { + null + } + } + + override fun AspectRatio?.write(parcel: Parcel, flags: Int) { + parcel.writeString("w:${this?.width}") + parcel.writeString("h:${this?.height}") + } } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostReason.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostReason.kt index f442a4c..6f1770e 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostReason.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostReason.kt @@ -1,24 +1,35 @@ package com.morpho.app.model.bluesky +import androidx.compose.runtime.Immutable import app.bsky.feed.FeedViewPostReasonUnion import app.bsky.feed.SkeletonFeedPostReasonUnion import com.morpho.app.model.uidata.Moment +import com.morpho.app.model.uidata.MomentParceler import com.morpho.butterfly.AtUri +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import dev.icerock.moko.parcelize.TypeParceler import kotlinx.serialization.Serializable +@Parcelize +@Immutable @Serializable -sealed interface BskyPostReason { +sealed interface BskyPostReason: Parcelable { + @Immutable @Serializable data class BskyPostRepost( val repostAuthor: Profile, + @TypeParceler() val indexedAt: Moment, ) : BskyPostReason + @Immutable @Serializable data class BskyPostFeedPost( val repost: AtUri ) : BskyPostReason + @Immutable @Serializable data class SourceFeed( val feed: FeedGenerator diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostReply.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostReply.kt index a14849c..77b0705 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostReply.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostReply.kt @@ -2,34 +2,81 @@ package com.morpho.app.model.bluesky import androidx.compose.runtime.Immutable import app.bsky.actor.ProfileViewBasic +import app.bsky.feed.GetPostsQuery +import app.bsky.feed.PostReplyRef import app.bsky.feed.ReplyRef import app.bsky.feed.ReplyRefParentUnion import app.bsky.feed.ReplyRefRootUnion +import com.atproto.repo.StrongRef +import com.morpho.app.data.MorphoAgent +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import kotlinx.collections.immutable.persistentListOf import kotlinx.serialization.Serializable +@Parcelize @Immutable @Serializable data class BskyPostReply( - val root: BskyPost?, - val parent: BskyPost?, - val grandparentAuthor: Profile? -) + val rootPost: BskyPost? = null, + val parentPost: BskyPost? = null, + val grandParentAuthor: Profile? = null, + val replyRef: BskyPostReplyRef? = null +): Parcelable fun ReplyRef.toReply(): BskyPostReply { return BskyPostReply( - root = when (val root = root) { + rootPost = when (val root = root) { is ReplyRefRootUnion.BlockedPost -> null is ReplyRefRootUnion.NotFoundPost -> null is ReplyRefRootUnion.PostView -> root.value.toPost() }, - parent = when (val parent = parent) { + parentPost = when (val parent = parent) { is ReplyRefParentUnion.BlockedPost -> null is ReplyRefParentUnion.NotFoundPost -> null is ReplyRefParentUnion.PostView -> parent.value.toPost() }, - grandparentAuthor = when (val grandparentAuthor = grandparentAuthor) { - is ProfileViewBasic -> grandparentAuthor.toProfile() + grandParentAuthor = when (val grandParentAuthor = this.grandparentAuthor) { + is ProfileViewBasic -> grandParentAuthor.toProfile() else -> null } ) } + +@Parcelize +@Immutable +@Serializable +public data class BskyPostReplyRef( + public val root: StrongRef, + public val parent: StrongRef, + public val grandParentAuthor: Profile? = null, +): Parcelable + +fun PostReplyRef.toReplyRef(): BskyPostReplyRef { + return BskyPostReplyRef( + root = this.root, + parent = this.parent, + grandParentAuthor = this.grandParentAuthor?.toProfile() + ) +} + +fun PostReplyRef.toReply(): BskyPostReply { + val replyRef = this.toReplyRef() + return BskyPostReply( + replyRef = replyRef, + grandParentAuthor = replyRef.grandParentAuthor + ) +} + +suspend fun PostReplyRef.hydratedReply(agent: MorphoAgent): BskyPostReply { + val parents = agent.api.getPosts(GetPostsQuery(persistentListOf(this.parent.uri, this.root.uri))) + .getOrNull()?.posts?.map { it.toPost() } ?: persistentListOf() + val grandparent = if (parents.first().reply?.replyRef?.parent?.uri != null) { + agent.api.getPosts(GetPostsQuery(persistentListOf(parents.first().reply?.replyRef?.parent?.uri!!))).getOrNull()?.posts?.firstOrNull() + } else null + return BskyPostReply( + rootPost = parents.firstOrNull { it.cid == this.root.cid }, + parentPost = parents.firstOrNull { it.cid == this.parent.cid }, + grandParentAuthor = this.grandParentAuthor?.toProfile() ?: grandparent?.author?.toProfile(), + ) +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostThread.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostThread.kt index 0a56a50..8e7ead9 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostThread.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPostThread.kt @@ -1,8 +1,8 @@ @file:Suppress("MemberVisibilityCanBePrivate") package com.morpho.app.model.bluesky - import androidx.compose.runtime.Immutable +import androidx.compose.ui.util.fastForEachIndexed import app.bsky.feed.ThreadViewPost import app.bsky.feed.ThreadViewPostParentUnion import app.bsky.feed.ThreadViewPostReplyUnion @@ -10,18 +10,22 @@ import com.morpho.app.model.bluesky.ThreadPost.* import com.morpho.app.util.mapImmutable import com.morpho.butterfly.AtUri import com.morpho.butterfly.Cid +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.serialization.Serializable +@Parcelize @Immutable @Serializable data class BskyPostThread( val post: BskyPost, - val parents: List, + val parent: ThreadPost? = null, val replies: List, -) { +): Parcelable { + val parents: List = if(parent != null) listOf(parent) + parent.parents() else listOf() + operator fun contains(other: Any?) : Boolean { when(other) { null -> return false @@ -29,13 +33,7 @@ data class BskyPostThread( is AtUri -> return other == post.uri is BskyPost -> return other.cid == post.cid else -> { - parents.map { - return when(it) { - is BlockedPost -> false - is NotFoundPost -> false - is ViewablePost -> it.contains(other) - } - } + replies.map { return when(it) { is BlockedPost -> false @@ -47,19 +45,113 @@ data class BskyPostThread( } return false } -} + fun anyMutedOrBlocked(): Boolean { + return this.post.author.mutedByMe || this.post.author.blocking + || this.post.author.blockedBy || this.replies.any { it.anyMutedOrBlocked() } + || this.parents.any { it.anyMutedOrBlocked() } + } + + fun containsWord(word: String): Boolean { + return this.post.text.contains(word, ignoreCase = true) + || this.replies.any { it.containsWord(word) } + || this.parents.any { it.containsWord(word) } + } + + fun getLabels(): List { + return this.post.labels + this.replies.flatMap { it.getLabels() } + } + + fun containsLabel(label: String): Boolean { + return this.post.labels.any { it.value == label } + || this.replies.any { it.containsLabel(label) } + || this.parents.any { it.containsLabel(label) } + } + fun filterReplies(filter: (ThreadPost) -> Boolean): BskyPostThread { + val threadReplies: MutableList = this.replies.toMutableList() + threadReplies.fastForEachIndexed { index, reply -> + if (reply != null && filter(reply)) { + threadReplies[index] = null + } else { + if (reply is ViewablePost) { + threadReplies[index] = reply.copy( + replies = reply.replies.filterNot { filter(it) } + ) + } + } + } + return BskyPostThread( + post = post, + parent = parent, + replies = threadReplies.filterNotNull() + ) + } + + fun addReply(reply: ViewablePost): BskyPostThread { + if(reply.uri == post.uri) return BskyPostThread( + post = post, + parent = if(reply.parent is ViewablePost && parent !is ViewablePost) + reply.parent else parent, + replies = replies.distinctBy { it.uri }.filterNot { it.uri == reply.uri || it.uri == post.uri }, + ) + reply.post.reply?.parentPost?.uri ?: return this + reply.post.reply.rootPost?.uri ?: return this + val threadReplies = this.replies.toMutableList() + val inParents = this.parents.any { it.uri == reply.uri } + val inReplies = this.replies.firstOrNull { it.uri == reply.uri } + return BskyPostThread( + post = post, + parent = if (!inParents) parent else if (inReplies != null) { + if (inReplies is ViewablePost) parent?.addReply(inReplies) else parent + } else parent, + replies = threadReplies.distinctBy { it.uri }.filterNot { it.uri == reply.uri || it.uri == post.uri }, + ) + } + + fun addReply(reply: BskyPost): BskyPostThread { + if(reply.uri == post.uri) return BskyPostThread( + post = post, + parent = parent, + replies = replies.distinctBy { it.uri }.filterNot { it.uri == reply.uri || it.uri == post.uri }, + ) + reply.reply?.parentPost?.uri ?: return this + reply.reply.rootPost?.uri ?: return this + val threadReplies = this.replies.toMutableList() + val inParents = this.parents.any { it.uri == reply.uri } + val inReplies = this.replies.any { it.uri == reply.uri } + val inAnyParentReplies = this.parents.any { + if(it is ViewablePost) it.replies.any { it.uri == reply.uri } else false + } + if(!inReplies && !inParents && !inAnyParentReplies) { + threadReplies.add(reply.toThreadPost()) + } else if(!inParents && inAnyParentReplies) { + parent?.addReply(reply) + } + val newThread = BskyPostThread( + post = post, + parent = parent, + replies = threadReplies.distinctBy { it.uri }, + ) + return newThread + } +} + +@Parcelize @Immutable @Serializable -sealed interface ThreadPost { +sealed interface ThreadPost:Parcelable { + val uri: AtUri? @Immutable @Serializable data class ViewablePost( val post: BskyPost, + val parent: ThreadPost? = null, val replies: List = persistentListOf(), ) : ThreadPost { + override val uri: AtUri + get() = post.uri override fun equals(other: Any?) : Boolean { return when(other) { null -> false @@ -69,6 +161,7 @@ sealed interface ThreadPost { } } + operator fun contains(other: Any?) : Boolean { when(other) { is Cid -> { @@ -130,10 +223,19 @@ sealed interface ThreadPost { } } + fun parents(): List { + val parent = when(this) { + is ViewablePost -> this.parent + is BlockedPost -> null + is NotFoundPost -> null + } + return if(parent != null) listOf(parent) + parent.parents() else listOf() + } + @Immutable @Serializable data class NotFoundPost( - val uri: AtUri? = null, + override val uri: AtUri? = null, ) : ThreadPost { override fun equals(other: Any?) : Boolean { if (other is AtUri) return uri == other @@ -148,7 +250,7 @@ sealed interface ThreadPost { @Immutable @Serializable data class BlockedPost( - val uri: AtUri? = null, + override val uri: AtUri? = null, ) : ThreadPost { override fun equals(other: Any?) : Boolean { if (other is AtUri) return uri == other @@ -160,60 +262,132 @@ sealed interface ThreadPost { } } + fun anyMutedOrBlocked(): Boolean { + return when(this) { + is ViewablePost -> this.post.author.mutedByMe || this.post.author.blocking + || this.post.author.blockedBy || this.replies.any { it.anyMutedOrBlocked() } -} + is BlockedPost -> true + is NotFoundPost -> true + } + } -fun ThreadViewPost.toThread(): BskyPostThread { - val parents = when(parent) { - is ThreadViewPostParentUnion.ThreadViewPost -> { - (parent as ThreadViewPostParentUnion.ThreadViewPost).value.findParentChain() + fun containsLabel(label: String): Boolean { + return when(this) { + is ViewablePost -> this.post.labels.any { it.value == label } + || this.replies.any { it.containsLabel(label) } + is BlockedPost -> false + is NotFoundPost -> false } - else -> persistentListOf() } - if (parents.isEmpty()) { - return BskyPostThread( - post = post.toPost(), - parents = persistentListOf(), - replies = replies.mapImmutable { it.toThreadPost(post.toPost(), post.toPost()) } - ) - } else { - val rootPost = parents.last().toPost() - val entryPost = this.post.toPost(BskyPostReply(parents.first().toPost(), rootPost, parents.first().toPost().reply?.parent?.author), null) - return BskyPostThread( - post = entryPost, - parents = parents.mapIndexed { index, post -> - post.toThreadPost( - if(index == parents.lastIndex) { - post.toPost() - } else { - parents[index + 1].toPost() - }, - rootPost - ) - }.reversed().toImmutableList(), - replies = replies.mapImmutable { reply -> reply.toThreadPost(entryPost, rootPost) }, - ) + + fun getLabels(): List { + return when(this) { + is ViewablePost -> this.post.labels + this.replies.flatMap { it.getLabels() } + is BlockedPost -> listOf() + is NotFoundPost -> listOf() + } + } + + fun containsWord(word: String): Boolean { + return when(this) { + is ViewablePost -> this.post.text.contains(word, ignoreCase = true) + || this.replies.any { it.containsWord(word) } + is BlockedPost -> false + is NotFoundPost -> false + } + } + + fun addParentReply(reply: BskyPost): ThreadPost { + return if(this !is ViewablePost) this + else if(this.parent == null) this + else if(reply.reply?.parentPost?.uri == this.parent.uri) { + this.parent.addReply(reply) + } else this.parent.addParentReply(reply) + } + + + fun addReply(reply: BskyPost): ThreadPost { + return addReply(ViewablePost(reply)) } + + fun addReply(reply: ViewablePost): ThreadPost { + return when(this) { + is ViewablePost -> ViewablePost(post, parent, (replies + reply).distinctBy { it.uri }) + is BlockedPost -> BlockedPost(uri) + is NotFoundPost -> NotFoundPost(uri) + } + } + + fun hasReplies(): Boolean { + return when(this) { + is ViewablePost -> replies.isNotEmpty() + else -> false + } + } + } -fun ThreadViewPost.toThreadPost(parent: BskyPost, root: BskyPost): ThreadPost { - val post = post.toPost(BskyPostReply(root, parent, root.reply?.parent?.author), null) +fun ThreadViewPost.toThread(): BskyPostThread { + val entryPost = this.post.toPost() + val newParent = parent?.toThreadPost() + val rootPost = parent?.getRoot() + val parentPost = if(newParent is ThreadPost.ViewablePost) newParent else null + val grandParent = if(parentPost?.parent is ThreadPost.ViewablePost) parentPost.parent else null + val postReply = BskyPostReply( + rootPost = rootPost?.toPost(), + parentPost = parentPost?.post, + grandParentAuthor = grandParent?.post?.author, + replyRef = entryPost.reply?.replyRef + ) + return BskyPostThread( + post = entryPost.copy(reply = postReply), + parent = newParent, + replies = replies.map { it.toThreadPost() } + ) +} + +fun ThreadViewPost.toThreadPost(): ThreadPost { + val post = post.toPost(null, null) return ViewablePost( post = post, - replies = replies.mapImmutable { it.toThreadPost(post, root) } + parent = parent?.toThreadPost(), + replies = replies.mapImmutable { it.toThreadPost() } ) } -fun ThreadViewPostReplyUnion.toThreadPost(parent: BskyPost, root: BskyPost): ThreadPost = when (this) { +fun ThreadViewPostReplyUnion.toThreadPost(): ThreadPost = when (this) { is ThreadViewPostReplyUnion.ThreadViewPost -> { - val post = value.post.toPost(BskyPostReply(root, parent, root.reply?.parent?.author), null) + val post = value.post.toPost(null, null) ViewablePost( post = post, - replies = value.replies.mapImmutable { it.toThreadPost(post, root) } + parent = value.parent?.toThreadPost(), + replies = value.replies.mapImmutable { it.toThreadPost() } ) } is ThreadViewPostReplyUnion.NotFoundPost -> NotFoundPost(value.uri) is ThreadViewPostReplyUnion.BlockedPost -> BlockedPost(value.uri) } +fun ThreadViewPostParentUnion.toThreadPost(): ThreadPost = when (this) { + is ThreadViewPostParentUnion.ThreadViewPost -> { + val post = value.post.toPost(null, null) + ViewablePost( + post = post, + parent = value.parent?.toThreadPost(), + replies = value.replies.mapImmutable { it.toThreadPost() } + ) + } + is ThreadViewPostParentUnion.NotFoundPost -> NotFoundPost(value.uri) + is ThreadViewPostParentUnion.BlockedPost -> BlockedPost(value.uri) +} + +fun ThreadViewPostParentUnion.getRoot(): ThreadViewPost? { + return when(this) { + is ThreadViewPostParentUnion.ThreadViewPost -> this.value.parent?.getRoot() + is ThreadViewPostParentUnion.NotFoundPost -> null + is ThreadViewPostParentUnion.BlockedPost -> null + } +} + diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPreferences.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPreferences.kt index f2b7f1f..5faf1d8 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPreferences.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/BskyPreferences.kt @@ -2,167 +2,3 @@ package com.morpho.app.model.bluesky //import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import androidx.compose.runtime.Immutable -import app.bsky.actor.* -import com.morpho.app.util.mapImmutable -import com.morpho.butterfly.AtUri -import com.morpho.butterfly.Did -import com.morpho.butterfly.Language -import com.morpho.butterfly.model.ReadOnlyList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.serialization.Serializable - -@Serializable -public data class BskyPreferences( - public var personalDetails: PersonalDetailsPref? = null, - public var adultContent: AdultContentPref? = null, - public val feedViewPrefs: MutableMap = mutableMapOf(), - public var skyFeedBuilderFeeds: SkyFeedBuilderFeedsPref? = null, - public var savedFeeds: SavedFeedsPref? = null, - public val contentLabelPrefs: MutableList = mutableListOf(), - public var threadViewPrefs: ThreadViewPref? = null, - // Get system languages and allow customization of this - public var languages: List = persistentListOf(), - public var mergeFeeds: Boolean = false, - public val mutes: MutableList = mutableListOf(), - public val listsMuted: MutableMap = mutableMapOf(), - public var mutedWords: List = persistentListOf(), - public var hiddenPosts: List = persistentListOf(), - public var labelers: List = persistentListOf(), -) { - fun toRemotePrefs(): ReadOnlyList { - val prefs = persistentListOf() - if (this.adultContent != null) prefs.add(PreferencesUnion.AdultContentPref(this.adultContent!!)) - if (this.personalDetails != null) prefs.add(PreferencesUnion.PersonalDetailsPref(this.personalDetails!!)) - if (this.savedFeeds != null) prefs.add(PreferencesUnion.SavedFeedsPref(this.savedFeeds!!)) - if (this.skyFeedBuilderFeeds != null) prefs.add( - PreferencesUnion.SkyFeedBuilderFeedsPref(this.skyFeedBuilderFeeds!!)) - if (this.threadViewPrefs != null) prefs.add(PreferencesUnion.ThreadViewPref(this.threadViewPrefs!!)) - if (this.feedViewPrefs.isNotEmpty()) this.feedViewPrefs.map { PreferencesUnion.FeedViewPref( - FeedViewPref( - it.key, - it.value.hideReplies, - it.value.hideRepliesByUnfollowed, - it.value.hideRepliesByLikeCount, - it.value.hideReposts, - it.value.hideQuotePosts, - if(it.key == "home") this.mergeFeeds else null, - )) } - if (this.contentLabelPrefs.isNotEmpty()) this.contentLabelPrefs.map { - prefs.add(PreferencesUnion.ContentLabelPref(it)) } - if (this.mutedWords.isNotEmpty()) prefs.add( - PreferencesUnion.MutedWordsPref(MutedWordsPref(this.mutedWords.toImmutableList()))) - if (this.labelers.isNotEmpty()) prefs.add( - PreferencesUnion.LabelersPref( - LabelersPref(this.labelers.toImmutableList().mapImmutable { LabelerPrefItem(it) }))) - return prefs.toImmutableList() - } - - fun labelsToHide(feed: String): List { - return feedViewPrefs[feed]?.labelsToHide ?: contentLabelPrefs.filter { it.visibility == Visibility.HIDE } - } -} - - -@Immutable -@Serializable -data class BskyUser( - val userDid: String, - val handle: String, - val displayName: String?, - val avatar: String?, - val profile: SerializableProfile, -) { - companion object{ - fun makeUser(profile: DetailedProfile): BskyUser { - return BskyUser( - profile.did.did, - profile.handle.handle, - profile.displayName, - profile.avatar, - profile.toSerializableProfile(), - ) - } - } - - fun getProfile(): DetailedProfile { - return profile.toProfile() - } -} - - -@Serializable -public data class BskyFeedPref( - /** - * Hide replies in the feed. - */ - public var hideReplies: Boolean = false, - /** - * Hide replies in the feed if they are not by followed users. - */ - public var hideRepliesByUnfollowed: Boolean = false, - /** - * Hide replies in the feed if they do not have this number of likes. - */ - public var hideRepliesByLikeCount: Long = 2, - /** - * Hide reposts in the feed. - */ - public var hideReposts: Boolean = false, - /** - * Hide quote posts in the feed. - */ - public var hideQuotePosts: Boolean = false, - - // Can be per feed, maybe add "warn" to this as well - public var labelsToHide: List = persistentListOf(), - public var languages: List = persistentListOf(), - public var hidePostsByMuted: Boolean = false -) - -fun GetPreferencesResponse.toPreferences(prefs: BskyPreferences) : BskyPreferences { - preferences.map { pref:PreferencesUnion -> - when(pref) { - is PreferencesUnion.AdultContentPref -> prefs.adultContent = pref.value - is PreferencesUnion.ContentLabelPref -> prefs.contentLabelPrefs.add(pref.value) - is PreferencesUnion.FeedViewPref -> { - if (pref.value.lab_mergeFeedEnabled != null) prefs.mergeFeeds = pref.value.lab_mergeFeedEnabled!! - val labelsToHide = prefs.feedViewPrefs[pref.value.feed]?.labelsToHide - val languages = prefs.feedViewPrefs[pref.value.feed]?.languages - prefs.feedViewPrefs[pref.value.feed] = BskyFeedPref( - hideReplies = pref.value.hideReplies == true, - hideQuotePosts = pref.value.hideQuotePosts == true, - hideReposts = pref.value.hideReposts == true, - hideRepliesByUnfollowed = pref.value.hideRepliesByUnfollowed == true, - hideRepliesByLikeCount = pref.value.hideRepliesByLikeCount?: 0, - ) - if(!labelsToHide.isNullOrEmpty()) prefs.feedViewPrefs[pref.value.feed]?.labelsToHide = labelsToHide - if(!languages.isNullOrEmpty()) prefs.feedViewPrefs[pref.value.feed]?.languages = languages else persistentListOf() - } - is PreferencesUnion.PersonalDetailsPref -> prefs.personalDetails = pref.value - is PreferencesUnion.SavedFeedsPref -> prefs.savedFeeds = pref.value - is PreferencesUnion.SkyFeedBuilderFeedsPref -> prefs.skyFeedBuilderFeeds = pref.value - is PreferencesUnion.ThreadViewPref -> prefs.threadViewPrefs = pref.value - is PreferencesUnion.HiddenPostsPref -> prefs.hiddenPosts = pref.value.items.toPersistentList() - is PreferencesUnion.LabelersPref -> prefs.labelers = pref.value.labelers.map { it.did }.toPersistentList() - is PreferencesUnion.InterestsPref -> {} - is PreferencesUnion.MutedWordsPref -> prefs.mutedWords = pref.value.items.toPersistentList() - else -> {} - } - } - return prefs -} - -fun GetPreferencesResponse.toPreferences() : BskyPreferences { - val prefs = this.toPreferences(BskyPreferences()) - prefs.feedViewPrefs.map { feed -> - prefs.contentLabelPrefs.map { label -> - if (label.visibility == Visibility.HIDE) feed.value.labelsToHide + label - } - feed.value.languages = prefs.languages - } - return prefs -} - diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/DraftPost.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/DraftPost.kt index 7b9534e..acb1e08 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/DraftPost.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/DraftPost.kt @@ -13,7 +13,7 @@ import com.morpho.app.data.SharedImage import com.morpho.app.data.imageToBlob import com.morpho.app.util.makeBlueskyText import com.morpho.app.util.resolveBlueskyText -import com.morpho.butterfly.Butterfly +import com.morpho.butterfly.ButterflyAgent import com.morpho.butterfly.Language import kotlinx.collections.immutable.toPersistentList import kotlinx.datetime.Clock @@ -37,24 +37,13 @@ data class DraftPost( val images: MutableList = mutableListOf(), ) { - suspend fun createPost(api: Butterfly): Post { + suspend fun createPost(agent: ButterflyAgent): Post { val text = makeBlueskyText(text) - val blueskyText = resolveBlueskyText(text, api).getOrDefault(text) - val replyRef = if (reply != null) { - val root = if (reply.reply?.root != null) { - StrongRef(reply.reply.root.uri, reply.reply.root.cid) - } else if (reply.reply?.parent != null) { - StrongRef(reply.reply.parent.uri, reply.reply.parent.cid) - } else { - StrongRef(reply.uri, reply.cid) - } + val blueskyText = resolveBlueskyText(text, agent).getOrDefault(text) + val replyRef = if (reply != null && reply.reply?.replyRef != null) { + val root = reply.reply.replyRef.root val parent = StrongRef(reply.uri, reply.cid) - val grandParentAuthor = (if (reply.reply?.parent != null) { - reply.reply.grandparentAuthor - } else { - reply.author - })?.toProfileViewBasic() - PostReplyRef(root, parent, grandParentAuthor) + PostReplyRef(root, parent) } else null val quoteRef = quote?.let { StrongRef(it.uri, it.cid) @@ -69,7 +58,7 @@ data class DraftPost( app.bsky.embed.RecordWithMediaMediaUnion.Images( Images( images.mapNotNull { - it.toImageRef(api) + it.toImageRef(agent) }.toPersistentList() ) ) @@ -79,7 +68,7 @@ data class DraftPost( PostEmbedUnion.Images( Images( images.mapNotNull { - it.toImageRef(api) + it.toImageRef(agent) }.toPersistentList() ) ) @@ -107,9 +96,9 @@ data class DraftImage( val altText: String? = null, val aspectRatio: AspectRatio? = null, ) { - suspend fun toImageRef(api: Butterfly) : app.bsky.embed.ImagesImage? { + suspend fun toImageRef(agent: ButterflyAgent) : app.bsky.embed.ImagesImage? { return app.bsky.embed.ImagesImage( - image = imageToBlob(image, api)?: return null, + image = imageToBlob(image, agent)?: return null, alt = altText ?: "", aspectRatio = aspectRatio, ) diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/FeedGenerator.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/FeedGenerator.kt index a82c2fa..97c414d 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/FeedGenerator.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/FeedGenerator.kt @@ -2,14 +2,22 @@ package com.morpho.app.model.bluesky import androidx.compose.runtime.Immutable import androidx.compose.ui.util.fastMap +import app.bsky.actor.FeedType +import app.bsky.actor.SavedFeed import app.bsky.feed.GeneratorView import com.morpho.app.model.uidata.Moment +import com.morpho.app.model.uidata.MomentParceler import com.morpho.app.util.mapImmutable import com.morpho.butterfly.AtUri import com.morpho.butterfly.Cid import com.morpho.butterfly.Did +import com.morpho.butterfly.model.TID +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import dev.icerock.moko.parcelize.TypeParceler import kotlinx.serialization.Serializable +@Parcelize @Serializable @Immutable data class FeedGenerator( @@ -24,8 +32,9 @@ data class FeedGenerator( public val likeCount: Long, public val likedByMe: Boolean, public val likeRecord: AtUri?, + @TypeParceler() public val indexedAt: Moment, -) +): Parcelable fun GeneratorView.toFeedGenerator() : FeedGenerator { @@ -47,4 +56,13 @@ fun GeneratorView.toFeedGenerator() : FeedGenerator { fun List.toFeedGenList(): List { return this.fastMap { it.toFeedGenerator() } +} + +fun FeedGenerator.toSavedFeed(pinned: Boolean = false): SavedFeed { + return SavedFeed( + id = TID.next().toString(), + type = FeedType.FEED, + value = this.uri.atUri, + pinned = pinned, + ) } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/FeedSourceInfo.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/FeedSourceInfo.kt new file mode 100644 index 0000000..c122753 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/FeedSourceInfo.kt @@ -0,0 +1,191 @@ +package com.morpho.app.model.bluesky + +import androidx.compose.runtime.Immutable +import app.bsky.actor.FeedType +import app.bsky.feed.GeneratorView +import app.bsky.feed.GetFeedGeneratorQuery +import app.bsky.graph.GetListQuery +import app.bsky.graph.ListView +import com.morpho.app.model.uidata.ContentCardMapEntry +import com.morpho.butterfly.AtUri +import com.morpho.butterfly.ButterflyAgent +import com.morpho.butterfly.Cid +import com.morpho.butterfly.Did +import com.morpho.butterfly.Handle +import com.morpho.butterfly.Nsid +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import kotlinx.serialization.Serializable + + +@Serializable +@Immutable +enum class AuthorFilter { + PostsWithReplies, + PostsNoReplies, + PostsAuthorThreads, + PostsWithMedia, +} + +@Serializable +@Immutable +@Parcelize +sealed interface FeedDescriptor: Parcelable { + @Serializable + @Immutable + data object Home: FeedDescriptor + @Serializable + @Immutable + data class Author( + val did: Did, + val filter: AuthorFilter = AuthorFilter.PostsWithReplies + ): FeedDescriptor + @Serializable + @Immutable + data class FeedGen(val uri: AtUri): FeedDescriptor + @Serializable + @Immutable + data class Likes(val did: Did): FeedDescriptor + @Serializable + @Immutable + data class List(val uri: AtUri): FeedDescriptor +} + +@Serializable +@Immutable +@Parcelize +sealed interface FeedSourceInfo: Parcelable { + val uri: AtUri + val cid: Cid + val avatar: String? + val displayName: String? + val description: String? + val creatorDid: Did + val creatorHandle: Handle + val feedDescriptor: FeedDescriptor + val type: Nsid + val pinned: Boolean? + + @Serializable + @Immutable + data class ListInfo( + override val uri: AtUri, + override val cid: Cid, + override val avatar: String?, + override val displayName: String?, + override val description: String?, + override val creatorDid: Did, + override val creatorHandle: Handle, + override val feedDescriptor: FeedDescriptor, + override val pinned: Boolean? = null, + ): FeedSourceInfo { + override val type: Nsid = Nsid("app.bsky.feed.generator") + } + + @Serializable + @Immutable + data class FeedInfo( + override val uri: AtUri, + override val cid: Cid, + override val avatar: String?, + override val displayName: String?, + override val description: String?, + override val creatorDid: Did, + override val creatorHandle: Handle, + override val feedDescriptor: FeedDescriptor, + val likeCount: Long? = null, + val likeUri: AtUri? = null, + override val pinned: Boolean? = null, + ): FeedSourceInfo { + override val type: Nsid = Nsid("app.bsky.graph.list") + } + + @Serializable + @Immutable + data object Home: FeedSourceInfo { + override val uri: AtUri = AtUri.HOME_URI + override val cid: Cid = Cid("home") + override val avatar: String? = null + override val displayName: String = "Home" + override val description: String = "Your home feed, currently same as Following" + override val creatorDid: Did = Did("did:web:morpho.app") + override val creatorHandle: Handle = Handle("${displayName.lowercase()}.morpho.app") + override val feedDescriptor: FeedDescriptor = FeedDescriptor.Home + override val type: Nsid = Nsid("app.morpho.feed.home") + override val pinned: Boolean = true + } + + @Serializable + @Immutable + data object Following: FeedSourceInfo { + override val creatorDid: Did = Did("did:web:morpho.app") + override val uri: AtUri = AtUri.HOME_URI + override val cid: Cid = Cid("following") + override val avatar: String? = null + override val displayName: String = "Following" + override val description: String = "Your feed of people you follow" + override val creatorHandle: Handle = Handle("${displayName.lowercase()}.morpho.app") + override val feedDescriptor: FeedDescriptor = FeedDescriptor.Home + override val type: Nsid = Nsid("app.morpho.feed.following") + override val pinned: Boolean = true + } +} + +fun FeedSourceInfo.toContentCardMapEntry(): ContentCardMapEntry { + return when(this) { + is FeedSourceInfo.FeedInfo -> ContentCardMapEntry.Feed(this.uri, this.displayName?: "", this.avatar) + FeedSourceInfo.Following -> ContentCardMapEntry.Home + FeedSourceInfo.Home -> ContentCardMapEntry.Home + is FeedSourceInfo.ListInfo -> ContentCardMapEntry.ListFeed(this.uri, this.displayName?: "", this.avatar) + } +} + +fun GeneratorView.hydrateFeedGenerator(): FeedSourceInfo.FeedInfo { + return FeedSourceInfo.FeedInfo( + uri = this.uri, + cid = this.cid, + avatar = this.avatar, + displayName = this.displayName, + description = this.description, + creatorDid = this.creator.did, + creatorHandle = this.creator.handle, + feedDescriptor = FeedDescriptor.FeedGen(this.uri), + likeCount = this.likeCount, + likeUri = this.viewer?.like, + ) +} + +fun ListView.hydrateList(): FeedSourceInfo.ListInfo { + return FeedSourceInfo.ListInfo( + uri = this.uri, + cid = this.cid, + avatar = this.avatar, + displayName = this.name, + description = this.description, + creatorDid = this.creator.did, + creatorHandle = this.creator.handle, + feedDescriptor = FeedDescriptor.List(this.uri), + ) +} + +suspend fun app.bsky.actor.SavedFeed.toFeedSourceInfo(agent: ButterflyAgent): Result { + return when(this.type) { + FeedType.FEED -> { + agent.api.getFeedGenerator(GetFeedGeneratorQuery(AtUri(this.value))) + .map { feed -> + feed.view.hydrateFeedGenerator().copy( + pinned = this.pinned + ) + } + } + FeedType.LIST -> { + agent.api.getList(GetListQuery(AtUri(this.value), 1)) + .map { list -> + list.list.hydrateList().copy( + pinned = this.pinned + ) + } + } + FeedType.TIMELINE -> Result.success(FeedSourceInfo.Following) + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/LitePost.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/LitePost.kt index 4cf3216..10fdd89 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/LitePost.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/LitePost.kt @@ -1,19 +1,27 @@ package com.morpho.app.model.bluesky +import androidx.compose.runtime.Immutable import app.bsky.feed.Post import com.morpho.app.model.uidata.Moment +import com.morpho.app.model.uidata.MomentParceler import com.morpho.app.util.mapImmutable import com.morpho.butterfly.Language +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import dev.icerock.moko.parcelize.TypeParceler import kotlinx.serialization.Serializable +@Parcelize +@Immutable @Serializable data class LitePost( val text: String, val facets: List, val feature: BskyPostFeature?, val langs: List, + @TypeParceler() val createdAt: Moment, -) +): Parcelable fun Post.toLitePost(): LitePost { return LitePost( diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/MorphoDataFeed.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/MorphoDataFeed.kt deleted file mode 100644 index f3b97f2..0000000 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/MorphoDataFeed.kt +++ /dev/null @@ -1,519 +0,0 @@ -package com.morpho.app.model.bluesky - -//import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import androidx.compose.ui.util.* -import app.bsky.actor.ContentLabelPref -import app.bsky.feed.FeedViewPost -import app.bsky.feed.GetPostThreadQuery -import app.bsky.feed.GetPostThreadResponseThreadUnion -import com.morpho.app.model.uidata.AtCursor -import com.morpho.app.model.uidata.Delta -import com.morpho.app.model.uidata.MorphoData -import com.morpho.app.util.mapImmutable -import com.morpho.butterfly.* -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* -import kotlinx.serialization.Serializable -import kotlin.time.Duration - - -typealias TunerFunction = (List) -> List - - - - -@Suppress("unused", "UNCHECKED_CAST") -@Serializable -data class MorphoDataFeed ( - private var _items: MutableList = mutableListOf(), - var cursor: AtCursor = null, - val uri: AtUri = AtUri.HOME_URI, - var hasNewPosts: Boolean = false, -) { - val items: List = _items.toList() - companion object { - - fun fromPosts( - posts: List, - cursor: AtCursor = null, - ): MorphoDataFeed { - return MorphoDataFeed( - _items = posts.map { MorphoDataItem.Post(it) }.toMutableList(), - cursor = cursor, hasNewPosts = cursor == null - ) - } - - fun fromMorphoData( - data: MorphoData - ): MorphoDataFeed { - return MorphoDataFeed( - _items = data.items.toMutableList(), - cursor = data.cursor, hasNewPosts = data.cursor == null - ) - } - - - fun fromFeedGen( - feeds: List, - cursor: AtCursor = null, - ): MorphoDataFeed { - return MorphoDataFeed( - _items = feeds.map { MorphoDataItem.FeedInfo(it) }.toMutableList(), - cursor = cursor, hasNewPosts = cursor == null - ) - } - - fun fromProfileList( - list: List, - cursor: AtCursor = null, - ): MorphoDataFeed { - return MorphoDataFeed( - _items = list.map { MorphoDataItem.ProfileItem(it) }.toMutableList(), - cursor = cursor, hasNewPosts = cursor == null - ) - } - - fun fromBskyList( - lists: List, - cursor: AtCursor = null, - ): MorphoDataFeed { - return MorphoDataFeed( - _items = lists.map { MorphoDataItem.ListInfo(it) }.toMutableList(), - cursor = cursor, hasNewPosts = cursor == null - ) - } - - fun fromModLabelDefs( - labels: List, - cursor: AtCursor = null, - ): MorphoDataFeed { - return MorphoDataFeed( - _items = labels.map { MorphoDataItem.ModLabel(it) }.toMutableList(), - cursor = cursor, hasNewPosts = cursor == null - ) - } - - fun fromModServiceDefs( - services: List, - cursor: AtCursor = null, - ): MorphoDataFeed { - return MorphoDataFeed( - _items = services.map { MorphoDataItem.LabelService(it) }.toMutableList(), - cursor = cursor, hasNewPosts = cursor == null - ) - } - - - fun concat( - posts: List, - feed: MorphoDataFeed, - cursor: AtCursor = feed.cursor, - ): MorphoDataFeed { - return MorphoDataFeed( - _items = (posts.mapImmutable { MorphoDataItem.Post(it.toPost()) } + feed._items).toList() - .toMutableList(), - - cursor = cursor, hasNewPosts = cursor == null - ) - } - fun concat( - feed: MorphoDataFeed, - posts: List, - cursor: AtCursor = feed.cursor, - ): MorphoDataFeed { - return MorphoDataFeed( - _items = (feed._items + posts.mapImmutable { MorphoDataItem.Post(it.toPost()) }) - .toMutableList(), - cursor = cursor, hasNewPosts = cursor == null - ) - } - - fun concat( - first: MorphoDataFeed, - last: MorphoDataFeed, - cursor: AtCursor = last.cursor - ): MorphoDataFeed { - return MorphoDataFeed( - _items = (first._items + last._items).toMutableList(), - cursor = cursor, hasNewPosts = cursor == null - ) - } - - fun concat( - first: MorphoData, - last: MorphoDataFeed, - cursor: AtCursor = last.cursor - ): MorphoDataFeed { - return MorphoDataFeed( - _items = (first.items + last.items).toMutableList(), - cursor = cursor, hasNewPosts = cursor == null - ) - } - - fun concat( - first: MorphoDataFeed, - last: MorphoData, - cursor: AtCursor = last.cursor - ): MorphoDataFeed { - return MorphoDataFeed( - _items = (first.items + last.items).toMutableList(), - cursor = cursor, hasNewPosts = cursor == null - ) - } - - //@NativeCoroutines - fun collectThreads( - list: List, - depth: Int = 3, height: Int = 10, - timeRange: Delta = Delta(Duration.parse("4h")), - cursor: AtCursor = null, - ): Flow> = flow { - emit(collectThreads(fromPosts(list.toBskyPostList(), cursor), depth, height, timeRange) - .distinctUntilChanged().last() - ) - }.flowOn(Dispatchers.Default) - - //@NativeCoroutines - fun collectThreads( - feed: MorphoDataFeed, - depth: Int = 3, height: Int = 10, - timeRange: Delta = Delta(Duration.parse("4h")), - cursor: AtCursor = feed.cursor, - ): Flow> = flow { - val threadCandidates = mutableMapOf>() - - feed._items.map { item -> - if (item is MorphoDataItem.Post) { - val post = item.post - if(post.reply != null) { - val itemCid = post.cid - val parent = post.reply.parent - val root = post.reply.root - if(itemCid !in threadCandidates.keys) { - var found = false - threadCandidates.forEach { thread -> - if(itemCid in thread.value.keys) { - if (parent != null && parent.cid !in thread.value.keys) { - thread.value[parent.cid] = parent - } - if (root != null && root.cid !in thread.value.keys) { - thread.value[root.cid] = root - } - found = true - return@forEach - } else if (parent != null && parent.cid in thread.value.keys) { - if(parent.reply?.parent != null) { - thread.value[parent.reply.parent.cid] = parent.reply.parent - } - } - } - if(!found) { - threadCandidates[itemCid] = mutableMapOf() - if (parent != null) { - threadCandidates[itemCid]?.set(parent.cid, parent ) - if(parent.reply?.parent != null) { - threadCandidates[itemCid]?.set(parent.reply.parent.cid, parent.reply.parent) - } - } - if (root != null && threadCandidates[itemCid]?.keys?.contains(root.cid) != true ) { - threadCandidates[itemCid]?.set(root.cid, root ) - } - } - } else { - if (parent != null && threadCandidates[itemCid]?.keys?.contains(parent.cid) != true ) { - threadCandidates[itemCid]?.set(parent.cid, parent ) - if(parent.reply?.parent != null) { - threadCandidates[itemCid]?.set(parent.reply.parent.cid, parent.reply.parent) - } - } - if (root != null && threadCandidates[itemCid]?.keys?.contains(root.cid) != true ) { - threadCandidates[itemCid]?.set(root.cid, root ) - } - } - } - } - } - - val threads = mutableMapOf>() - threadCandidates.map { thread -> - if (thread.value.values.isNotEmpty()) threads[thread.key] = thread.value.values.toMutableList() - } - feed._items.mapIndexed { index, item -> - if (item is MorphoDataItem.Post){ - val post = item.post - val itemCid = post.cid - if( itemCid in threads.keys) { - val level = 1 - val thread = threads[itemCid] - ?.filter { (it.createdAt - post.createdAt).duration <= timeRange.duration } - ?.sortedByDescending { it.createdAt } - .orEmpty() - val parents: Flow = flow { - generateSequence(post.reply?.parent) { - it.reply?.parent - }.toList().reversed().map { r-> - emit(ThreadPost.ViewablePost( - r, - findReplies(level, height, r, thread.asFlow()) - .toList().toImmutableList() - )) - } - } - val replies: Flow = flow { - threads[itemCid]?.filter { - (it.reply?.parent?.cid ?: Cid("")) == itemCid - }?.map { p -> - emit(ThreadPost.ViewablePost( - p, - findReplies(level, depth, p, thread.asFlow()) - .toList().toImmutableList() - )) - }.orEmpty() - } - feed._items[index] = MorphoDataItem.Thread( - BskyPostThread( - post, - parents.toList().toImmutableList(), - replies.toList().toImmutableList() - ) - ) - } - } - - } - feed._items.sortedByDescending { - when(it) { - is MorphoDataItem.Post -> it.post.createdAt - is MorphoDataItem.Thread -> it.thread.post.createdAt - } - } - emit(MorphoDataFeed(feed._items, cursor, feed.uri, feed.hasNewPosts)) - }.flowOn(Dispatchers.Default) - - private fun findReplies(level: Int, depth: Int, post: BskyPost, list: Flow - ): Flow = flow { - list.filter { - (it.reply?.parent?.cid ?: Cid("")) == post.cid - }.map { - if (level < depth) { - val r = findReplies(level + 1, depth, it, list) - .distinctUntilChanged().toList() - emit(ThreadPost.ViewablePost(it, r.toImmutableList())) - } else { - emit(ThreadPost.ViewablePost(it)) - } - } - }.flowOn(Dispatchers.Default) - fun filterByPrefs( - posts: List, - prefs: BskyFeedPref, - follows: List = persistentListOf(), - ): List { - var feed = posts.fastFilter { post -> // A-B test perf with fast and normal filter - (!prefs.hideReposts && post.reason is BskyPostReason.BskyPostRepost) - || (!prefs.hideQuotePosts && isQuotePost(post)) - || ((!prefs.hideReplies && (post.reply != null)) - && (isSelfReply(post) || isThreadRoot(post) || post.reposted - || (post.likeCount <= prefs.hideRepliesByLikeCount) ) - && (!prefs.hideRepliesByUnfollowed && isFollowingAllAuthors(post, follows)) ) - || (post.reply == null && !isQuotePost(post) && post.reason == null) - } - feed = filterbyLanguage(feed, prefs.languages) - feed = filterByContentLabel(feed, prefs.labelsToHide) - return feed - } - - - - fun filterbyLanguage( - posts: List, - languages: List, - ): List { - return posts.fastFilter { post -> post.langs.any { languages.contains(it) } } - } - - fun filterByContentLabel( - posts: List, - toHide: List = persistentListOf(), - ): List { - return posts.fastFilter { post -> post.labels.none { label -> toHide.fastAny { it.label == label.value } } } - } - - fun filterBy(did: Did, posts: List): List { - return posts.fastFilter { it.author.did != did } - } - - fun filterBy(string: String, posts: List) : List { - return posts.fastFilter { - it.text.contains(string) - } - } - - fun filterByWord(string: String, posts: List) : List { - return filterBy(Regex("""\b$string\b"""), posts) - } - - fun filterBy(regex: Regex, posts: List) : List { - return posts.fastFilter { - it.text.contains(regex) - } - } - - fun dedupPosts(posts: List): List { - return posts.fastDistinctBy { post-> // A-B test perf with fast and normal distinctBy - post.cid - } - } - - fun collectThreads( - apiProvider: Butterfly, - cursor: AtCursor = null, - posts: List, - uri: AtUri = AtUri.HOME_URI, - depth: Long = 1, height: Long = 10, - ): Flow> = flow { - val threads: MutableMap = mutableMapOf() - posts.asReversed().fastMap { post -> - val reply = getIfSelfReply(post) - if ((reply != null) && posts.filterNot { it.uri == post.uri } - .none { threads.keys.contains(it.uri) || threads.values.any { thread-> - thread.contains(it.uri) - } }) { - if (reply.author.did == post.reply?.root?.author?.did - && post.author.did == post.reply.root.author.did - ) { - apiProvider.api.getPostThread( - GetPostThreadQuery( - reply.uri, - depth, - height - ) - ).onSuccess { - when (val thread = it.thread) { - is GetPostThreadResponseThreadUnion.BlockedPost -> {} - is GetPostThreadResponseThreadUnion.NotFoundPost -> {} - is GetPostThreadResponseThreadUnion.ThreadViewPost -> { - threads[post.uri] = thread.value.toThread() - } - } - } - } - } - } - var morphoDataItems: List = posts.fastMap { - val thread = threads[it.uri] - if (threads.containsKey(it.uri) && thread != null) { - MorphoDataItem.Thread(thread) - } else { - MorphoDataItem.Post(it) - } - } - morphoDataItems = morphoDataItems.fastFilter { item -> - threads.none { - item is MorphoDataItem.Post && it.value.contains(item.post.uri) - } - } - emit(MorphoDataFeed(_items = morphoDataItems.toMutableList(), cursor, uri, hasNewPosts = cursor == null)) - }.flowOn(Dispatchers.Default) - } - - fun collectThreads( - depth: Int = 3, height: Int = 80, - timeRange: Delta = Delta(Duration.parse("4h")) - ): Flow> = flow { - emit(collectThreads(this@MorphoDataFeed as MorphoDataFeed, - depth, height, timeRange).distinctUntilChanged().last()) - }.flowOn(Dispatchers.Default) - - - - operator fun plus(feed: MorphoDataFeed) { - _items = (_items + feed._items).toMutableList() - cursor = feed.cursor - } - - operator fun contains(cid: Cid): Boolean { - return _items.fastAny { - when(it) { - is MorphoDataItem.Post -> it.post.cid == cid - is MorphoDataItem.Thread -> it.thread.contains(cid) - is MorphoDataItem.FeedInfo -> it.feed.cid == cid - is MorphoDataItem.ListInfo -> it.list.cid == cid - is MorphoDataItem.ModLabel -> false - is MorphoDataItem.ProfileItem -> false - is MorphoDataItem.LabelService -> it.service.cid == cid - else -> {false} - } - } - } - - - -} -fun List.toBskyPostList(): List { - return this.fastMap { it.toPost() } -} - -fun List.tune( - tuners: List = persistentListOf(), -) : List { - var feed = MorphoDataFeed.dedupPosts(this) - tuners.fastForEach { tuner-> - feed = tuner(feed) - } - return feed -} - -fun isFollowingAllAuthors(post: BskyPost, follows: List): Boolean { - return follows.fastAny { - (post.author.did == it - || post.reply?.parent?.author?.did == it - || post.reply?.root?.author?.did == it) - } -} - -fun isQuotePost(post: BskyPost) : Boolean { - return when(post.feature) { - is BskyPostFeature.MediaRecordFeature -> true - is BskyPostFeature.RecordFeature -> true - else -> false - } -} - -fun isSelfReply(post: BskyPost) : Boolean { - return if (post.reply != null) { - if(post.reply.parent?.author?.did == post.author.did) { - true - } else post.reply.root?.author?.did == post.author.did - } else { - false - } -} - -fun getIfSelfReply(post: BskyPost) : BskyPost? { - return if (post.reply != null) { - if(post.reply.parent?.author?.did == post.author.did) { - post.reply.parent - } else if (post.reply.root?.author?.did == post.author.did) { - post.reply.root - } else { - null - } - } else { - null - } -} - -fun isInThread(post: BskyPost) : Boolean { - return post.reply != null -} - -fun isThreadRoot(post: BskyPost) : Boolean { - return (post.replyCount > 0 && post.reply == null) -} - -fun isSecondInThread(post: BskyPost) : Boolean { - return (post.reply?.parent == post.reply?.root && post.replyCount > 0) -} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/MorphoDataItem.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/MorphoDataItem.kt index b8db7a5..7942b2e 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/MorphoDataItem.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/MorphoDataItem.kt @@ -1,8 +1,17 @@ package com.morpho.app.model.bluesky import androidx.compose.runtime.Immutable +import app.bsky.actor.Visibility +import app.bsky.feed.* +import com.morpho.app.CommonParcelize +import com.morpho.app.util.deserialize +import com.morpho.butterfly.AtUri +import com.morpho.butterfly.InterpretedLabelDefinition +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize import kotlinx.serialization.Serializable + /** * Type union for different types of data items that can be displayed in the app. * Can use interface directly or use subclasses for more specific types where needed. @@ -10,40 +19,246 @@ import kotlinx.serialization.Serializable * This would help keep "when" statements from scenario where we want * e.g. PostItems and ThreadItems from needing to handle all possible subtypes. */ +@Parcelize @Immutable @Serializable -sealed interface MorphoDataItem { +@CommonParcelize +sealed interface MorphoDataItem: Parcelable { + + @Immutable + @Serializable + @CommonParcelize + sealed interface FeedItem: MorphoDataItem { + + fun getAuthors(): AuthorContext? { + return when(this) { + is Post -> { + AuthorContext( + author = post.author, + parentAuthor = post.reply?.parentPost?.author, + grandParentAuthor = post.reply?.parentPost?.reply?.parentPost?.author, + rootAuthor = post.reply?.rootPost?.author, + ) + } + is Thread -> { + AuthorContext( + author = thread.post.author, + parentAuthor = thread.post.reply?.parentPost?.author, + grandParentAuthor = thread.post.reply?.parentPost?.reply?.parentPost?.author, + rootAuthor = thread.post.reply?.rootPost?.author, + ) + } + } + } + + val key: String + get() = when(this) { + is Post -> { + when(reason) { + is BskyPostReason.BskyPostRepost -> "post_${post.uri}_${reason.indexedAt}_${post.indexedAt}" + is BskyPostReason.BskyPostFeedPost -> "post_${post.uri}_${reason.repost}_${post.indexedAt}" + else -> "post_${post.uri}_${post.reason?.hashCode()?:0}_${post.indexedAt}" + } + } + is Thread -> { + when(reason) { + is BskyPostReason.BskyPostRepost -> "thread_${thread.post.uri}_${reason.indexedAt}_${thread.post.indexedAt}" + is BskyPostReason.BskyPostFeedPost -> "thread_${thread.post.uri}_${reason.repost}_${thread.post.indexedAt}" + else -> "thread_${thread.post.uri}_${thread.post.indexedAt}" + } + } + } + + val rootUri: AtUri + get() = when(this) { + is Post -> post.reply?.replyRef?.root?.uri ?: post.uri + is Thread -> if(thread.post.reply != null) { + thread.post.reply.replyRef?.root?.uri ?: thread.post.uri + } else thread.post.uri + } + + val rootAccessiblePost: BskyPost + get() = when(this) { + is Post -> post.reply?.rootPost ?: post + is Thread -> if(thread.post.reply != null) { + if(thread.post.reply.rootPost != null) { + thread.post.reply.rootPost + } else { + val parent = thread.parents.firstOrNull { + when(it) { + is ThreadPost.ViewablePost -> true + else -> false + } + } + when(parent) { + is ThreadPost.ViewablePost -> parent.post + else -> thread.post + } + } + } else thread.post + } + + val parentAuthor: Profile? + get() = when(this) { + is Post -> post.reply?.parentPost?.author + is Thread -> thread.post.reply?.parentPost?.author + } + val isQuotePost: Boolean + get() = when(this) { + is Post -> when(post.feature) { + is BskyPostFeature.ExternalFeature -> false + is BskyPostFeature.ImagesFeature -> false + is BskyPostFeature.MediaRecordFeature -> true + is BskyPostFeature.RecordFeature -> true + is BskyPostFeature.UnknownEmbed -> false + is BskyPostFeature.VideoFeature -> false + null -> false + } + is Thread -> false + } + + val isReply: Boolean + get() = when(this) { + is Post -> post.reply != null + is Thread -> thread.post.reply != null + } + + val isRepost: Boolean + get() = when(this) { + is Post -> post.reason is BskyPostReason.BskyPostRepost + is Thread -> thread.post.reason is BskyPostReason.BskyPostRepost + } + + val likeCount: Long + get() = when(this) { + is Post -> post.likeCount + is Thread -> thread.post.likeCount + } - sealed interface FeedItem: MorphoDataItem + companion object { + fun fromFeedViewPost(feedPost: FeedViewPost): FeedItem { + val items = mutableListOf() + val reason = feedPost.reason + val post = feedPost.post + val reply = feedPost.reply + var isIncompleteThread = false + var isOrphan = false + if (reply == null) { + val newPost = post.toPost() + return Post(newPost, newPost.reason, isOrphan = isOrphan) + } + if (reason != null) { + return Post(post.toPost(reply.toReply(), reason.toReason()), isOrphan = isOrphan) + } + + val rootUri = reply.root.getRootStatus()?.second ?: post.uri + val rootStatus = reply.root.getRootStatus()?.first ?: PostStatus.NotFound + val root = reply.root.postView() + val parent = reply.parent.postView() + items.add(feedPost.post) + val grandparent = if(rootStatus == PostStatus.Viewable && when(reply.parent) { + is ReplyRefParentUnion.BlockedPost -> false + is ReplyRefParentUnion.NotFoundPost -> false + is ReplyRefParentUnion.PostView -> { + val parentRef = reply.parent as ReplyRefParentUnion.PostView + val parentPost = try { + app.bsky.feed.Post.serializer().deserialize(parentRef.value.record) + } catch (e: Exception) { + null + } + parentPost?.reply?.parent?.uri == rootUri + } + }) { root } else null + + if(parent != null) items.add(0, parent) + if (grandparent == null) isOrphan = true + if (rootStatus != PostStatus.Viewable) { + return Post(post.toPost(reply.toReply(), feedPost.reason?.toReason()), null, isOrphan = true) + } + if (rootUri == parent?.uri) { + return if (items.size == 1) { + Post(post.toPost(reply.toReply(), feedPost.reason?.toReason()), null, isOrphan = isOrphan) + } else { + Thread( + BskyPostThread( + post = post.toPost(reply.toReply(), null), + + replies = listOf() + ), + null, + isIncompleteThread = isIncompleteThread, + ) + } + } + if(root != null) items.add(0, root) + if (grandparent != null) { + items.add(0, grandparent) + isIncompleteThread = true + } + return if (items.size == 1) { + Post(post.toPost(reply.toReply(), feedPost.reason?.toReason()), feedPost.reason?.toReason(), isOrphan = isOrphan) + } else { + + val thread = BskyPostThread( + post = post.toPost(reply.toReply(), null), + parent = parent?.toThreadPost(items), + replies = listOf() + ) + + Thread( + thread, + null, + isIncompleteThread = isIncompleteThread, + ) + } + } + } + } @Immutable @Serializable + @CommonParcelize data class Post( val post: BskyPost, - val reason: BskyPostReason? = null, + val reason: BskyPostReason? = post.reason, + val isOrphan: Boolean = false, ): FeedItem @Immutable @Serializable + @CommonParcelize data class Thread( val thread: BskyPostThread, val reason: BskyPostReason? = null, - ): FeedItem + val isIncompleteThread: Boolean = false, + ): FeedItem { + fun addReply(reply: BskyPost): Thread { + return this.copy(thread = thread.addReply(reply)) + } + + fun addReply(reply: ThreadPost.ViewablePost): Thread { + return this.copy(thread = thread.addReply(reply)) + } + + } @Immutable @Serializable + @CommonParcelize data class FeedInfo( val feed: FeedGenerator, ): MorphoDataItem @Immutable @Serializable + @CommonParcelize data class ProfileItem( - val profile: Profile, + val profile: DetailedProfile, ): MorphoDataItem @Immutable @Serializable + @CommonParcelize data class ListInfo( val list: BskyList, ): MorphoDataItem @@ -51,14 +266,153 @@ sealed interface MorphoDataItem { @Immutable @Serializable + @CommonParcelize data class ModLabel( - val label: BskyLabelDefinition, + val label: InterpretedLabelDefinition, + val setting: Visibility, ): MorphoDataItem - @Immutable - @Serializable - data class LabelService( - val service: BskyLabelService, - ): MorphoDataItem + fun containsUri(uri: AtUri): Boolean { + return when(this) { + is Post -> post.uri == uri + is Thread -> { + thread.post.uri == uri || thread.parents.any { parent -> + when(parent) { + is ThreadPost.ViewablePost -> parent.post.uri == uri + is ThreadPost.BlockedPost -> parent.uri == uri + is ThreadPost.NotFoundPost -> parent.uri == uri + } + } || thread.replies.any { reply -> + when(reply) { + is ThreadPost.ViewablePost -> reply.post.uri == uri + is ThreadPost.BlockedPost -> reply.uri == uri + is ThreadPost.NotFoundPost -> reply.uri == uri + } + } + } + is FeedInfo -> feed.uri == uri + is ListInfo -> list.uri == uri + is ModLabel -> label.identifier == uri.atUri + is ProfileItem -> false + } + } + + fun getUris(): List { + return when(this) { + is Post -> listOf(post.uri) + is Thread -> { + ( thread.parents.map { parent -> + when(parent) { + is ThreadPost.ViewablePost -> parent.post.uri + is ThreadPost.BlockedPost -> parent.uri + is ThreadPost.NotFoundPost -> parent.uri + } + } + listOf(thread.post.uri) + thread.replies.map { reply -> + when(reply) { + is ThreadPost.ViewablePost -> reply.post.uri + is ThreadPost.BlockedPost -> reply.uri + is ThreadPost.NotFoundPost -> reply.uri + } + }).filterNotNull() + } + is FeedInfo -> listOf(feed.uri) + is ListInfo -> listOf(list.uri) + is ModLabel -> listOf() + is ProfileItem -> listOf() + } + } + + fun getUri(): AtUri? { + return when(this) { + is Post -> post.uri + is Thread -> thread.post.uri + is FeedInfo -> feed.uri + is ListInfo -> list.uri + is ModLabel -> null + is ProfileItem -> null + } + } + + +} + +@Immutable +@Serializable +data class AuthorContext( + val author: Profile, + val parentAuthor: Profile? = null, + val grandParentAuthor: Profile? = null, + val rootAuthor: Profile? = null, +) + +fun PostView.parentOrNull(posts: List): ThreadPost? { + val post = this.toPost() + val parentUri = post.reply?.replyRef?.parent?.uri + return if(parentUri != null) (posts.firstOrNull { it.uri == parentUri })?.toPost()?.let { bskyPost -> + val recParent = parentOrNull(posts.filterNot { it.uri == parentUri }) + ThreadPost.ViewablePost(bskyPost, recParent, listOf( + ThreadPost.ViewablePost(post, ThreadPost.ViewablePost(bskyPost, recParent, listOf()), listOf()) + )) + } else null +} + +fun PostView.toThreadPost(posts: List): ThreadPost { + val parent = this.parentOrNull(posts.filterNot { it.uri == this.uri }) + return ThreadPost.ViewablePost(this.toPost(), parent, listOf()) +} + +fun BskyPost.toThreadPost(reply: BskyPost? = null): ThreadPost { + val parent = this.reply?.parentPost + return ThreadPost.ViewablePost(this, parent?.toThreadPost(), + reply?.let { listOf(it.toThreadPost())} ?: listOf()) +} + +fun FeedViewPost.toThreadPost(reply: BskyPost? = null): ThreadPost { + val post = this.toPost() + val parent = post.reply?.parentPost + return ThreadPost.ViewablePost(post, parent?.toThreadPost(post), + reply?.let { listOf(it.toThreadPost())} ?: listOf()) +} + +enum class PostStatus { + Viewable, + NotFound, + Blocked, +} + +inline fun ReplyRefParentUnion?.getParentStatus(): Pair? { + return when(this) { + is ReplyRefParentUnion.BlockedPost -> PostStatus.Blocked to this.value.uri + is ReplyRefParentUnion.NotFoundPost -> PostStatus.NotFound to this.value.uri + is ReplyRefParentUnion.PostView -> PostStatus.Viewable to this.value.uri + null -> null + } +} + +inline fun ReplyRefParentUnion?.postView(): PostView? { + return when(this) { + is ReplyRefParentUnion.BlockedPost -> null + is ReplyRefParentUnion.NotFoundPost -> null + is ReplyRefParentUnion.PostView -> this.value + null -> null + } } + +inline fun ReplyRefRootUnion?.getRootStatus(): Pair? { + return when(this) { + is ReplyRefRootUnion.BlockedPost -> PostStatus.Blocked to this.value.uri + is ReplyRefRootUnion.NotFoundPost -> PostStatus.NotFound to this.value.uri + is ReplyRefRootUnion.PostView -> PostStatus.Viewable to this.value.uri + null -> null + } +} + +inline fun ReplyRefRootUnion?.postView(): PostView? { + return when(this) { + is ReplyRefRootUnion.BlockedPost -> null + is ReplyRefRootUnion.NotFoundPost -> null + is ReplyRefRootUnion.PostView -> this.value + null -> null + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/NotificationsList.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/NotificationsList.kt deleted file mode 100644 index e84981c..0000000 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/NotificationsList.kt +++ /dev/null @@ -1,201 +0,0 @@ -package com.morpho.app.model.bluesky - -import androidx.compose.ui.util.fastMap -import app.bsky.notification.ListNotificationsNotification -import app.bsky.notification.ListNotificationsReason -import com.morpho.app.model.uidata.AtCursor -import com.morpho.app.util.mapImmutable -import com.morpho.butterfly.AtUri -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.serialization.Serializable - -/** - * Notifications combined for display - * Will show unread if any of a category are unread - * Categorizes either by what it refers to (for likes and so on) or by type (for follows) - */ -@Serializable -data class NotificationsList( - private val notifications: List = persistentListOf(), - val cursor: AtCursor = null, -) { - private var _notificationsList: MutableList = mutableListOf() - val notificationsList: List - get() { - if (!initialized) { - initList() - } - return _notificationsList.fastMap { it.toImmutable() }.toList() - } - - private var initialized = false - init { - initList() - } - - private fun initList() { - if (initialized) return - val seen = mutableListOf() - notifications.map { notif -> - if(notif.reasonSubject != null && seen.contains(notif.reasonSubject)) { - val index = _notificationsList.indexOfFirst { - it.reasonSubject == notif.reasonSubject - } - if (index >= 0 && notif.reason == _notificationsList[index].reason) { - _notificationsList[index].notifications.add(notif) - _notificationsList[index].isRead = if (notif.isRead) true else _notificationsList[index].isRead - } else { - _notificationsList.add( - MutableNotificationsListItem( - notifications = mutableListOf(notif), - reason = notif.reason, - isRead = notif.isRead, - reasonSubject = notif.reasonSubject - ) - ) - } - } else if (notif.reasonSubject != null) { - seen.add(notif.reasonSubject!!) - _notificationsList.add( - MutableNotificationsListItem( - notifications = mutableListOf(notif), - reason = notif.reason, - isRead = notif.isRead, - reasonSubject = notif.reasonSubject - ) - ) - } else { - val index = _notificationsList.indexOfFirst { item-> - item.reason == notif.reason - } - if (index >= 0) { - _notificationsList[index].notifications.add(notif) - _notificationsList[index].isRead = if (notif.isRead) true else _notificationsList[index].isRead - } else { - _notificationsList.add( - MutableNotificationsListItem( - notifications = mutableListOf(notif), - reason = notif.reason, - isRead = notif.isRead, - reasonSubject = notif.reasonSubject - ) - ) - } - } - } - initialized = true - } - fun concat(new: List): NotificationsList { - return NotificationsList( - notifications.toPersistentList().addAll(new.map { - it.toBskyNotification() - }) - ) - } - - fun concat(new: NotificationsList): NotificationsList { - return NotificationsList( - notifications.toPersistentList().addAll(new.notifications) - ) - } - fun markAllRead(): NotificationsList { - _notificationsList.map { - it.isRead = true - } - val newNotifs = notifications.mapImmutable { - when(it) { - is BskyNotification.Follow -> it.copy(isRead = true) - is BskyNotification.Like -> it.copy(isRead = true) - is BskyNotification.Post -> it.copy(isRead = true) - is BskyNotification.Repost -> it.copy(isRead = true) - is BskyNotification.Unknown -> it.copy(isRead = true) - } - } - return this.copy( - notifications = newNotifs - ) - } - - fun markRead(uri: AtUri): NotificationsList { - _notificationsList.forEach { notificationsListItem -> - if(notificationsListItem.notifications.firstOrNull { it.uri == uri } != null) { - notificationsListItem.isRead = true - notificationsListItem.notifications.map { - when(it) { - is BskyNotification.Follow -> it.copy(isRead = true) - is BskyNotification.Like -> it.copy(isRead = true) - is BskyNotification.Post -> it.copy(isRead = true) - is BskyNotification.Repost -> it.copy(isRead = true) - is BskyNotification.Unknown -> it.copy(isRead = true) - } - } - } - } - return this - } -} - -@Serializable -data class MutableNotificationsListItem( - val notifications: MutableList = mutableListOf(), - val reason: ListNotificationsReason, - var isRead: Boolean = false, - val reasonSubject: AtUri? = null, -) { - companion object { - fun fromImmutable(item: NotificationsListItem): MutableNotificationsListItem { - return MutableNotificationsListItem( - notifications = item.notifications.toMutableList(), - reason = item.reason, - isRead = item.isRead, - reasonSubject = item.reasonSubject - ) - } - } - fun toImmutable(): NotificationsListItem { - return NotificationsListItem( - notifications = notifications.toImmutableList(), - reason = reason, - isRead = isRead, - reasonSubject = reasonSubject - ) - } -} - -@Serializable -data class NotificationsListItem( - val notifications: List, - val reason: ListNotificationsReason, - val isRead: Boolean, - val reasonSubject: AtUri?, -) { - companion object { - fun fromMutable(item: MutableNotificationsListItem) { - NotificationsListItem( - notifications = item.notifications.toImmutableList(), - reason = item.reason, - isRead = item.isRead, - reasonSubject = item.reasonSubject - ) - } - } - - override fun hashCode(): Int { - return notifications.hashCode() + reason.hashCode() + reasonSubject.hashCode() - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as NotificationsListItem - - if (reason != other.reason) return false - if (reasonSubject != other.reasonSubject) return false - if (notifications != other.notifications) return false - - return true - } -} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/NotificationsSource.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/NotificationsSource.kt new file mode 100644 index 0000000..abfe48c --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/NotificationsSource.kt @@ -0,0 +1,181 @@ +package com.morpho.app.model.bluesky + +import app.bsky.notification.ListNotificationsReason +import com.morpho.app.data.MorphoDataSource +import com.morpho.app.model.uistate.NotificationsFilterState +import com.morpho.butterfly.AtUri +import com.morpho.butterfly.Cursor +import kotlinx.serialization.Serializable +import org.lighthousegames.logging.logging + +class NotificationsSource: MorphoDataSource() { + companion object { + val log = logging() + val defaultConfig = app.cash.paging.PagingConfig( + pageSize = 40, + prefetchDistance = 20, + initialLoadSize = 80, + enablePlaceholders = false, + ) + } + + override suspend fun load(params: LoadParams): LoadResult { + try { + val limit = params.loadSize + val loadCursor = when(params) { + is LoadParams.Append -> params.key + is LoadParams.Prepend -> Cursor.Empty + is LoadParams.Refresh -> Cursor.Empty + } + return agent.listNotifications(limit.toLong(), loadCursor.value).map { response -> + val items = response.items.map { it.toBskyNotification()}.collectNotifications() + LoadResult.Page( + data = items, + prevKey = when(params) { + is LoadParams.Append -> loadCursor + is LoadParams.Prepend -> null + is LoadParams.Refresh -> Cursor.Empty + }, + nextKey = response.cursor, + ) + }.onFailure { + return LoadResult.Error(it) + }.getOrDefault(LoadResult.Error(Exception("Load failed"))) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } +} + +fun List.collectNotifications() : List { + val seen = mutableListOf() + val workList = mutableListOf() + this.map { notif -> + if(notif.reasonSubject != null && seen.contains(notif.reasonSubject)) { + val index = workList.indexOfFirst { + it.reasonSubject == notif.reasonSubject + } + if (index >= 0 && notif.reason == workList[index].reason) { + workList[index].notifications.add(notif) + workList[index].isRead = if (notif.isRead) true else workList[index].isRead + } else { + workList.add( + MutableNotificationsListItem( + notifications = mutableListOf(notif), + reason = notif.reason, + isRead = notif.isRead, + reasonSubject = notif.reasonSubject + ) + ) + } + } else if (notif.reasonSubject != null) { + seen.add(notif.reasonSubject!!) + workList.add( + MutableNotificationsListItem( + notifications = mutableListOf(notif), + reason = notif.reason, + isRead = notif.isRead, + reasonSubject = notif.reasonSubject + ) + ) + } else { + val index = workList.indexOfFirst { item-> + item.reason == notif.reason + } + if (index >= 0) { + workList[index].notifications.add(notif) + workList[index].isRead = if (notif.isRead) true else workList[index].isRead + } else { + workList.add( + MutableNotificationsListItem( + notifications = mutableListOf(notif), + reason = notif.reason, + isRead = notif.isRead, + reasonSubject = notif.reasonSubject + ) + ) + } + } + } + return workList.map { it.toImmutable() } +} + +fun List.filterNotifications( + filter: NotificationsFilterState, +): List { + return this.filter { + (if(it.isRead) filter.showAlreadyRead else true) && + when(it.reason) { + ListNotificationsReason.LIKE -> filter.showLikes + ListNotificationsReason.REPOST -> filter.showReposts + ListNotificationsReason.FOLLOW -> filter.showFollows + ListNotificationsReason.MENTION -> filter.showMentions + ListNotificationsReason.REPLY -> filter.showReplies + ListNotificationsReason.QUOTE -> filter.showQuotes + else -> true + } + }.toList() +} + +@Serializable +data class MutableNotificationsListItem( + val notifications: MutableList = mutableListOf(), + val reason: ListNotificationsReason, + var isRead: Boolean = false, + val reasonSubject: AtUri? = null, +) { + companion object { + fun fromImmutable(item: NotificationsListItem): MutableNotificationsListItem { + return MutableNotificationsListItem( + notifications = item.notifications.distinctBy { it.author.did }.toMutableList(), + reason = item.reason, + isRead = item.isRead, + reasonSubject = item.reasonSubject + ) + } + } + fun toImmutable(): NotificationsListItem { + return NotificationsListItem( + notifications = notifications.distinctBy { it.author.did }, + reason = reason, + isRead = isRead, + reasonSubject = reasonSubject + ) + } +} + +@Serializable +data class NotificationsListItem( + val notifications: List, + val reason: ListNotificationsReason, + val isRead: Boolean, + val reasonSubject: AtUri?, +) { + companion object { + fun fromMutable(item: MutableNotificationsListItem) { + NotificationsListItem( + notifications = item.notifications.distinctBy { it.author.did }, + reason = item.reason, + isRead = item.isRead, + reasonSubject = item.reasonSubject + ) + } + } + + override fun hashCode(): Int { + return notifications.hashCode() + reason.hashCode() + reasonSubject.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as NotificationsListItem + + if (reason != other.reason) return false + if (reasonSubject != other.reasonSubject) return false + if (notifications != other.notifications) return false + + return true + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/Profile.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/Profile.kt index ebc78a5..73ac0b7 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/Profile.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/Profile.kt @@ -3,24 +3,31 @@ package com.morpho.app.model.bluesky import androidx.compose.runtime.Immutable import app.bsky.actor.* +import com.morpho.app.model.uidata.MaybeMomentParceler import com.morpho.app.model.uidata.Moment +import com.morpho.app.model.uidata.MomentParceler import com.morpho.app.util.mapImmutable import com.morpho.butterfly.AtUri import com.morpho.butterfly.Did import com.morpho.butterfly.Handle +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import dev.icerock.moko.parcelize.TypeParceler import kotlinx.collections.immutable.toImmutableList import kotlinx.serialization.Serializable - +@Immutable +@Serializable enum class ProfileType { Basic, Detailed, Service } +@Parcelize @Immutable @Serializable -sealed interface Profile { +sealed interface Profile: Parcelable { val did: Did val handle: Handle val displayName: String? @@ -36,8 +43,10 @@ sealed interface Profile { val knownFollowers: List @Serializable val labels: List - val associated: ProfileAssociated? + val associated: BskyProfileAssociated? + @TypeParceler() val createdAt: Moment? + @TypeParceler() val indexedAt: Moment? val type: ProfileType get() = when (this) { @@ -53,14 +62,17 @@ sealed interface Profile { get() = followedBy != null } +@Parcelize @Immutable @Serializable -data class BlockRecord(val uri: AtUri) +data class BlockRecord(val uri: AtUri): Parcelable +@Parcelize @Immutable @Serializable -data class FollowRecord(val uri: AtUri) +data class FollowRecord(val uri: AtUri): Parcelable +@Parcelize @Immutable @Serializable data class BasicProfile( @@ -79,13 +91,16 @@ data class BasicProfile( override val blockingByList: UserListBasic?, override val numKnownFollowers: Long, override val knownFollowers: List, - override val associated: ProfileAssociated?, + override val associated: BskyProfileAssociated?, + @TypeParceler() override val createdAt: Moment?, -) : Profile { + ) : Profile, Parcelable{ + @TypeParceler() override val indexedAt: Moment? = null } +@Parcelize @Immutable @Serializable data class DetailedProfile( @@ -98,7 +113,9 @@ data class DetailedProfile( val followersCount: Long, val followsCount: Long, val postsCount: Long, + @TypeParceler() override val createdAt: Moment?, + @TypeParceler() override val indexedAt: Moment?, override val mutedByMe: Boolean, override val following: FollowRecord?, @@ -111,7 +128,7 @@ data class DetailedProfile( override val blockingByList: UserListBasic?, override val numKnownFollowers: Long, override val knownFollowers: List, - override val associated: ProfileAssociated?, + override val associated: BskyProfileAssociated?, ) : Profile { fun toSerializableProfile(): SerializableProfile { return SerializableProfile( @@ -142,6 +159,7 @@ data class DetailedProfile( } +@Parcelize @Immutable @Serializable data class SerializableProfile( @@ -154,7 +172,9 @@ data class SerializableProfile( val followersCount: Long, val followsCount: Long, val postsCount: Long, + @TypeParceler() val indexedAt: Moment?, + @TypeParceler() val createdAt: Moment?, val mutedByMe: Boolean, val following: FollowRecord?, @@ -167,8 +187,8 @@ data class SerializableProfile( val blockingByList: UserListBasic?, val numKnownFollowers: Long, val knownFollowers: List, - val associated: ProfileAssociated?, -) { + val associated: BskyProfileAssociated?, +): Parcelable { val type: ProfileType get() = ProfileType.Detailed fun toProfile(): DetailedProfile { @@ -222,7 +242,7 @@ fun ProfileViewDetailed.toProfile(): DetailedProfile { blockingByList = viewer?.blockingByList?.toList(), numKnownFollowers = viewer?.knownFollowers?.count ?: 0, knownFollowers = viewer?.knownFollowers?.followers?.mapImmutable { it.toProfile() }?.toList() ?: listOf(), - associated = associated, + associated = associated?.toBskyProfileAssociated(), createdAt = createdAt?.let(::Moment), ) } @@ -243,7 +263,7 @@ fun ProfileViewBasic.toProfile(): Profile { blockingByList = viewer?.blockingByList?.toList(), numKnownFollowers = viewer?.knownFollowers?.count ?: 0, knownFollowers = viewer?.knownFollowers?.followers?.mapImmutable { it.toProfile() }?.toList() ?: listOf(), - associated = associated, + associated = associated?.toBskyProfileAssociated(), createdAt = createdAt?.let(::Moment), ) } @@ -264,7 +284,7 @@ fun ProfileView.toProfile(): Profile { blockingByList = viewer?.blockingByList?.toList(), numKnownFollowers = viewer?.knownFollowers?.count ?: 0, knownFollowers = viewer?.knownFollowers?.followers?.mapImmutable { it.toProfile() }?.toList() ?: listOf(), - associated = associated, + associated = associated?.toBskyProfileAssociated(), createdAt = createdAt?.let(::Moment), ) } @@ -291,4 +311,37 @@ fun Profile.toProfileViewBasic(): ProfileViewBasic { labels = labels.mapImmutable { it.toAtProtoLabel() }, ) -} \ No newline at end of file +} + +fun ProfileAssociated.toBskyProfileAssociated(): BskyProfileAssociated { + return BskyProfileAssociated( + lists = this.lists, + feedGens = this.feedGens, + labeler = this.labeler, + starterPacks = this.starterPacks, + chat = this.chat?.toProfileAssociatedChat() + ) +} +@Immutable +@Parcelize +@Serializable +public data class BskyProfileAssociated( + public val lists: Long? = null, + public val feedGens: Long? = null, + public val labeler: Boolean? = null, + public val starterPacks: Long? = null, + public val chat: BskyProfileAssociatedChat? = null, +): Parcelable + +fun ProfileAssociatedChat.toProfileAssociatedChat(): BskyProfileAssociatedChat { + return BskyProfileAssociatedChat( + allowIncoming = this.allowIncoming + ) +} + +@Parcelize +@Immutable +@Serializable +public data class BskyProfileAssociatedChat( + public val allowIncoming: AllowIncoming, +): Parcelable \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/Reference.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/Reference.kt index e3b6fec..b11cac8 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/Reference.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/Reference.kt @@ -1,9 +1,11 @@ package com.morpho.app.model.bluesky -import kotlinx.serialization.Serializable +import androidx.compose.runtime.Immutable import com.morpho.butterfly.AtUri import com.morpho.butterfly.Cid +import kotlinx.serialization.Serializable +@Immutable @Serializable data class Reference( val uri: AtUri, diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/ThreadViewPostUnion.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/ThreadViewPostUnion.kt index 7a98811..64b6769 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/ThreadViewPostUnion.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/bluesky/ThreadViewPostUnion.kt @@ -1,48 +1,27 @@ package com.morpho.app.model.bluesky -import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName -import com.morpho.butterfly.valueClassSerializer import kotlinx.serialization.Serializable import kotlin.jvm.JvmInline -@kotlinx.serialization.Serializable +@Serializable public sealed interface ThreadViewPostUnion { - public class ThreadViewPostSerializer : KSerializer by valueClassSerializer( - serialName = "app.bsky.feed.defs#threadViewPost", - constructor = ThreadViewPostUnion::ThreadViewPost, - valueProvider = ThreadViewPost::value, - valueSerializerProvider = { app.bsky.feed.ThreadViewPost.serializer() }, - ) - @Serializable(with = ThreadViewPostSerializer::class) + @Serializable @JvmInline @SerialName("app.bsky.feed.defs#threadViewPost") public value class ThreadViewPost( public val `value`: app.bsky.feed.ThreadViewPost, ) : ThreadViewPostUnion - public class NotFoundPostSerializer : KSerializer by valueClassSerializer( - serialName = "app.bsky.feed.defs#notFoundPost", - constructor = ThreadViewPostUnion::NotFoundPost, - valueProvider = NotFoundPost::value, - valueSerializerProvider = { app.bsky.feed.NotFoundPost.serializer() }, - ) - - @Serializable(with = NotFoundPostSerializer::class) + @Serializable @JvmInline @SerialName("app.bsky.feed.defs#notFoundPost") public value class NotFoundPost( public val `value`: app.bsky.feed.NotFoundPost, ) : ThreadViewPostUnion - public class BlockedPostSerializer : KSerializer by valueClassSerializer( - serialName = "app.bsky.feed.defs#blockedPost", - constructor = ThreadViewPostUnion::BlockedPost, - valueProvider = BlockedPost::value, - valueSerializerProvider = { app.bsky.feed.BlockedPost.serializer() }, - ) - @Serializable(with = BlockedPostSerializer::class) + @Serializable @JvmInline @SerialName("app.bsky.feed.defs#blockedPost") public value class BlockedPost( diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/BskyDataService.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/BskyDataService.kt deleted file mode 100644 index dec70fe..0000000 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/BskyDataService.kt +++ /dev/null @@ -1,1067 +0,0 @@ -package com.morpho.app.model.uidata - -//import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import androidx.compose.ui.util.fastFirstOrNull -import app.bsky.actor.GetProfilesQuery -import app.bsky.actor.ProfileViewBasic -import app.bsky.feed.* -import app.bsky.graph.GetFollowersQuery -import app.bsky.graph.GetFollowsQuery -import app.bsky.graph.GetListsQuery -import app.bsky.labeler.GetServicesQuery -import app.bsky.labeler.GetServicesResponseViewUnion -import com.atproto.repo.GetRecordQuery -import com.atproto.repo.StrongRef -import com.morpho.app.di.UpdateTick -import com.morpho.app.model.bluesky.* -import com.morpho.app.model.bluesky.MorphoDataFeed.Companion.filterByContentLabel -import com.morpho.app.model.bluesky.MorphoDataFeed.Companion.filterByPrefs -import com.morpho.app.model.bluesky.MorphoDataFeed.Companion.filterbyLanguage -import com.morpho.app.model.uistate.ContentCardState -import com.morpho.app.model.uistate.ContentLoadingState -import com.morpho.app.model.uistate.FeedType -import com.morpho.app.util.json -import com.morpho.app.util.mapImmutable -import com.morpho.butterfly.* -import kotlinx.collections.immutable.* -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.encodeToJsonElement -import kotlinx.serialization.json.jsonObject -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.koin.mp.KoinPlatform.getKoin -import org.lighthousegames.logging.logging - -fun initAtCursor(): MutableSharedFlow { - return MutableSharedFlow(1, 1, BufferOverflow.DROP_OLDEST) -} - -suspend fun Flow>>.handleToState( - default: MorphoData, - scope: CoroutineScope = BskyDataService.serviceScope, -): StateFlow> = transform { - if (it.isFailure) { - emit(ContentCardState.Skyline(default, ContentLoadingState.Error(it.exceptionOrNull()?.message ?: "Failed to load feed"), false)) - } else { - emit(ContentCardState.Skyline(it.getOrNull() ?: default, ContentLoadingState.Idle, false)) - } -}.stateIn( - scope, - SharingStarted.Eagerly, - ContentCardState.Skyline(default) -) - -suspend fun Flow>>.handleToState( - profile: Profile, - default: MorphoData, - scope: CoroutineScope = BskyDataService.serviceScope, -): StateFlow> = transform { - if (it.isFailure) { - emit(ContentCardState.ProfileTimeline(profile, default, ContentLoadingState.Error(it.exceptionOrNull()?.message ?: "Failed to load feed"), false)) - } else { - emit(ContentCardState.ProfileTimeline(profile, it.getOrNull() ?: default, ContentLoadingState.Idle, false)) - } -}.stateIn( - scope, - SharingStarted.Eagerly, - ContentCardState.ProfileTimeline(profile, default) -) - -suspend fun getPost(uri: AtUri, api: Butterfly = getKoin().get()): Flow = flow { - val query = GetPostsQuery(persistentListOf(uri)) - api.api.getPosts(query).onSuccess { response -> - emit(response.posts.first().toPost()) - }.onFailure { - BskyDataService.log.e { "Failed to get post at $uri.\nError: $it" } - emit(null) - } -} - -fun getReplyRefs(uri: AtUri, api: Butterfly = getKoin().get()): Flow> = flow { - uri.toParts().onFailure { emit(Result.failure(it)) }.onSuccess { uriParts-> - api.api.getRecord(GetRecordQuery(uriParts.repo, uriParts.collection, uriParts.rkey)) - .onSuccess { parentResponse -> - val parentReply = parentResponse.value.jsonObject["reply"]?.jsonObject - if(parentReply != null) { - val rootUri = parentReply["root"]?.jsonObject?.get("uri")?.recordType - if (rootUri != null) { - AtUri.parseAtUri(rootUri).onFailure { emit(Result.failure(it)) }.onSuccess { parts -> - api.api.getRecord(GetRecordQuery(parts.repo, parts.collection, parts.rkey)) - .onSuccess { rootResponse -> - val rootRef = rootResponse.cid?.let { StrongRef(rootResponse.uri, it) } - val parentRef = parentResponse.cid?.let { StrongRef(parentResponse.uri, it) } - val grandParentAuthor = parentReply["grandparentAuthor"]?.jsonObject?.let { ProfileViewBasic.serializer().deserialize(it) } - if(rootRef != null && parentRef != null) { - emit(Result.success(PostReplyRef(rootRef, parentRef, grandParentAuthor))) - } else { - emit(Result.failure(Error( - "Failed to get reply refs:\nRoot: $rootResponse\nParent: $parentResponse"))) - } - }.onFailure { emit(Result.failure(it)) } - } - - } - } - }.onFailure { emit(Result.failure(it)) } - } - -} - -suspend fun getPosts(posts: List, api: Butterfly = getKoin().get()): Flow?> = flow { - val query = GetPostsQuery(posts.toPersistentList()) - api.api.getPosts(query).onSuccess { response -> - emit(response.posts.mapImmutable { it.toPost() }) - }.onFailure { - BskyDataService.log.e { "Failed to get post.\nError: $it" } - emit(null) - } -} - -@Suppress("unused", "MemberVisibilityCanBePrivate", "UNCHECKED_CAST") -// TODO: Revisit these casts if we can, but they should be safe -class BskyDataService: KoinComponent { - val api: Butterfly by inject() - - private val _dataFlows = mutableMapOf>>() - - private val mutex = Mutex() - private var timelineTuners = persistentListOf( - { posts -> filterByContentLabel(posts, contentLabelService.labelsToHide.value) }, - { posts -> filterbyLanguage(posts, languages.value) }, - ) - private val contentLabelService by inject() - private val languages: StateFlow> = contentLabelService.preferences.prefs - .distinctUntilChanged().map { preferencesList -> - preferencesList?.fastFirstOrNull { - it.user.userDid == api.atpUser?.id?.toString() - }?.preferences?.languages?: persistentListOf(Language("en")) } .stateIn( - serviceScope, - SharingStarted.WhileSubscribed(100), - persistentListOf(Language("en")) - ) - - - // Secondary way to make sure you have the most recent stuff, in case you lose the original reference - val dataFlows: ImmutableMap>> - get() = _dataFlows.mapValues { it.value.asStateFlow() }.toImmutableMap() - - companion object { - val log = logging() - val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - } - - - - suspend fun refresh( - uri: AtUri, - cursor: AtCursor = null, - ): Result>> { - val flow = dataFlows[uri] ?: return Result.failure(Exception("No feed to refresh.")) - val data = flow.value - when(data.feedType) { - FeedType.HOME -> { - try { - val query = json.decodeFromJsonElement(data.query).copy(cursor = cursor) - api.api.getTimeline(query).onSuccess { response -> - - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList().tune(timelineTuners)).last() - val feed = if (cursor != null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(data, newPosts) - } else if (cursor == null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, data) - } else { - newPosts - } - val newData = feed.toMorphoData("Home") - .copy(query = json.encodeToJsonElement(query)) - - mutex.withLock { - _dataFlows[uri]?.update { newData } - } - return Result.success(flow) - } - } catch (e: Exception) { - log.e { "Failed to refresh feed at $uri.\nError: $e" } - return Result.failure(e) - } - } - FeedType.PROFILE_POSTS -> { - try { - val query = json.decodeFromJsonElement(data.query).copy(cursor = cursor) - api.api.getAuthorFeed(query).onSuccess { response -> - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList()).last() - val feed = if (cursor != null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(data, newPosts) - } else if (cursor == null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, data) - } else { - newPosts - } - val newData = feed.toMorphoData("Posts") - .copy(query = json.encodeToJsonElement(query)) - mutex.withLock { - _dataFlows[uri]?.update { newData } - } - return Result.success(flow) - } - } catch (e: Exception) { - log.e { "Failed to refresh feed at $uri.\nError: $e" } - return Result.failure(e) - } - } - FeedType.PROFILE_REPLIES -> { - try { - val query = Json.decodeFromJsonElement(data.query).copy(cursor = cursor) - api.api.getAuthorFeed(query).onSuccess { response -> - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList()).last() - val feed = if (cursor != null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(data, newPosts) - } else if (cursor == null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, data) - } else { - newPosts - } - val newData = feed.toMorphoData("Replies") - .copy(query = json.encodeToJsonElement(query)) - mutex.withLock { - _dataFlows[uri]?.update { newData } - } - return Result.success(flow) - } - } catch (e: Exception) { - log.e { "Failed to refresh feed at $uri.\nError: $e" } - return Result.failure(e) - } - } - FeedType.PROFILE_MEDIA -> { - try { - val query = json.decodeFromJsonElement(data.query).copy(cursor = cursor) - api.api.getAuthorFeed(query).onSuccess { response -> - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList()).last() - val feed = if (cursor != null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(data, newPosts) - } else if (cursor == null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, data) - } else { - newPosts - } - val newData = feed.toMorphoData("Media") - .copy(query = json.encodeToJsonElement(query)) - mutex.withLock { - _dataFlows[uri]?.update { newData } - } - return Result.success(flow) - } - } catch (e: Exception) { - log.e { "Failed to refresh feed at $uri.\nError: $e" } - return Result.failure(e) - } - } - FeedType.PROFILE_LIKES -> { - try { - val query = json.decodeFromJsonElement(data.query).copy(cursor = cursor) - api.api.getActorLikes(query).onSuccess { response -> - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList()).last() - val feed = if (cursor != null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(data, newPosts) - } else if (cursor == null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, data) - } else { - newPosts - } - val newData = feed.toMorphoData("Likes") - .copy(query = json.encodeToJsonElement(query)) - mutex.withLock { - _dataFlows[uri]?.update { newData } - } - return Result.success(flow) - } - } catch (e: Exception) { - log.e { "Failed to refresh feed at $uri.\nError: $e" } - return Result.failure(e) - } - } - FeedType.PROFILE_USER_LISTS -> { - try { - val query = json.decodeFromJsonElement(data.query).copy(cursor = cursor) - api.api.getLists(query).onSuccess { response -> - val newData = if (cursor != null && data.items.isNotEmpty()) { - MorphoData.concat(data, response.lists.mapImmutable { MorphoDataItem.ListInfo(it.toList()) }) - } else if (cursor == null && data.items.isNotEmpty()) { - MorphoData.concat(response.lists.mapImmutable { MorphoDataItem.ListInfo(it.toList()) }, data) - } else { - MorphoData("Lists", uri, response.cursor, - response.lists.mapImmutable { MorphoDataItem.ListInfo(it.toList()) }) - }.copy(query = json.encodeToJsonElement(query)) - mutex.withLock { - _dataFlows[uri]?.update { newData as MorphoData} - } - return Result.success(flow) - } - } catch (e: Exception) { - log.e { "Failed to refresh feed at $uri.\nError: $e" } - return Result.failure(e) - } - } - FeedType.PROFILE_MOD_SERVICE -> { - try { - val query = json.decodeFromJsonElement(data.query) - api.api.getServices(query).onSuccess { response -> - val newData = if (cursor != null && data.items.isNotEmpty()) { - MorphoData.concat(data, response.views.mapImmutable { - when(it) { - is GetServicesResponseViewUnion.LabelerViewDetailed -> - MorphoDataItem.LabelService(it.value.toLabelService()) - is GetServicesResponseViewUnion.LabelerView -> - MorphoDataItem.LabelService(it.value.toLabelService()) - } - }) - } else if (cursor == null && data.items.isNotEmpty()) { - MorphoData.concat(response.views.mapImmutable { - when(it) { - is GetServicesResponseViewUnion.LabelerViewDetailed -> - MorphoDataItem.LabelService(it.value.toLabelService()) - is GetServicesResponseViewUnion.LabelerView -> - MorphoDataItem.LabelService(it.value.toLabelService()) - } - }, data) - } else { - MorphoData("Services", uri, null, - response.views.mapImmutable { - when(it) { - is GetServicesResponseViewUnion.LabelerViewDetailed -> - MorphoDataItem.LabelService(it.value.toLabelService()) - is GetServicesResponseViewUnion.LabelerView -> - MorphoDataItem.LabelService(it.value.toLabelService()) - } - }) - }.copy(query = json.encodeToJsonElement(query)) - mutex.withLock { - _dataFlows[uri]?.update { newData as MorphoData} - } - return Result.success(flow) - } - } catch (e: Exception) { - log.e { "Failed to refresh feed at $uri.\nError: $e" } - return Result.failure(e) - } - } - FeedType.PROFILE_FEEDS_LIST -> { - try { - val query = json.decodeFromJsonElement(data.query).copy(cursor = cursor) - api.api.getActorFeeds(query).onSuccess { response -> - val newData = if (cursor != null && data.items.isNotEmpty()) { - MorphoData.concat(data, response.feeds.mapImmutable { MorphoDataItem.FeedInfo(it.toFeedGenerator()) }) - } else if (cursor == null && data.items.isNotEmpty()) { - MorphoData.concat(response.feeds.mapImmutable { MorphoDataItem.FeedInfo(it.toFeedGenerator()) }, data) - } else { - MorphoData("Feeds", uri, response.cursor, - response.feeds.mapImmutable { MorphoDataItem.FeedInfo(it.toFeedGenerator()) }) - }.copy(query = json.encodeToJsonElement(query)) - mutex.withLock { - _dataFlows[uri]?.update { newData as MorphoData} - } - return Result.success(flow) - } - } catch (e: Exception) { - log.e { "Failed to refresh feed at $uri.\nError: $e" } - return Result.failure(e) - } - } - FeedType.OTHER -> { - try { - val query = json.decodeFromJsonElement(data.query).copy(cursor = cursor) - api.api.getFeed(query).onSuccess { response -> - val tuners = persistentListOf() - - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList().tune(tuners)).last() - val feed = if (cursor != null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(data, newPosts) - } else if (cursor == null && data.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, data) - } else { - newPosts - } - val newData = feed.toMorphoData(data.title) - .copy(query = json.encodeToJsonElement(query)) - mutex.withLock { - _dataFlows[uri]?.update { newData } - } - return Result.success(flow) - } - } catch (e: Exception) { - log.e { "Failed to refresh feed at $uri.\nError: $e" } - return Result.failure(e) - } - } - } - return Result.failure(Exception("Invalid feed type.")) - } - @OptIn(FlowPreview::class) - suspend fun timeline( - cursor: SharedFlow, - limit: Long = 50, - feedPref: StateFlow = MutableStateFlow(BskyFeedPref()), - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow>> = flow { - cursor.debounce(300).combine(feedPref) { c, f -> c to f } - .collect { flows -> - //log.d { "Timeline flow tick." } - val (cur, pref) = flows - val prev = dataFlows[AtUri.HOME_URI]?.value - val query = GetTimelineQuery(limit = limit, cursor = cur) - api.api.getTimeline(query).onSuccess { response -> - val tuners = persistentListOf() - tuners.add { posts -> filterByPrefs(posts, pref) } - - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList().tune(tuners)).last() - val feed = if (cur != null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(prev, newPosts) - } else if (cur == null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, prev) - } else { - newPosts - } - val data = feed.toMorphoData("Home") - .copy(query = json.encodeToJsonElement(query)) - - emit(Result.success(data as MorphoData)) - log.d{ - "Timeline " + - "Old cursor: $cur " + - "New cursor: ${response.cursor}" - } - log.v { - "${data.items.map { - when(it) { - is MorphoDataItem.Post -> "${it.post.uri}\n" - is MorphoDataItem.Thread -> "${it.thread.post.uri}\n" - } - }}" - } - mutex.withLock { - if(prev == null) _dataFlows[AtUri.HOME_URI] = MutableStateFlow(data) - else _dataFlows[AtUri.HOME_URI]?.update { data } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get timeline.\nError: $it" } - log.v { "Cursor: $cur | Limit: $limit\nFeedPref: $pref\n" } - } - } - }.distinctUntilChanged().flowOn(dispatcher + CoroutineName("Timeline")) - //.stateIn(scope, SharingStarted.WhileSubscribed(100), Result.success( - // MorphoData("Home", AtUri.HOME_URI, null) - //)) - - @OptIn(FlowPreview::class) - suspend fun feed( - feedInfo: FeedInfo, - cursor: SharedFlow, - limit: Long = 50, - feedPref: StateFlow = MutableStateFlow(null), - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow>> = flow { - cursor.debounce(300).combine(feedPref) { c, f -> c to f } - .collect { flows -> - //log.d { "Feed flow tick."} - val cur = flows.first - val pref = flows.second - val prev = dataFlows[feedInfo.uri]?.value - val query = GetFeedQuery(feedInfo.uri, limit, cur) - api.api.getFeed(query).onSuccess { response -> - val tuners = persistentListOf() - if (pref != null) tuners.add { posts -> filterByPrefs(posts, pref) } - - val newPosts = MorphoDataFeed - .collectThreads( - api, - response.cursor, - response.feed.toBskyPostList().tune(tuners), - feedInfo.uri - ).last() - val feed = if (cur != null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(prev, newPosts) - } else if (cur == null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, prev) - } else { - newPosts - } - val data = feed.toMorphoData(feedInfo.name) - .copy(query = json.encodeToJsonElement(query)) - - emit(Result.success(data as MorphoData)) - log.d{ - "Feed: ${feedInfo.name} " + - "Old cursor: $cur " + - "New cursor: ${response.cursor}" - } - log.v { - "${data.items.map { - when(it) { - is MorphoDataItem.Post -> it.post.uri - is MorphoDataItem.Thread -> it.thread.post.uri - } - }}" - } - mutex.withLock { - if(prev == null) _dataFlows[feedInfo.uri] = MutableStateFlow(data) - else _dataFlows[feedInfo.uri]?.update { data } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get feed at ${feedInfo.uri}.\nError: $it" } - log.v { "Cursor: $cur | Limit: $limit\nFeedPref: $pref" } - } - } - }.distinctUntilChanged().flowOn(dispatcher) - - suspend fun following( - id: AtIdentifier, - cursor: SharedFlow, - limit: Long = 50, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow>> = flow { - val uri = AtUri.followsUri(id) - cursor.collect { cur -> - val prev = dataFlows[uri]?.value - val query = GetFollowsQuery(id, limit, cur) - api.api.getFollows(query).onSuccess { response -> - val data = if (cur != null && prev != null && prev.items.isNotEmpty()) { - MorphoData.concat(prev, response.follows.mapImmutable { MorphoDataItem.ProfileItem(it.toProfile()) }) - } else if (cur == null && prev != null && prev.items.isNotEmpty()) { - MorphoData.concat(response.follows.mapImmutable { MorphoDataItem.ProfileItem(it.toProfile()) }, prev) - } else { - MorphoData("Following", uri, response.cursor, - response.follows.mapImmutable { MorphoDataItem.ProfileItem(it.toProfile()) }) - }.copy(query = json.encodeToJsonElement(query)) - emit(Result.success(data as MorphoData)) - mutex.withLock { - if(prev == null) _dataFlows[uri] = MutableStateFlow(data as MorphoData) - else _dataFlows[uri]?.update { data as MorphoData } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get follows for $id.\nError: $it" } - log.v { "Cursor: $cur | Limit: $limit" } - } - } - }.distinctUntilChanged().flowOn(dispatcher + CoroutineName("Follows of $id")) - - suspend fun followers( - id: AtIdentifier, - cursor: SharedFlow, - limit: Long = 50, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow>> = flow { - val uri = AtUri.followersUri(id) - cursor.collect { cur -> - val prev = dataFlows[uri]?.value - val query = GetFollowersQuery(id, limit, cur) - api.api.getFollowers(query).onSuccess { response -> - val data = if (cur != null && prev != null && prev.items.isNotEmpty()) { - MorphoData.concat(prev, response.followers.mapImmutable { MorphoDataItem.ProfileItem(it.toProfile()) }) - } else if (cur == null && prev != null && prev.items.isNotEmpty()) { - MorphoData.concat(response.followers.mapImmutable { MorphoDataItem.ProfileItem(it.toProfile()) }, prev) - } else { - MorphoData("Following", uri, response.cursor, - response.followers.mapImmutable { MorphoDataItem.ProfileItem(it.toProfile()) }) - }.copy(query = json.encodeToJsonElement(query)) - emit(Result.success(data as MorphoData)) - mutex.withLock { - if(prev == null) _dataFlows[uri] = MutableStateFlow(data as MorphoData) - else _dataFlows[uri]?.update { data as MorphoData } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get followers for $id.\nError: $it" } - log.v { "Cursor: $cur | Limit: $limit" } - } - } - }.distinctUntilChanged().flowOn(dispatcher + CoroutineName("Followers of $id")) - suspend fun authorFeed( - id: AtIdentifier, - type: FeedType, - cursor: SharedFlow, - limit: Long = 50, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow>> = flow>> { - when(type){ - FeedType.PROFILE_POSTS -> { - val uri = AtUri.profilePostsUri(id) - cursor.collect { cur -> - val prev = dataFlows[uri]?.value - val query = GetAuthorFeedQuery(id, limit, cur, GetAuthorFeedFilter.POSTS_NO_REPLIES) - api.api.getAuthorFeed(query).onSuccess { response -> - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList()).last() - val feed = if (cur != null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(prev, newPosts) - } else if (cur == null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, prev) - } else { - newPosts - } - val data = feed.toMorphoData("Posts", uri) - .copy(query = json.encodeToJsonElement(query)) - emit(Result.success(data as MorphoData)) - mutex.withLock { - if(prev == null) _dataFlows[uri] = MutableStateFlow(data) - else _dataFlows[uri]?.update { data } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get posts feed for $id.\nError: $it" } - log.v { "Cursor: $cur | Limit: $limit\n" } - } - } - } - FeedType.PROFILE_REPLIES -> { - val uri = AtUri.profileRepliesUri(id) - cursor.collect { cur -> - val prev = dataFlows[uri]?.value - val query = GetAuthorFeedQuery(id, limit, cur, GetAuthorFeedFilter.POSTS_WITH_REPLIES) - api.api.getAuthorFeed(query).onSuccess { response -> - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList()).last() - val feed = if (cur != null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(prev, newPosts) - } else if (cur == null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, prev) - } else { - newPosts - } - val data = feed.toMorphoData("Replies", uri) - .copy(query = json.encodeToJsonElement(query)) - emit(Result.success(data as MorphoData)) - mutex.withLock { - if(prev == null) _dataFlows[uri] = MutableStateFlow(data) - else _dataFlows[uri]?.update { data } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get reply feed of $id.\nError: $it" } - log.v { "Cursor: $cur | Limit: $limit\n" } - } - } - } - FeedType.PROFILE_MEDIA -> { - val uri = AtUri.profileMediaUri(id) - cursor.collect { cur -> - val prev = dataFlows[uri]?.value - val query = GetAuthorFeedQuery(id, limit, cur, GetAuthorFeedFilter.POSTS_WITH_MEDIA) - api.api.getAuthorFeed(query).onSuccess { response -> - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList()).last() - val feed = if (cur != null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(prev, newPosts) - } else if (cur == null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, prev) - } else { - newPosts - } - val data = feed.toMorphoData("Media", uri) - .copy(query = json.encodeToJsonElement(query)) - emit(Result.success(data as MorphoData)) - mutex.withLock { - if(prev == null) _dataFlows[uri] = MutableStateFlow(data) - else _dataFlows[uri]?.update { data } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get media feed of $id.\nError: $it" } - log.v { "Cursor: $cur | Limit: $limit\n" } - } - } - } - else -> { - emit(Result.failure(Exception("Invalid profile tab type."))) - } - } - }.distinctUntilChanged().flowOn(dispatcher + CoroutineName("${type.name} feed for $id")) - suspend fun profileTabContent( - id: AtIdentifier, - type: FeedType, - cursor: SharedFlow, - limit: Long = 50, - dispatcher: CoroutineDispatcher = Dispatchers.Default, - scope: CoroutineScope = serviceScope, - ): Flow>> = flow { - when(type) { - FeedType.PROFILE_FEEDS_LIST -> { - profileFeedsList(id, cursor, limit, dispatcher) - .collect { emit(it as Result>) } - } - FeedType.PROFILE_USER_LISTS -> { - profileLists(id, cursor, limit, dispatcher) - .collect { emit(it as Result>) } - } - FeedType.PROFILE_LIKES -> { - profileLikes(id, cursor, limit, dispatcher) - .collect { emit(it as Result>) } - } - FeedType.PROFILE_MOD_SERVICE -> { - if (id.toString().startsWith("did:")) - profileServiceView(Did(id.toString()), cursor.map { Unit }.shareIn(scope, SharingStarted.Lazily), dispatcher) - .collect { emit(it as Result>) } - } - else -> { - authorFeed(id, type, cursor, limit, dispatcher) - .collect { emit(it as Result>) } - } - } - }.distinctUntilChanged().flowOn(dispatcher + CoroutineName("${type.name} content for $id")) - suspend fun profileLists( - id: AtIdentifier, - cursor: SharedFlow, - limit: Long = 50, - dispatcher: CoroutineDispatcher = Dispatchers.Default, - ): Flow>> = flow>> { - val uri = AtUri.profileUserListsUri(id) - cursor.collect { cur -> - val prev = dataFlows[uri]?.value - val query = GetListsQuery(id, limit, cur) - api.api.getLists(query).onSuccess { response -> - val data = if (cur != null && prev != null && prev.items.isNotEmpty()) { - MorphoData.concat(prev, response.lists.mapImmutable { MorphoDataItem.ListInfo(it.toList()) }) - } else if (cur == null && prev != null && prev.items.isNotEmpty()) { - MorphoData.concat(response.lists.mapImmutable { MorphoDataItem.ListInfo(it.toList()) }, prev) - } else { - MorphoData("Lists", uri, response.cursor, - response.lists.mapImmutable { MorphoDataItem.ListInfo(it.toList()) }) - }.copy(query = json.encodeToJsonElement(query)) - - emit(Result.success(data as MorphoData)) - mutex.withLock { - if(prev == null) _dataFlows[uri] = MutableStateFlow(data as MorphoData) - else _dataFlows[uri]?.update { data as MorphoData } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get lists for $id.\nError: $it" } - log.v { "Cursor: $cur | Limit: $limit" } - } - } - }.distinctUntilChanged().flowOn(dispatcher + CoroutineName("Lists made by $id")) - suspend fun profileFeedsList( - id: AtIdentifier, - cursor: SharedFlow, - limit: Long = 50, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow>> = flow>> { - val uri = AtUri.profileFeedsListUri(id) - cursor.onEach { cur -> - val prev = dataFlows[uri]?.value - val query = GetActorFeedsQuery(id, limit, cur) - api.api.getActorFeeds(query).onSuccess { response -> - val data = if (cur != null && prev != null && prev.items.isNotEmpty()) { - MorphoData.concat(prev, response.feeds.mapImmutable { MorphoDataItem.FeedInfo(it.toFeedGenerator()) }) - } else if (cur == null && prev != null && prev.items.isNotEmpty()) { - MorphoData.concat(response.feeds.mapImmutable { MorphoDataItem.FeedInfo(it.toFeedGenerator()) }, prev) - } else { - MorphoData("Feeds", uri, response.cursor, - response.feeds.mapImmutable { MorphoDataItem.FeedInfo(it.toFeedGenerator()) }) - }.copy(query = json.encodeToJsonElement(query)) - - emit(Result.success(data as MorphoData)) - mutex.withLock { - if(prev == null) _dataFlows[uri] = MutableStateFlow(data as MorphoData) - else _dataFlows[uri]?.update { data as MorphoData } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get feeds for $id.\nError: $it" } - log.v { "Cursor: $cur | Limit: $limit" } - } - } - }.distinctUntilChanged().flowOn(dispatcher + CoroutineName("Feeds made by $id")) - suspend fun profileServiceView( - did: Did, - update: SharedFlow, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow>> = flow>> { - val uri = AtUri.profileModServiceUri(did) - update.collect { - val query = GetServicesQuery(listOf(did).toImmutableList(), true) - api.api.getServices(query).onSuccess { response -> - val data = MorphoData("Labels", uri, null, - response.views.mapImmutable { - when(it) { - is GetServicesResponseViewUnion.LabelerViewDetailed -> - MorphoDataItem.LabelService(it.value.toLabelService()) - is GetServicesResponseViewUnion.LabelerView -> - MorphoDataItem.LabelService(it.value.toLabelService()) - } - }) - - emit(Result.success(data)) - mutex.withLock { - if(dataFlows[uri] == null) _dataFlows[uri] = MutableStateFlow(data as MorphoData) - else _dataFlows[uri]?.update { data as MorphoData } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get label services for $did.\nError: $it" } - } - } - }.distinctUntilChanged() - .flowOn(dispatcher + CoroutineName("Label Services of $did")) - - suspend fun profileLikes( - id: AtIdentifier, - cursor: SharedFlow, - limit: Long = 50, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow>> = flow>> { - val uri = AtUri.profileUserListsUri(id) - cursor.collect { cur -> - val prev = dataFlows[uri]?.value - - val query = GetActorLikesQuery(id, limit, cur) - api.api.getActorLikes(query) .onSuccess { response -> - val newPosts = MorphoDataFeed - .collectThreads(api, response.cursor, response.feed.toBskyPostList()).last() - val feed = if (cur != null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(prev, newPosts) - } else if (cur == null && prev != null && prev.items.isNotEmpty()) { - MorphoDataFeed.concat(newPosts, prev) - } else { - newPosts - } - val data = feed.toMorphoData("Likes").copy(query = json.encodeToJsonElement(query)) - - emit(Result.success(data as MorphoData)) - mutex.withLock { - if(prev == null) _dataFlows[uri] = MutableStateFlow(data) - else _dataFlows[uri]?.update { data } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get likes for $id.\nError: $it" } - log.v { "Cursor: $cur | Limit: $limit" } - } - - } - }.distinctUntilChanged() - .flowOn(dispatcher + CoroutineName("Likes of $id")) - - suspend fun profiles( - profiles: List, - update: SharedFlow, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow>> = flow>> { - val uri = AtUri.myUserListUri(profiles.hashCode().toString()) - update.collect { - val query = GetProfilesQuery(profiles.toPersistentList()) - api.api.getProfiles(query).onSuccess { response -> - - val data = MorphoData("Profiles", uri, null, - response.profiles.mapImmutable { MorphoDataItem.ProfileItem(it.toProfile()) }, - json.encodeToJsonElement(query)) - - emit(Result.success(data)) - mutex.withLock { - if(dataFlows[uri] == null) _dataFlows[uri] = MutableStateFlow(data as MorphoData) - else _dataFlows[uri]?.update { data as MorphoData } - } - }.onFailure { - emit(Result.failure(it)) - log.e { "Failed to get profiles.\nError: $it" } - log.v { "$profiles" } - } - } - }.distinctUntilChanged().flowOn(dispatcher) - suspend fun peekLatest( - feed: MorphoData, - update: SharedFlow = MutableSharedFlow(), - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow = flow { - update.collect { - when(feed.feedType) { - FeedType.HOME -> { - val query = GetTimelineQuery(limit = 1, cursor = null) - api.api.getTimeline(query).onSuccess { response -> - if (response.feed.isNotEmpty()) { - val cid = response.feed.first().post.cid - if (!feed.contains(cid)) { - emit(MorphoDataItem.Post(response.feed.first().toPost())) - } else { - emit(null) - } - } - }.onFailure { emit(null) } - } - FeedType.PROFILE_POSTS -> { - val query = GetAuthorFeedQuery(feed.uri.id(api), 1, null, GetAuthorFeedFilter.POSTS_NO_REPLIES) - api.api.getAuthorFeed(query) - .onSuccess { response -> - if (response.feed.isNotEmpty()) { - val cid = response.feed.first().post.cid - if (!feed.contains(cid)) { - emit(MorphoDataItem.Post(response.feed.first().toPost())) - } else { - emit(null) - } - } - }.onFailure { emit(null) } - } - FeedType.PROFILE_REPLIES -> { - val query = GetAuthorFeedQuery(feed.uri.id(api), 1, null, GetAuthorFeedFilter.POSTS_WITH_REPLIES) - api.api.getAuthorFeed(query) - .onSuccess { response -> - if (response.feed.isNotEmpty()) { - val cid = response.feed.first().post.cid - if (!feed.contains(cid)) { - emit(MorphoDataItem.Post(response.feed.first().toPost())) - } else { - emit(null) - } - } - }.onFailure { emit(null) } - } - FeedType.PROFILE_MEDIA -> { - val query = GetAuthorFeedQuery(feed.uri.id(api), 1, null, GetAuthorFeedFilter.POSTS_WITH_MEDIA) - api.api.getAuthorFeed(query) - .onSuccess { response -> - if (response.feed.isNotEmpty()) { - val cid = response.feed.first().post.cid - if (!feed.contains(cid)) { - emit(MorphoDataItem.Post(response.feed.first().toPost())) - } else { - emit(null) - } - } - }.onFailure { emit(null) } - } - FeedType.PROFILE_LIKES -> { - val query = GetActorLikesQuery(feed.uri.id(api), 1, null) - api.api.getActorLikes(query) - .onSuccess { response -> - if (response.feed.isNotEmpty()) { - val cid = response.feed.first().post.cid - if (!feed.contains(cid)) { - emit(MorphoDataItem.Post(response.feed.first().toPost())) - } else { - emit(null) - } - } - }.onFailure { emit(null) } - } - FeedType.PROFILE_USER_LISTS -> { - val query = GetListsQuery(feed.uri.id(api), 1) - api.api.getLists(query) - .onSuccess { response -> - if (response.lists.isNotEmpty()) { - val cid = response.lists.first().cid - if (!feed.contains(cid)) { - emit(MorphoDataItem.ListInfo(response.lists.first().toList())) - } else { - emit(null) - } - } - }.onFailure { emit(null) } - } - FeedType.PROFILE_MOD_SERVICE -> { - val id = feed.uri.id(api) - if(Did.Regex.matches(id.toString())) emit(null) - else { - val query = GetServicesQuery(persistentListOf(Did(id.toString())), true) - api.api.getServices(query) - .onSuccess { response -> - if (response.views.isNotEmpty()) { - when(response.views.first()) { - is GetServicesResponseViewUnion.LabelerViewDetailed -> { - val cid = (response.views.first() as GetServicesResponseViewUnion.LabelerViewDetailed).value.cid - if (!feed.contains(cid)) { - emit(MorphoDataItem.LabelService((response.views.first() as GetServicesResponseViewUnion.LabelerViewDetailed).value.toLabelService())) - } else { - emit(null) - } - } - - is GetServicesResponseViewUnion.LabelerView -> { - val cid = (response.views.first() as GetServicesResponseViewUnion.LabelerView).value.cid - if (!feed.contains(cid)) { - emit(MorphoDataItem.LabelService((response.views.first() as GetServicesResponseViewUnion.LabelerView).value.toLabelService())) - } else { - emit(null) - } - } - } - } - }.onFailure { emit(null) } - } - } - FeedType.PROFILE_FEEDS_LIST -> { - val query = GetActorFeedsQuery(feed.uri.id(api), 1) - api.api.getActorFeeds(query) - .onSuccess { response -> - if (response.feeds.isNotEmpty()) { - val cid = response.feeds.first().cid - if (!feed.contains(cid)) { - emit(MorphoDataItem.FeedInfo(response.feeds.first().toFeedGenerator())) - } else { - emit(null) - } - } - }.onFailure { emit(null) } - } - FeedType.OTHER -> { - // assume it's a custom feed for now, but we should probably add more types - val query = GetFeedQuery(feed.uri, 1) - api.api.getFeed(query) - .onSuccess { response -> - if (response.feed.isNotEmpty()) { - val cid = response.feed.first().post.cid - if (!feed.contains(cid)) { - emit(MorphoDataItem.Post(response.feed.first().toPost())) - } else { - emit(null) - } - } - }.onFailure { emit(null) } - } - } - } - }.distinctUntilChanged().flowOn(dispatcher) - - - - fun checkIfNewTimeline( - interval: Long = 60000, - dispatcher: CoroutineDispatcher = Dispatchers.IO - ): Flow = flow { - val updateTick = UpdateTick(interval) - updateTick.tick(true) - updateTick.t.collect { - val query = GetTimelineQuery(limit = 1, cursor = null) - api.api.getTimeline(query).onSuccess { response -> - if (response.feed.isNotEmpty()) { - val cid = response.feed.first().post.cid - if (dataFlows[AtUri.HOME_URI]?.value?.contains(cid) == false) { - emit(true) - } else { - emit(false) - } - } - }.onFailure { emit(false) } - } - }.distinctUntilChanged().flowOn(dispatcher) - - fun removeFeed(uri: AtUri): MorphoData? { - return _dataFlows.remove(uri)?.value - } -} diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/BskyNotificationService.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/BskyNotificationService.kt deleted file mode 100644 index e6a523a..0000000 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/BskyNotificationService.kt +++ /dev/null @@ -1,156 +0,0 @@ -package com.morpho.app.model.uidata - -import app.bsky.notification.GetUnreadCountQuery -import app.bsky.notification.ListNotificationsQuery -import app.bsky.notification.ListNotificationsReason -import app.bsky.notification.UpdateSeenRequest -import com.morpho.app.di.UpdateTick -import com.morpho.app.model.bluesky.NotificationsList -import com.morpho.app.model.bluesky.NotificationsListItem -import com.morpho.app.model.bluesky.toBskyNotification -import com.morpho.app.model.uistate.NotificationsFilterState -import com.morpho.app.util.mapImmutable -import com.morpho.butterfly.AtUri -import com.morpho.butterfly.Butterfly -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.datetime.Clock -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.lighthousegames.logging.logging - -class BskyNotificationService: KoinComponent { - val api: Butterfly by inject() - - private val mutex = Mutex() - - private var _notifications = MutableStateFlow(NotificationsList()) - - val notifications: StateFlow - get() = _notifications.asStateFlow() - - private var _filter = MutableStateFlow(NotificationsFilterState()) - - val filter: StateFlow - get() = _filter.asStateFlow() - - - companion object { - val log = logging() - val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - } - - fun updateFilter(filterState: NotificationsFilterState) = serviceScope.launch { - mutex.withLock { - _filter.update { filterState } - } - } - - fun updateNotificationsSeen() = serviceScope.launch { - api.api.updateSeen(UpdateSeenRequest(Clock.System.now())) - _notifications.update { it.markAllRead() } - } - - fun markAsRead(uri: AtUri) = serviceScope.launch { - _notifications.update { it.markRead(uri) } - } - - fun getUnreadCountLocal(): Long { - return _notifications.value.notificationsList.count { !it.isRead }.toLong() - } - - suspend fun getUnreadCount(): Result { - return api.api.getUnreadCount(GetUnreadCountQuery(Clock.System.now())).map { - log.d { "Unread count: ${it.count}" } - it.count - } - } - - @OptIn(FlowPreview::class) - suspend fun unreadCount( - update: SharedFlow, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow = flow { - update.debounce(300).collect { - getUnreadCount().onSuccess { - emit(it) - }.onFailure { - log.e { "Failed to get unread count: $it" } - log.e { "Falling back to local" } - emit(getUnreadCountLocal()) - } - } - }.distinctUntilChanged().flowOn(dispatcher + CoroutineName("UnreadCount")) - - suspend fun unreadNotifTick(interval: Long = 60000): SharedFlow = flow { - val updateTick = UpdateTick(interval) - updateTick.tick(true) - updateTick.t.collect { - emit(Unit) - } - }.shareIn(serviceScope, SharingStarted.WhileSubscribed(), 1) - - fun unreadCountFlow( - interval: Long = 60000, - dispatcher: CoroutineDispatcher = Dispatchers.IO - ): Flow = flow { - unreadCount(unreadNotifTick(interval), dispatcher).collect { - emit(it) - } - }.distinctUntilChanged().flowOn(dispatcher + CoroutineName("UnreadCountFlow")) - - - @OptIn(FlowPreview::class) - suspend fun notifications( - cursor: SharedFlow = initAtCursor(), - limit: Long = 50, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Flow> = flow { - cursor.debounce(300).collect { cursor -> - val query = ListNotificationsQuery(limit, cursor) - val result = api.api.listNotifications(query).map { response -> - if(notifications.value.notificationsList.isNotEmpty()) { - if (cursor == null) { - NotificationsList( - response.notifications.mapImmutable { it.toBskyNotification() }, - response.cursor - ).concat(notifications.value) - } else { - notifications.value.concat(response.notifications) - } - } else { - NotificationsList( - response.notifications.mapImmutable { it.toBskyNotification() }, - response.cursor - ) - } - } - if (result.isSuccess) { - mutex.withLock { - _notifications.update { result.getOrThrow() } - } - } else log.e { "Failed to get notifications: ${result.exceptionOrNull()}" } - emit(result) - } - }.distinctUntilChanged().flowOn(dispatcher + CoroutineName("Notifications")) -} - -fun filterNotifications( - list: List, - filter: NotificationsFilterState, -): List { - return list.filter { - (if(it.isRead) filter.showAlreadyRead else true) && - when(it.reason) { - ListNotificationsReason.LIKE -> filter.showLikes - ListNotificationsReason.REPOST -> filter.showReposts - ListNotificationsReason.FOLLOW -> filter.showFollows - ListNotificationsReason.MENTION -> filter.showMentions - ListNotificationsReason.REPLY -> filter.showReplies - ListNotificationsReason.QUOTE -> filter.showQuotes - else -> true - } - }.toList() -} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ContentCardMapEntry.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ContentCardMapEntry.kt index c31e214..3aa369d 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ContentCardMapEntry.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ContentCardMapEntry.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Immutable import com.morpho.app.model.uistate.FeedType import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.serialization.Serializable @Immutable @@ -12,7 +11,6 @@ import kotlinx.serialization.Serializable sealed interface ContentCardMapEntry { val uri: AtUri val title: String - val cursorFlow: MutableSharedFlow val avatar: String? @Immutable @@ -20,7 +18,6 @@ sealed interface ContentCardMapEntry { data object Home: ContentCardMapEntry, Skyline { override val uri: AtUri = AtUri.HOME_URI override val title: String = "Home" - override val cursorFlow: MutableSharedFlow = initAtCursor() override val avatar: String? = null } @@ -33,7 +30,14 @@ sealed interface ContentCardMapEntry { data class Feed( override val uri: AtUri, override val title: String = uri.atUri, - override val cursorFlow: MutableSharedFlow = initAtCursor(), + override val avatar: String? = null, + ) : ContentCardMapEntry, Skyline + + @Immutable + @Serializable + data class ListFeed( + override val uri: AtUri, + override val title: String = uri.atUri, override val avatar: String? = null, ) : ContentCardMapEntry, Skyline @@ -42,7 +46,6 @@ sealed interface ContentCardMapEntry { data class PostThread( override val uri: AtUri, override val title: String = uri.atUri, - override val cursorFlow: MutableSharedFlow = initAtCursor(), override val avatar: String? = null, ) : ContentCardMapEntry @@ -51,7 +54,6 @@ sealed interface ContentCardMapEntry { data class UserList( override val uri: AtUri, override val title: String = uri.atUri, - override val cursorFlow: MutableSharedFlow = initAtCursor(), override val avatar: String? = null, ) : ContentCardMapEntry @@ -60,7 +62,6 @@ sealed interface ContentCardMapEntry { data class FeedList( override val uri: AtUri, override val title: String = uri.atUri, - override val cursorFlow: MutableSharedFlow = initAtCursor(), override val avatar: String? = null, ) : ContentCardMapEntry @@ -69,7 +70,6 @@ sealed interface ContentCardMapEntry { data class ServiceList( override val uri: AtUri, override val title: String = uri.atUri, - override val cursorFlow: MutableSharedFlow = initAtCursor(), override val avatar: String? = null, ) : ContentCardMapEntry @@ -79,7 +79,6 @@ sealed interface ContentCardMapEntry { val id: AtIdentifier, override val uri: AtUri = AtUri.profileUri(id), override val title: String = uri.atUri, - override val cursorFlow: MutableSharedFlow = initAtCursor(), override val avatar: String? = null, ) : ContentCardMapEntry diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ContentLabelService.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ContentLabelService.kt index bb61dfc..7734e33 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ContentLabelService.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ContentLabelService.kt @@ -1,901 +1,212 @@ package com.morpho.app.model.uidata - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Warning -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.util.fastAny -import androidx.compose.ui.util.fastFilter -import androidx.compose.ui.util.fastForEach -import app.bsky.actor.ContentLabelPref -import app.bsky.actor.MutedWord -import app.bsky.actor.Visibility -import app.bsky.labeler.GetServicesQuery -import app.bsky.labeler.GetServicesResponseViewUnion -import com.atproto.label.LabelValue -import com.atproto.label.Severity -import com.morpho.app.data.PreferencesRepository -import com.morpho.app.model.bluesky.* -import com.morpho.butterfly.AtUri -import com.morpho.butterfly.Butterfly -import com.morpho.butterfly.Language -import com.morpho.butterfly.model.ReadOnlyList -import kotlinx.collections.immutable.* -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* -import kotlinx.serialization.Contextual -import kotlinx.serialization.Serializable -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.lighthousegames.logging.logging - -@Immutable -@Serializable -data class ContentHandling( - val scope: LabelScope, - val action: LabelAction, - val source: LabelDescription, - val id: String, - @Contextual - val icon: ImageVector, -) - -sealed interface LabelDescription { - val name: String - val description: String - - sealed interface Block: LabelDescription - data object Blocking: Block { - override val name: String = "User Blocked" - override val description: String = "You have blocked this user. You cannot view their content" - - } - data object BlockedBy: Block { - override val name: String = "User Blocking You" - override val description: String = "This user has blocked you. You cannot view their content." - } - data class BlockList( - val listName: String, - val listUri: AtUri, - ): Block { - override val name: String = "User Blocked by $listName" - override val description: String = "This user is on a block list you subscribe to. You cannot view their content." - } - data object OtherBlocked: Block { - override val name: String = "Content Not Available" - override val description: String = "This content is not available because one of the users involved has blocked the other." - } - - sealed interface Muted: LabelDescription - data class MuteList( - val listName: String, - val listUri: AtUri, - ): Muted { - override val name: String = "User Muted by $listName" - override val description: String = "This user is on a mute list you subscribe to." - } - data object YouMuted: Muted { - override val name: String = "Account Muted" - override val description: String = "You have muted this user." - } - data class MutedWord(val word: String): Muted { - override val name: String = "Post Hidden by Muted Word" - override val description: String = "This post contains the word or tag \"$word\". You've chosen to hide it." - } - - data class HiddenPost(val uri: AtUri): LabelDescription { - override val name: String = "Post Hidden by You" - override val description: String = "You have hidden this post." - } - - data class Label( - override val name: String, - override val description: String, - val severity: Severity, - ): LabelDescription -} - -sealed interface LabelSource { - data object User: LabelSource - data class List( - val list: BskyList, - ): LabelSource - data class Labeler( - val labeler: BskyLabelService, - ): LabelSource -} - -sealed interface LabelCause { - val downgraded: Boolean - val priority: Int - val source: LabelSource - data class Blocking( - override val source: LabelSource, - override val downgraded: Boolean, - ): LabelCause { - override val priority: Int = 3 - } - data class BlockedBy( - override val source: LabelSource, - override val downgraded: Boolean, - ): LabelCause { - override val priority: Int = 4 - } - - data class BlockOther( - override val source: LabelSource, - override val downgraded: Boolean, - ): LabelCause { - override val priority: Int = 4 - } - - data class Label( - override val source: LabelSource, - val label: BskyLabel, - val labelDef: InterpretedLabelDefinition, - val target: LabelTarget, - val setting: LabelSetting, - val behaviour: ModBehaviour, - val noOverride: Boolean, - override val priority: Int, - override val downgraded: Boolean, - ): LabelCause { - init { - require( - priority == 1 || priority == 2 || priority == 3 || - priority == 5 || priority == 7 || priority == 8 - ) - } - } - - data class Muted( - override val source: LabelSource, - override val downgraded: Boolean, - ): LabelCause { - override val priority: Int = 6 - } - - data class MutedWord( - override val source: LabelSource, - override val downgraded: Boolean, - ): LabelCause { - override val priority: Int = 6 - } - - data class Hidden( - override val source: LabelSource, - override val downgraded: Boolean, - ): LabelCause { - override val priority: Int = 6 - } - -} - - - -@Serializable -@Immutable -open class InterpretedLabelDefinition( - val identifier: String, - val configurable: Boolean, - val severity: Severity, - val whatToHide: LabelScope, - val defaultSetting: LabelSetting?, - @Contextual - val flags: List = persistentListOf(), - val behaviours: ModBehaviours, - val localizedName: String = "", - val localizedDescription: String = "", - @Contextual - val allDescriptions: ImmutableMap = persistentMapOf(), -) { - companion object { - - } - - public fun toContentHandling(target: LabelTarget, icon: ImageVector? = null): ContentHandling { - val action = behaviours.forScope(whatToHide, target).minOrNull() ?: when(defaultSetting) { - LabelSetting.HIDE -> LabelAction.Blur - LabelSetting.WARN -> LabelAction.Alert - LabelSetting.IGNORE -> LabelAction.Inform - null -> LabelAction.None - } - return ContentHandling( - id = identifier, - scope = whatToHide, - action = action, - source = LabelDescription.Label( - name = localizedName, - description = localizedDescription, - severity = severity, - ), - icon = icon ?: when(severity) { - Severity.ALERT -> Icons.Default.Warning - Severity.NONE -> Icons.Default.Info - Severity.INFORM -> Icons.Default.Info - } - ) - } -} - -val LABELS: PersistentMap = persistentMapOf( - LabelValue.HIDE to Hide, - LabelValue.WARN to Warn, - LabelValue.NO_UNAUTHENTICATED to NoUnauthed, - LabelValue.PORN to Porn, - LabelValue.SEXUAL to Sexual, - LabelValue.NUDITY to Nudity, - LabelValue.GRAPHIC_MEDIA to GraphicMedia, -) -data object Hide: InterpretedLabelDefinition( - "!hide", - false, - Severity.ALERT, - LabelScope.Content, - LabelSetting.HIDE, - persistentListOf(LabelValueDefFlag.NoSelf, LabelValueDefFlag.NoOverride), - ModBehaviours( - account = ModBehaviour( - profileList = LabelAction.Blur, - profileView = LabelAction.Blur, - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - displayName = LabelAction.Blur, - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - profile = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - displayName = LabelAction.Blur, - ), - content = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - ), - localizedName = "Hide", - localizedDescription = "Hide", -) - -data object Warn: InterpretedLabelDefinition( - "!warn", - false, - Severity.NONE, - LabelScope.Content, - LabelSetting.WARN, - persistentListOf(LabelValueDefFlag.NoSelf), - ModBehaviours( - account = ModBehaviour( - profileList = LabelAction.Blur, - profileView = LabelAction.Blur, - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - displayName = LabelAction.Blur, - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - profile = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - displayName = LabelAction.Blur, - ), - content = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - ), - localizedName = "Warn", - localizedDescription = "Warn", -) - -data object NoUnauthed: InterpretedLabelDefinition( - "!no-unauthenticated", - false, - Severity.NONE, - LabelScope.Content, - LabelSetting.HIDE, - persistentListOf(LabelValueDefFlag.NoOverride, LabelValueDefFlag.Unauthed), - ModBehaviours( - account = ModBehaviour( - profileList = LabelAction.Blur, - profileView = LabelAction.Blur, - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - displayName = LabelAction.Blur, - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - profile = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - displayName = LabelAction.Blur, - ), - content = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - ), - localizedName = "No Unauthenticated", - localizedDescription = "Do not show to unauthenticated users", -) - -data object Porn: InterpretedLabelDefinition( - "porn", - true, - Severity.NONE, - LabelScope.Media, - LabelSetting.HIDE, - persistentListOf(LabelValueDefFlag.Adult), - ModBehaviours( - account = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - ), - profile = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - ), - content = ModBehaviour( - contentMedia = LabelAction.Blur, - ), - ), - localizedName = "Sexually Explicit", - localizedDescription = "This content is sexually explicit", -) - -data object Sexual: InterpretedLabelDefinition( - "sexual", - true, - Severity.NONE, - LabelScope.Media, - LabelSetting.HIDE, - persistentListOf(LabelValueDefFlag.Adult), - ModBehaviours( - account = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - ), - profile = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - ), - content = ModBehaviour( - contentMedia = LabelAction.Blur, - ), - ), - localizedName = "Suggestive", - localizedDescription = "This content may be suggestive or sexual in nature", -) - -data object Nudity: InterpretedLabelDefinition( - "nudity", - true, - Severity.NONE, - LabelScope.Media, - LabelSetting.HIDE, - persistentListOf(LabelValueDefFlag.Adult), - ModBehaviours( - account = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - ), - profile = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - ), - content = ModBehaviour( - contentMedia = LabelAction.Blur, - ), - ), - localizedName = "Nudity", - localizedDescription = "This content contains nudity, artistic or otherwise", -) - -data object GraphicMedia: InterpretedLabelDefinition( - "graphic-media", - true, - Severity.NONE, - LabelScope.Media, - LabelSetting.HIDE, - persistentListOf(LabelValueDefFlag.Adult), - ModBehaviours( - account = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - ), - profile = ModBehaviour( - avatar = LabelAction.Blur, - banner = LabelAction.Blur, - ), - content = ModBehaviour( - contentMedia = LabelAction.Blur, - ), - ), - localizedName = "Graphic Content", - localizedDescription = "This content is graphic or violent in nature", -) - - -class ContentLabelService: KoinComponent { - val api:Butterfly by inject() - val preferences: PreferencesRepository by inject() - - private val _labelPrefs: MutableStateFlow> = MutableStateFlow(listOf()) - private val _labelers: MutableStateFlow> = MutableStateFlow(listOf()) - private val _mutedWords: MutableStateFlow> = MutableStateFlow(listOf()) - private val _hiddenPosts: MutableStateFlow> = MutableStateFlow(listOf()) - private val _showAdultContent: MutableStateFlow = MutableStateFlow(false) - private val _feedPrefs: MutableStateFlow> = MutableStateFlow(mapOf()) - - val labelers = _labelers.asStateFlow() - val labelPrefs = _labelPrefs.asStateFlow() - val mutedWords = _mutedWords.asStateFlow() - val hiddenPosts = _hiddenPosts.asStateFlow() - val showAdultContent = _showAdultContent.asStateFlow() - val feedPrefs = _feedPrefs.asStateFlow() - val labelsToHide = labelPrefs.map { contentLabelPrefs -> - contentLabelPrefs.fastFilter { it.visibility == Visibility.HIDE } - }.stateIn(serviceScope, SharingStarted.Eagerly, persistentListOf()) - - private val handlingCache = mutableStateMapOf>() - private val definitionCache = mutableStateMapOf() - - companion object { - val log = logging() - val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - } - - init { - serviceScope.launch { - while(!api.isLoggedIn()) { - delay(100) - } - if (api.isLoggedIn()) { - preferences.userPrefs(api.atpUser!!.id).map { prefs -> - _labelPrefs.update { prefs?.preferences?.contentLabelPrefs ?: emptyList() } - _mutedWords.update { prefs?.preferences?.mutedWords ?: emptyList() } - _hiddenPosts.update { prefs?.preferences?.hiddenPosts ?: emptyList() } - _showAdultContent.update { prefs?.preferences?.adultContent?.enabled ?: false } - _feedPrefs.update { prefs?.preferences?.feedViewPrefs ?: emptyMap() } - val labelerProfiles = prefs?.preferences?.labelers?.toImmutableList() - ?.let { GetServicesQuery(it) }?.let { - api.api.getServices(it) - .map { resp -> - resp.views.map { service -> - when(service) { - is GetServicesResponseViewUnion.LabelerView -> - service.value.toLabelService() - is GetServicesResponseViewUnion.LabelerViewDetailed -> - service.value.toLabelService() - } - } - }.getOrNull() - } ?: emptyList() - _labelers.update { labelerProfiles } - } - initDefinitionCache() - } - - } - - } - - private fun initDefinitionCache() { - val labelers = labelers.value - log.verbose { "Labelers: $labelers" } - val labelPrefs = labelPrefs.value - log.verbose { "Label prefs: $labelPrefs" } - val labelPrefMap = labelPrefs.associateBy { if (it.labelerDid == null) it.label else it.labelerDid.toString() } - val labelerMap = labelers.associateBy { it.did.toString() } - log.verbose { "Labeler map: $labelerMap" } - val labelMap = labelerMap.mapValues { (id, labeler) -> - val labelPref = labelPrefMap[id] - if (labelPref != null) { - val policy = labeler.policies.firstOrNull { it.identifier == labelPref.label } - if (policy != null) { - Pair( - labeler.labels.first { it.value == policy.identifier }, - policy.copy(defaultSetting = labelPref.visibility.toLabelSetting()), - ) - } else { - Pair( - labeler.labels.first { label -> - labeler.policies.fastAny { it.identifier == label.value } }, - labeler.policies.first { def -> - labeler.labels.fastAny { it.value == def.identifier } }, - ) - } - } else { - Pair( - labeler.labels.first { label -> - labeler.policies.fastAny { it.identifier == label.value } }, - labeler.policies.first { def -> - labeler.labels.fastAny { it.value == def.identifier } }, - ) - } - } - val definitionMap = labelMap.mapValues { (id, pair) -> - val (label, policy) = pair - val name = label.value - val flags = mutableListOf() - var interpreted: InterpretedLabelDefinition? = null - if (policy.adultOnly == true) { - flags.add(LabelValueDefFlag.Adult) - } - when (label.getLabelValue()) { - LabelValue.HIDE -> interpreted = Hide - LabelValue.WARN -> interpreted = Warn - LabelValue.NO_UNAUTHENTICATED -> interpreted = NoUnauthed - LabelValue.PORN -> interpreted = Porn - LabelValue.SEXUAL -> interpreted = Sexual - LabelValue.NSFL -> interpreted = GraphicMedia - LabelValue.GORE -> interpreted = GraphicMedia - LabelValue.GRAPHIC_MEDIA -> interpreted = GraphicMedia - else -> {} - } - - if (interpreted == null) { - val behaviours = when (policy.whatToHide) { - LabelScope.Content -> ModBehaviours( - account = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - profile = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - content = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - ) - LabelScope.Media -> BlurAllMedia - LabelScope.None -> ModBehaviours( - NoopBehaviour, - NoopBehaviour, - NoopBehaviour, - ) - } - interpreted = InterpretedLabelDefinition( - policy.identifier, - true, - policy.severity, - policy.whatToHide, - policy.defaultSetting, - flags.toImmutableList(), - behaviours, - localizedName = policy.localizedName, - localizedDescription = policy.localizedDescription, - allDescriptions = policy.allDescriptions, - ) - } - Pair(name, interpreted) - }.values.toMap() - definitionCache.putAll(definitionMap) - } - - fun getContentHandlingForPost(post: BskyPost): List { -// // TODO: Add some way to invalidate the cache -// if (handlingCache.containsKey(post.uri)) { -// return handlingCache[post.uri]!! +// +//import app.bsky.actor.MuteTargetGroup +//import app.bsky.actor.MutedWord +//import app.bsky.actor.Visibility +//import app.bsky.labeler.LabelerViewDetailed +//import com.atproto.label.Blurs +//import com.atproto.label.Severity +//import com.morpho.app.data.MorphoAgent +//import com.morpho.app.model.bluesky.BskyPost +//import com.morpho.app.model.bluesky.MorphoDataItem +//import com.morpho.app.model.bluesky.toAtProtoLabel +//import com.morpho.app.model.bluesky.toListVewBasic +//import com.morpho.butterfly.AtUri +//import com.morpho.butterfly.ContentHandling +//import com.morpho.butterfly.Did +//import com.morpho.butterfly.InterpretedLabelDefinition +//import com.morpho.butterfly.LabelAction +//import com.morpho.butterfly.LabelCause +//import com.morpho.butterfly.LabelDescription +//import com.morpho.butterfly.LabelIcon +//import com.morpho.butterfly.LabelSource +//import com.morpho.butterfly.LabelTarget +//import com.morpho.butterfly.LabelValueDefFlag +//import com.morpho.butterfly.LabelValueID +//import com.morpho.butterfly.LabelerID +//import com.morpho.butterfly.ModerationPreferences +//import com.morpho.butterfly.MutedWordTarget +//import kotlinx.coroutines.CoroutineScope +//import kotlinx.coroutines.Dispatchers +//import kotlinx.coroutines.SupervisorJob +//import kotlinx.coroutines.runBlocking +//import org.koin.core.component.KoinComponent +//import org.koin.core.component.inject +//import org.lighthousegames.logging.logging +// +//class ContentLabelService: KoinComponent { +// val agent: MorphoAgent by inject() +// val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) +// companion object { +// val log = logging("ContentLabelService") +// } +// +// val modPrefs: ModerationPreferences +// get() = agent.prefs.modPrefs +// +// val hiddenPosts: List +// get() = modPrefs.hiddenPosts +// +// val mutedWords: List +// get() = modPrefs.mutedWords +// +// +// val labelers: Map> +// get() = modPrefs.labelers +// +// val labels: Map +// get() = modPrefs.labels +// +// var labelDefinitions: Map> = emptyMap() +// private set +// +// var labelerDetails: Map = emptyMap() +// private set +// +// init { +// runBlocking { +// labelDefinitions = agent.getLabelDefinitions(modPrefs) +// val details = agent.getLabelersDetailed(labelers.keys.map { Did(it) }).getOrNull()?.associateBy { +// it.creator.did.did +// } +// labelerDetails = details ?: emptyMap() // } - val result = mutableListOf() - val causes = mutableListOf() - val labels = post.labels - if (hiddenPosts.value.contains(post.uri)) { - causes.add(LabelCause.Hidden(LabelSource.User, false)) - result.add(Hide.toContentHandling(LabelTarget.Content)) - // Short circuit if the post is hidden, we shouldn't really get here - // Generally it will be filtered out at the feed retrieval level - return result.toImmutableList() - } - if (labels.isNotEmpty()) { - log.verbose { "Post ${post.uri} has labels: ${labels.joinToString { it.value }}" } - if (!showAdultContent.value) { - val adultLabeler = labelPrefs.value.fastFilter { prefLabel -> - labels.fastAny { bskyLabel -> - prefLabel.label == bskyLabel.value && - labelers.value.fastAny { it.policies.fastAny { policy -> - policy.adultOnly == true && policy.identifier == prefLabel.label - } } - } - } - val adultLabel = labels.firstOrNull { bskyLabel -> - val value = bskyLabel.getLabelValue() - value == LabelValue.GRAPHIC_MEDIA - || value == LabelValue.GORE - || value == LabelValue.NSFL - || value == LabelValue.PORN - || value == LabelValue.SEXUAL - || value == LabelValue.NUDITY - || adultLabeler.isNotEmpty() - } - log.debug { "Post ${post.uri} has adult label: $adultLabel" } - when (adultLabel?.getLabelValue()) { - LabelValue.PORN -> causes.add(LabelCause.Label( - LabelSource.Labeler(labelers.value.firstOrNull { it.did == adultLabel.creator } ?: BlueskyHardcodedLabeler), - adultLabel, - Porn, - LabelTarget.Content, - LabelSetting.HIDE, - Porn.behaviours.content, - noOverride = true, - priority = 7, - downgraded = false, - )) - LabelValue.SEXUAL -> causes.add(LabelCause.Label( - LabelSource.Labeler(labelers.value.firstOrNull { it.did == adultLabel.creator } ?: BlueskyHardcodedLabeler), - adultLabel, - Sexual, - LabelTarget.Content, - LabelSetting.HIDE, - Sexual.behaviours.content, - noOverride = true, - priority = 7, - downgraded = false, - )) - LabelValue.NUDITY -> causes.add(LabelCause.Label( - LabelSource.Labeler(labelers.value.firstOrNull { it.did == adultLabel.creator } ?: BlueskyHardcodedLabeler), - adultLabel, - Nudity, - LabelTarget.Content, - LabelSetting.HIDE, - Nudity.behaviours.content, - noOverride = true, - priority = 7, - downgraded = false, - )) - LabelValue.GRAPHIC_MEDIA -> causes.add(LabelCause.Label( - LabelSource.Labeler(labelers.value.firstOrNull { it.did == adultLabel.creator } ?: BlueskyHardcodedLabeler), - adultLabel, - GraphicMedia, - LabelTarget.Content, - LabelSetting.HIDE, - GraphicMedia.behaviours.content, - noOverride = true, - priority = 8, - downgraded = false, - )) - LabelValue.NSFL -> causes.add(LabelCause.Label( - LabelSource.Labeler(labelers.value.firstOrNull { it.did == adultLabel.creator } ?: BlueskyHardcodedLabeler), - adultLabel, - GraphicMedia, - LabelTarget.Content, - LabelSetting.HIDE, - GraphicMedia.behaviours.content, - noOverride = true, - priority = 8, - downgraded = false, - )) - LabelValue.GORE -> causes.add(LabelCause.Label( - LabelSource.Labeler(labelers.value.firstOrNull { it.did == adultLabel.creator } ?: BlueskyHardcodedLabeler), - adultLabel, - GraphicMedia, - LabelTarget.Content, - LabelSetting.HIDE, - GraphicMedia.behaviours.content, - noOverride = true, - priority = 8, - downgraded = false, - )) - null -> {} - else -> { - adultLabeler.fastForEach { prefLabel -> - val labeler = labelers.value.firstOrNull { it.did == prefLabel.labelerDid } - val labelDef = labeler?.policies?.firstOrNull { it.identifier == prefLabel.label } - if (labeler != null && labelDef != null) { - val cached = definitionCache[prefLabel.label] - if (cached != null) { - val cause = LabelCause.Label( - LabelSource.Labeler(labeler), - adultLabel, - cached, - LabelTarget.Content, - prefLabel.visibility.toLabelSetting(), - cached.behaviours.content, - noOverride = false, - priority = 7, - downgraded = false, - ) - causes.add(cause) - } else { - val behaviours = when (labelDef.whatToHide) { - LabelScope.Content -> ModBehaviours( - account = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - profile = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - content = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - ) - LabelScope.Media -> BlurAllMedia - LabelScope.None -> ModBehaviours( - NoopBehaviour, - NoopBehaviour, - NoopBehaviour, - ) - } - val interpreted = InterpretedLabelDefinition( - adultLabel.value, - true, - labelDef.severity, - labelDef.whatToHide, - labelDef.defaultSetting, - persistentListOf(LabelValueDefFlag.Adult), - behaviours, - localizedName = labelDef.localizedName, - localizedDescription = labelDef.localizedDescription, - ) - val cause = LabelCause.Label( - LabelSource.Labeler(labeler), - adultLabel, - interpreted, - LabelTarget.Content, - prefLabel.visibility.toLabelSetting(), - interpreted.behaviours.content, - noOverride = false, - priority = 7, - downgraded = false, - ) - causes.add(cause) - definitionCache[prefLabel.label] = interpreted - } - } - - } - } - - } - } - val labelsWeCareAbout = labelPrefs.value.fastFilter { prefLabel -> - labels.fastAny { it.value == prefLabel.label } - } - - log.verbose { "Post ${post.uri} has labels we care about: ${labelsWeCareAbout.joinToString { it.label }}" } - labelsWeCareAbout.fastForEach { prefLabel -> - val cachedInterpretation = definitionCache[prefLabel.label] - if (cachedInterpretation != null) { - log.verbose { "Post ${post.uri} has cached interpretation for ${prefLabel.label}" } - val cause = LabelCause.Label( - LabelSource.Labeler(labelers.value.firstOrNull { it.did == prefLabel.labelerDid }!!), - labels.first { it.value == prefLabel.label }, - cachedInterpretation, - LabelTarget.Content, - prefLabel.visibility.toLabelSetting(), - cachedInterpretation.behaviours.content, - noOverride = false, - priority = 5, - downgraded = false, - ) - causes.add(cause) - } else { - val labeler = labelers.value.firstOrNull { it.did == prefLabel.labelerDid } - val labelDef = labeler?.policies?.firstOrNull { it.identifier == prefLabel.label } - if (labeler != null && labelDef != null) { - val behaviours = when (labelDef.whatToHide) { - LabelScope.Content -> ModBehaviours( - account = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - profile = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - content = ModBehaviour( - contentList = LabelAction.Blur, - contentView = LabelAction.Blur, - ), - ) - LabelScope.Media -> BlurAllMedia - LabelScope.None -> ModBehaviours( - NoopBehaviour, - NoopBehaviour, - NoopBehaviour, - ) - } - val interpreted = InterpretedLabelDefinition( - labelDef.identifier, - true, - labelDef.severity, - labelDef.whatToHide, - labelDef.defaultSetting, - persistentListOf(LabelValueDefFlag.Adult), - behaviours, - localizedName = labelDef.localizedName, - localizedDescription = labelDef.localizedDescription, - ) - val cause = LabelCause.Label( - LabelSource.Labeler(labeler), - labels.first { it.value == prefLabel.label }, - interpreted, - LabelTarget.Content, - prefLabel.visibility.toLabelSetting(), - interpreted.behaviours.content, - noOverride = false, - priority = 5, - downgraded = false, - ) - causes.add(cause) - definitionCache[prefLabel.label] = interpreted - } - } - } - } - causes.sortByDescending { it.priority } - causes.fastForEach { cause -> - // TODO: handle stuff from lists and so on - when (cause) { - is LabelCause.Blocking -> { - result.add(ContentHandling( - scope = LabelScope.Content, - action = LabelAction.Blur, - source = LabelDescription.Blocking, - id = "blocking", - icon = Icons.Default.Info, - )) - } - is LabelCause.BlockedBy -> { - result.add(ContentHandling( - scope = LabelScope.Content, - action = LabelAction.Blur, - source = LabelDescription.BlockedBy, - id = "blocked-by", - icon = Icons.Default.Info, - )) - } - is LabelCause.BlockOther -> { - result.add(ContentHandling( - scope = LabelScope.Content, - action = LabelAction.Blur, - source = LabelDescription.OtherBlocked, - id = "blocked-other", - icon = Icons.Default.Info, - )) - } - is LabelCause.Muted -> { - - result.add(ContentHandling( - scope = LabelScope.Content, - action = LabelAction.Blur, - source = LabelDescription.YouMuted, - id = "muted", - icon = Icons.Default.Info, - )) - } - is LabelCause.MutedWord -> { - result.add( - ContentHandling( - scope = LabelScope.Content, - action = LabelAction.Blur, - source = LabelDescription.MutedWord("Some word"), - id = "muted-word", - icon = Icons.Default.Info, - ) - ) - } - is LabelCause.Label -> { - val handling = cause.labelDef.toContentHandling(cause.target) - result.add(handling) - } - is LabelCause.Hidden -> { - result.add(Hide.toContentHandling(LabelTarget.Content)) - } - } - } - - log.verbose { "Post ${post.uri} has handling: \n$result" } - return result.toList() - } - -} \ No newline at end of file +// +// } +// +// fun shouldHideItem(item: MorphoDataItem.FeedItem): Boolean { +// return when (item) { +// is MorphoDataItem.Post -> { +// item.post.author.mutedByMe +// || item.post.author.blocking +// || item.post.author.blockedBy +// || hiddenPosts.any { uri -> item.containsUri(uri) } +// || mutedWords.any { +// item.post.text.contains(it.value, ignoreCase = true) +// } || if(!modPrefs.adultContentEnabled) { +// val adultLabels = item.post.labels.filter { label -> +// labelDefinitions[label.creator.did]?.get(label.value)?.flags +// ?.contains(LabelValueDefFlag.Adult) == true +// } +// adultLabels.isNotEmpty() +// } else { +// item.post.labels.any { label -> +// labels[label.value] == Visibility.HIDE +// } +// } +// } +// is MorphoDataItem.Thread -> { +// item.thread.anyMutedOrBlocked() +// || hiddenPosts.any { uri -> item.containsUri(uri) } +// || mutedWords.any { +// item.thread.containsWord(it.value) +// } || if(!modPrefs.adultContentEnabled) { +// val adultLabels = item.thread.getLabels().filter { label -> +// labelDefinitions[label.creator.did]?.get(label.value)?.flags +// ?.contains(LabelValueDefFlag.Adult) == true +// } +// adultLabels.isNotEmpty() +// } else { +// item.thread.getLabels().any { label -> +// labels[label.value] == Visibility.HIDE +// } +// } +// } +// } +// } +// +// fun getContentHandlingForPost(post: BskyPost): List> { +// val result = mutableListOf>() +// val postLabels = post.labels +// +// if(post.author.mutedByMe) { +// result.add(ContentHandling( +// scope = Blurs.CONTENT, +// action = LabelAction.Blur, +// source = LabelDescription.YouMuted, +// id = "muted", +// icon = LabelIcon.EyeSlash(labelerAvatar = null), +// ) to LabelCause.Muted(LabelSource.User, false)) +// } +// if(post.author.mutedByList != null) { +// val list = post.author.mutedByList!! +// result.add(ContentHandling( +// scope = Blurs.CONTENT, +// action = LabelAction.Blur, +// source = LabelDescription.MuteList( +// list.name, +// list.uri, +// ), +// id = "muted-word", +// icon = LabelIcon.EyeSlash( labelerAvatar = list.avatar), +// ) to LabelCause.Muted(LabelSource.List(list.toListVewBasic()), false)) +// } +// val anyMutedWords = mutedWords.filter { post.text.contains(it.value, ignoreCase = true) } +// if(anyMutedWords.isNotEmpty()) anyMutedWords.forEach { word -> +// if(!word.targets.contains(MutedWordTarget("content"))) return@forEach +// if(word.actorTarget == MuteTargetGroup.EXCLUDE_FOLLOWING && post.author.followedByMe) return@forEach +// result.add(ContentHandling( +// scope = Blurs.CONTENT, +// action = LabelAction.Blur, +// source = LabelDescription.MutedWord(word.value), +// id = "muted-word", +// icon = LabelIcon.EyeSlash(), +// ) to LabelCause.MutedWord(LabelSource.User, false)) +// } +// +// +// if (postLabels.isNotEmpty()) { +// log.verbose { "Post ${post.uri} has labels: ${postLabels.joinToString { it.value }}" } +// // Adult content hiding if someone doesn't have it enabled is handled earlier, +// // before rendering starts, as is Visibility.HIDE +// // so we don't need to worry about it here +// val relevantLabels = labels.filter { prefLabel -> +// (prefLabel.value == Visibility.WARN || prefLabel.value == Visibility.HIDE) +// && postLabels.any { it.value == it.value } }.toList() +// .sortedBy { it.second.ordering } +// val filteredPostLabels = postLabels.filter { label -> +// relevantLabels.any { label.value == it.first } +// } +// +// val possibleCauses = filteredPostLabels.mapNotNull { label -> +// labelDefinitions[label.creator.did]?.get(label.value)?.let { labelDef -> +// val localizedDefString = labelDef.allDescriptions.firstOrNull { +// it.lang == agent.myLanguage.value +// } ?: labelDef.allDescriptions.firstOrNull { it.lang.tag == "en" } +// val localLabelDef = labelDef.copy( +// localizedName = localizedDefString?.name ?: labelDef.localizedName, +// localizedDescription = localizedDefString?.description +// ?: labelDef.localizedDescription, +// ) +// +// LabelCause.Label( +// LabelSource.Labeler(labelerDetails[label.creator.did]!!), +// label.toAtProtoLabel(), +// localLabelDef, +// localLabelDef.whatToHide, +// labels[label.value] ?: labelDef.defaultSetting ?: Visibility.IGNORE, +// localLabelDef.behaviours.content, +// noOverride = !localLabelDef.configurable, +// priority = when (localLabelDef.severity) { +// Severity.INFORM -> 5 +// Severity.ALERT -> 2 +// Severity.NONE -> 8 +// Severity.WARN -> 1 +// }, +// downgraded = false, +// ) to localLabelDef.toContentHandling( +// LabelTarget.Content, +// avatar = labelerDetails[label.creator.did]?.creator?.avatar +// ) +// } +// }.sortedBy{ it.first.priority } +// possibleCauses.forEach { (cause, handling) -> +// result.add(handling to cause) +// } +// } +// +// log.verbose { "Post ${post.uri} has handling: \n$result" } +// return result.toList() +// } +// +//} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Delta.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Delta.kt index e95abff..2352dbe 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Delta.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Delta.kt @@ -1,9 +1,14 @@ package com.morpho.app.model.uidata +import androidx.compose.runtime.Immutable +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize import kotlinx.serialization.Serializable import kotlin.time.Duration +@Parcelize +@Immutable @Serializable data class Delta( val duration: Duration, -) \ No newline at end of file +): Parcelable \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Events.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Events.kt new file mode 100644 index 0000000..9763d1f --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Events.kt @@ -0,0 +1,213 @@ +package com.morpho.app.model.uidata + +import app.bsky.actor.* +import com.atproto.repo.StrongRef +import com.morpho.app.model.bluesky.AuthorFilter +import com.morpho.app.model.bluesky.BskyPost +import com.morpho.app.model.bluesky.FeedDescriptor +import com.morpho.app.model.bluesky.FeedSourceInfo +import com.morpho.app.model.uistate.ListsOrFeeds +import com.morpho.app.ui.common.ComposerRole +import com.morpho.butterfly.AtIdentifier +import com.morpho.butterfly.AtUri +import com.morpho.butterfly.Did +import com.morpho.butterfly.model.Timestamp +import io.github.vinceglb.filekit.core.PlatformFile +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable + +@Serializable +sealed interface Event { + data class UpdateSeenNotifications( + val seenAt: Timestamp = Clock.System.now() + ): Event + + data class ComposePost( + val post: BskyPost, + val role: ComposerRole = ComposerRole.StandalonePost, + ): Event, PostEvent +} +sealed interface ModerationEvent: Event + +sealed interface LoadEvent: Event +sealed interface ListEvent: Event + +sealed interface FeedEvent: Event { + val uri: AtUri? + data class Load( + val descriptor: FeedDescriptor, + ): FeedEvent, LoadEvent { + override val uri: AtUri = when(descriptor) { + is FeedDescriptor.Author -> when(descriptor.filter) { + AuthorFilter.PostsNoReplies -> AtUri.profilePostsUri(descriptor.did) + AuthorFilter.PostsWithReplies -> AtUri.profileRepliesUri(descriptor.did) + AuthorFilter.PostsAuthorThreads -> AtUri.profileRepliesUri(descriptor.did) + AuthorFilter.PostsWithMedia -> AtUri.profileMediaUri(descriptor.did) + } + is FeedDescriptor.FeedGen -> descriptor.uri + is FeedDescriptor.Likes -> AtUri.profileLikesUri(descriptor.did) + is FeedDescriptor.List -> descriptor.uri + is FeedDescriptor.Home -> AtUri.HOME_URI + } + } + + data class LoadLists( + val actor: AtIdentifier, + val listsOrFeeds: ListsOrFeeds, + ): FeedEvent, LoadEvent, ListEvent { + override val uri: AtUri = AtUri.profileUserListsUri(actor) + } + + data class LoadFeed( + val actor: AtIdentifier, + val filter: AuthorFilter?, + ): FeedEvent, LoadEvent, ListEvent { + override val uri: AtUri = when(filter) { + AuthorFilter.PostsNoReplies -> AtUri.profilePostsUri(actor) + AuthorFilter.PostsWithReplies -> AtUri.profileRepliesUri(actor) + AuthorFilter.PostsAuthorThreads -> AtUri.profileRepliesUri(actor) + AuthorFilter.PostsWithMedia -> AtUri.profileMediaUri(actor) + null -> AtUri.profileLikesUri(actor) + } + } + + data class LoadSaved( + val info: SavedFeed, + ): FeedEvent, LoadEvent { + override val uri: AtUri = AtUri(info.value) + } + + data class LoadHydrated( + val info: FeedSourceInfo, + ): FeedEvent, LoadEvent { + override val uri: AtUri = info.uri + } + + data class Peek( + val info: FeedSourceInfo + ): FeedEvent { + override val uri: AtUri = info.uri + } + + +} + +sealed interface PageEvent: Event +sealed interface ListPageEvent: PageEvent + +sealed interface FeedPageEvent: PageEvent { + data class LikeFeed(val like: StrongRef): FeedPageEvent, LikeEvent + data class UnlikeFeed(val uri: AtUri): FeedPageEvent, LikeEvent + data class Save(val info: SavedFeed): FeedPageEvent, PrefsEvent + data class UnSave(val id: String): FeedPageEvent, PrefsEvent +} + +sealed interface CuratedListPageEvent: ListPageEvent { + data class Pin(val info: SavedFeed): CuratedListPageEvent, PrefsEvent + data class Unpin(val id: String): CuratedListPageEvent, PrefsEvent +} + +sealed interface ModListPageEvent: ListPageEvent { + data class MuteList(val list: AtUri): ModListPageEvent, ModerationEvent + data class UnmuteList(val uri: AtUri): ModListPageEvent, ModerationEvent + data class BlockList(val list: AtUri): ModListPageEvent, ModerationEvent + data class UnblockList(val uri: AtUri): ModListPageEvent, ModerationEvent +} + +sealed interface PrefsEvent: Event { + data class MuteWord(val word: MutedWord): PrefsEvent + data class UnMuteWord(val word: MutedWord): PrefsEvent + data class SetThreadViewPref(val pref: ThreadViewPref): PrefsEvent + data class SetFeedViewPref(val feed: String, val feedViewPref: FeedViewPref): PrefsEvent +} + +sealed interface ListDataEvent: Event { + data class LoadActor( + val actor: AtIdentifier + ): ListDataEvent, LoadEvent + + data class LoadFromPost( + val post: AtUri + ): ListDataEvent, LoadEvent +} + +sealed interface SearchEvent: Event { + val query: String? + + data class Actors( + val term: String? = null, + override val query: String? = null, + ): SearchEvent + + data class ActorsTypeahead( + val term: String? = null, + override val query: String? = null, + ): SearchEvent + + data class Posts( + override val query: String? = null, + ): SearchEvent +} + +// Unsure about some of these, maybe events should only be repeatable things? +sealed interface LikeEvent: Event +sealed interface ThreadEvent: Event + +sealed interface PostEvent: Event { + data class Reply(val post: BskyPost): PostEvent + data class Quote(val post: BskyPost): PostEvent + + + data class LikePost(val like: StrongRef): PostEvent, LikeEvent + data class UnlikePost(val uri: AtUri): PostEvent, LikeEvent + data class Repost(val repost: StrongRef): PostEvent + data class DeleteRepost(val uri: AtUri): PostEvent + + data class Hide(val uri: AtUri): PostEvent, PrefsEvent, ModerationEvent + data class Unhide(val uri: AtUri): PostEvent, PrefsEvent, ModerationEvent + + data class LoadThread(val post: AtUri): PostEvent, LoadEvent, ThreadEvent + data class ReportPost(val subject: StrongRef): PostEvent, ModerationEvent +} + +sealed interface LabelerEvent: Event { + data class LikeLabeler(val like: StrongRef): LabelerEvent, LikeEvent + data class UnlikeLabeler(val uri: AtUri): LabelerEvent, LikeEvent + data class Subscribe(val did: Did): LabelerEvent, PrefsEvent, ModerationEvent + data class Unsubscribe(val did: Did): LabelerEvent, PrefsEvent, ModerationEvent + + data class SetLabelPref( + val label: String, + val value: Visibility, + val labeler: Did, + ): LabelerEvent, PrefsEvent, ModerationEvent +} + +sealed interface MyProfileEvent: ProfileEvent { + data object EnterEditing: MyProfileEvent + data object ExitEditing: MyProfileEvent +} + +sealed interface ProfileEditEvent: MyProfileEvent { + data class SetDisplayName(val name: String): ProfileEditEvent + data class SetDescription(val description: String): ProfileEditEvent + data class SetAvatar(val avatar: PlatformFile): ProfileEditEvent + data class SetBanner(val banner: PlatformFile): ProfileEditEvent +} + +sealed interface ActorEvent: Event { + +} + +sealed interface ProfileEvent: ActorEvent { + data class Follow(val subject: Did): ProfileEvent + data class Unfollow(val uri: AtUri): ProfileEvent + + data class Mute(val subject: Did): ProfileEvent, PrefsEvent, ModerationEvent + data class Unmute(val subject: Did): ProfileEvent, PrefsEvent, ModerationEvent + + data class Block(val subject: Did): ProfileEvent, ModerationEvent + data class Unblock(val uri: AtUri): ProfileEvent, ModerationEvent + + data class ReportAccount(val subject: Did): ProfileEvent, ModerationEvent +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/FeedInfo.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/FeedInfo.kt index 125a716..200f895 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/FeedInfo.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/FeedInfo.kt @@ -1,18 +1,21 @@ package com.morpho.app.model.uidata -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.RssFeed import androidx.compose.runtime.Immutable -import androidx.compose.ui.graphics.vector.ImageVector import com.morpho.app.model.bluesky.FeedGenerator +import com.morpho.app.model.bluesky.UserList import com.morpho.butterfly.AtUri +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import kotlinx.serialization.Serializable +@Parcelize @Immutable +@Serializable data class FeedInfo( val uri: AtUri, val name: String, val description: String? = null, val avatar: String? = null, - val icon: ImageVector = Icons.Default.RssFeed, val feed: FeedGenerator? = null, -) \ No newline at end of file + val list: UserList? = null, +): Parcelable \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/FeedPresenter.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/FeedPresenter.kt new file mode 100644 index 0000000..4acb62c --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/FeedPresenter.kt @@ -0,0 +1,208 @@ +package com.morpho.app.model.uidata + +import app.bsky.feed.GetAuthorFeedFilter +import app.bsky.feed.GetFeedQuery +import app.bsky.feed.GetListFeedQuery +import app.cash.paging.InvalidatingPagingSourceFactory +import app.cash.paging.Pager +import app.cash.paging.cachedIn +import com.morpho.app.data.FeedTuner +import com.morpho.app.data.MorphoDataSource +import com.morpho.app.data.MorphoFeedSource +import com.morpho.app.model.bluesky.AuthorFilter +import com.morpho.app.model.bluesky.FeedDescriptor +import com.morpho.app.model.bluesky.FeedSourceInfo +import com.morpho.app.model.bluesky.MorphoDataItem +import com.morpho.butterfly.ButterflyAgent +import com.morpho.butterfly.Cursor +import com.morpho.butterfly.FeedRequest +import com.morpho.butterfly.PagedResponse +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map + + +class FeedPresenter( + var descriptor: FeedDescriptor? = null, +): PagedPresenter() { + + private var pagerFactory = InvalidatingPagingSourceFactory { + descriptor?.getDataSource(agent) ?: getTimelineDataSource(agent) + } + + override var pager: Pager = run { + val pagingConfig = MorphoDataSource.defaultConfig + Pager(pagingConfig) { + pagerFactory.invoke() + } + } + + + + override fun produceUpdates(events: Flow): Flow = events.filter { + it is FeedEvent.Load && it.descriptor == descriptor + }.map { event -> + when(event) { + is FeedEvent.Load -> { + when(event.descriptor) { + is FeedDescriptor.Author -> AuthorFeedUpdate.Feed( + event.descriptor.did, event.descriptor.filter, pager.flow.cachedIn(presenterScope)) + is FeedDescriptor.FeedGen -> { + FeedUpdate.Feed(event.uri, pager.flow.cachedIn(presenterScope)) + } + FeedDescriptor.Home -> FeedUpdate.Feed( + FeedSourceInfo.Home.uri, pager.flow.cachedIn(presenterScope)) + is FeedDescriptor.Likes -> AuthorFeedUpdate.Likes( + event.descriptor.did, pager.flow.cachedIn(presenterScope)) + is FeedDescriptor.List -> { + FeedUpdate.Feed(event.uri, pager.flow.cachedIn(presenterScope)) + } + } + } + else -> UIUpdate.NoOp + } + } +} + +fun FeedSourceInfo.getDataSource( + agent: ButterflyAgent, +): MorphoFeedSource { + return when(this) { + is FeedSourceInfo.FeedInfo -> getFeedDataSource(this.feedDescriptor as FeedDescriptor.FeedGen, agent) + is FeedSourceInfo.ListInfo -> getListDataSource(this.feedDescriptor as FeedDescriptor.List, agent) + else -> getTimelineDataSource(agent) + } +} + +fun FeedDescriptor.getDataSource( + agent: ButterflyAgent, +): MorphoFeedSource { + return when(this) { + is FeedDescriptor.FeedGen -> getFeedDataSource(this, agent) + is FeedDescriptor.List -> getListDataSource(this, agent) + is FeedDescriptor.Home -> getTimelineDataSource(agent) + is FeedDescriptor.Author -> getAuthorFeedDataSource(this, agent) + is FeedDescriptor.Likes -> getLikesDataSource(this, agent) + } +} + +fun getLikesDataSource( + descriptor: FeedDescriptor.Likes, + agent: ButterflyAgent +): MorphoFeedSource { + val request: FeedRequest = { cursor, limit -> + agent.getActorLikes(descriptor.did, limit, cursor.value).map { response -> + val newCursor = response.cursor + val items = response.items + .map { MorphoDataItem.FeedItem.fromFeedViewPost(it) } as List + PagedResponse.Feed(newCursor, items) + } + } + val tuners = agent.id?.let { + FeedTuner.useFeedTuners(it, agent.prefs, descriptor) + } ?: listOf>() + return MorphoFeedSource(request, tuners, repliesBumpThreads = true) +} + +@Suppress("UNCHECKED_CAST") +fun getAuthorFeedDataSource( + descriptor: FeedDescriptor.Author, + agent: ButterflyAgent +): MorphoFeedSource { + val request: FeedRequest = when(descriptor.filter) { + AuthorFilter.PostsWithReplies -> { cursor, limit -> + agent.getAuthorFeed(descriptor.did, limit, cursor.value, GetAuthorFeedFilter.POSTS_WITH_REPLIES) + .map { response -> + val newCursor = response.cursor + val items = response.items + .map { MorphoDataItem.FeedItem.fromFeedViewPost(it) } as List + PagedResponse.Feed(newCursor, items) + } + } + AuthorFilter.PostsNoReplies -> { cursor, limit -> + agent.getAuthorFeed(descriptor.did, limit, cursor.value, GetAuthorFeedFilter.POSTS_NO_REPLIES) + .map { response -> + val newCursor = response.cursor + val items = response.items + .map { MorphoDataItem.FeedItem.fromFeedViewPost(it) } as List + PagedResponse.Feed(newCursor, items) + } + } + AuthorFilter.PostsAuthorThreads -> { cursor, limit -> + agent.getAuthorFeed(descriptor.did, limit, cursor.value, GetAuthorFeedFilter.POSTS_WITH_REPLIES) + .map { response -> + val newCursor = response.cursor + val items = response.items + .map { MorphoDataItem.FeedItem.fromFeedViewPost(it) } as List + PagedResponse.Feed(newCursor, items) + } + } + AuthorFilter.PostsWithMedia -> { cursor, limit -> + agent.getAuthorFeed(descriptor.did, limit, cursor.value, GetAuthorFeedFilter.POSTS_WITH_MEDIA) + .map { response -> + val newCursor = response.cursor + val items = response.items + .map { MorphoDataItem.FeedItem.fromFeedViewPost(it) } as List + PagedResponse.Feed(newCursor, items) + } + } + } + val tuners = agent.id?.let { + FeedTuner.useFeedTuners(it, agent.prefs, descriptor) + } ?: listOf>() + return MorphoFeedSource(request, tuners, repliesBumpThreads = true) +} + + +fun getFeedDataSource( + descriptor: FeedDescriptor.FeedGen, + agent: ButterflyAgent +): MorphoFeedSource { + val request: FeedRequest = { cursor, limit -> + agent.api.getFeed(GetFeedQuery(descriptor.uri, limit, cursor.value)).map { response -> + val newCursor = Cursor(response.cursor) + val items = response.feed + .map { MorphoDataItem.FeedItem.fromFeedViewPost(it) } as List + PagedResponse.Feed(newCursor, items) + } + } + val tuners = agent.id?.let { + FeedTuner.useFeedTuners(it, agent.prefs, descriptor) + } ?: listOf>() + return MorphoFeedSource(request, tuners, repliesBumpThreads = true) +} + +fun getListDataSource( + descriptor: FeedDescriptor.List, + agent: ButterflyAgent +): MorphoFeedSource { + val request: FeedRequest = { cursor, limit -> + agent.api.getListFeed(GetListFeedQuery(descriptor.uri, limit, cursor.value)).map { response -> + val newCursor = Cursor(response.cursor) + val items = response.feed + .map { MorphoDataItem.FeedItem.fromFeedViewPost(it) } as List + PagedResponse.Feed(newCursor, items) + } + } + val tuners = agent.id?.let { + FeedTuner.useFeedTuners(it, agent.prefs, descriptor) + } ?: listOf>() + return MorphoFeedSource(request, tuners, repliesBumpThreads = true) +} + +fun getTimelineDataSource( + agent: ButterflyAgent +): MorphoFeedSource { + val request: FeedRequest = { cursor, limit -> + agent.getTimeline(cursor = cursor.value, limit = limit).map { response -> + val newCursor = response.cursor + val items = response.items + .map { MorphoDataItem.FeedItem.fromFeedViewPost(it) } as List + PagedResponse.Feed(newCursor, items) + } + } + val tuners = agent.id?.let { + FeedTuner.useFeedTuners(it, agent.prefs, FeedDescriptor.Home) + } ?: listOf>() + return MorphoFeedSource(request, tuners, repliesBumpThreads = true) +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Moment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Moment.kt index 0346f41..f16f152 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Moment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Moment.kt @@ -1,16 +1,22 @@ package com.morpho.app.model.uidata import androidx.compose.runtime.Immutable +import com.morpho.butterfly.json +import dev.icerock.moko.parcelize.Parcel +import dev.icerock.moko.parcelize.Parceler import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject import kotlin.jvm.JvmInline + @Serializable @Immutable @JvmInline value class Moment( val instant: Instant, -) : Comparable { +): Comparable { operator fun plus(delta: Delta): Moment = Moment(instant + delta.duration) operator fun minus(delta: Delta): Moment = Moment(instant - delta.duration) @@ -19,3 +25,44 @@ value class Moment( override fun compareTo(other: Moment): Int = instant.compareTo(instant) } + +object MomentParceler : Parceler{ + override fun create(parcel: Parcel): Moment { + val moment = parcel.readString()?.substringAfter("t")?.substringBefore("Z") + return moment?.let { Moment(Instant.fromEpochMilliseconds(it.toLong())) } + ?: Moment(Instant.DISTANT_PAST) + } + + override fun Moment.write(parcel: Parcel, flags: Int) { + parcel.writeString("t${this.instant.toEpochMilliseconds()}Z") + } +} + +object MaybeMomentParceler : Parceler{ + override fun create(parcel: Parcel): Moment? { + val moment = parcel.readString()?.substringAfter("t")?.substringBefore("Z") + if(moment == "0") return null + return moment?.let { Moment(Instant.fromEpochMilliseconds(it.toLong())) } + } + + override fun Moment?.write(parcel: Parcel, flags: Int) { + if(this == null) { + parcel.writeString("t0Z") + return + } else { + parcel.writeString("t${this.instant.toEpochMilliseconds()}Z") + } + } +} + +object JsonElementParceler : Parceler{ + override fun create(parcel: Parcel): JsonElement { + val serialized = parcel.readString() + return serialized?.let { json.parseToJsonElement(it) } ?: JsonObject(emptyMap()) + } + + override fun JsonElement.write(parcel: Parcel, flags: Int) { + parcel.writeString(json.encodeToString(JsonElement.serializer(), this)) + } +} + diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/MorphoData.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/MorphoData.kt index 8ef8aa1..ca5894c 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/MorphoData.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/MorphoData.kt @@ -1,77 +1,193 @@ package com.morpho.app.model.uidata //import com.rickclephas.kmp.nativecoroutines.NativeCoroutines +import androidx.compose.runtime.Immutable import androidx.compose.ui.util.fastAny -import com.morpho.app.model.bluesky.MorphoDataFeed +import androidx.compose.ui.util.fastDistinctBy +import androidx.compose.ui.util.fastFilterNotNull +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastForEachIndexed +import app.bsky.feed.FeedViewPost +import com.morpho.app.data.FeedTuner +import com.morpho.app.data.MorphoAgent +import com.morpho.app.model.bluesky.AuthorContext +import com.morpho.app.model.bluesky.BskyList +import com.morpho.app.model.bluesky.BskyPostReason +import com.morpho.app.model.bluesky.BskyPostThread +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.bluesky.FeedGenerator import com.morpho.app.model.bluesky.MorphoDataItem +import com.morpho.app.model.bluesky.ThreadPost import com.morpho.app.model.uistate.FeedType -import com.morpho.butterfly.* -import kotlinx.collections.immutable.persistentListOf +import com.morpho.butterfly.AtIdentifier +import com.morpho.butterfly.AtUri +import com.morpho.butterfly.Cid +import com.morpho.butterfly.Did +import com.morpho.butterfly.Handle +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.single import kotlinx.datetime.Instant import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlin.time.Duration -typealias AtCursor = String? +typealias TunerFunction = (List, FeedTuner) -> List + +@Parcelize +@Immutable +@Serializable +data class AtCursor(val cursor: String?, val scroll: Int): Parcelable { + companion object { + val EMPTY: AtCursor = AtCursor(null, 0) + } +} + + +@Immutable @Serializable data class MorphoData( val title: String = "Home", val uri: AtUri = AtUri.HOME_URI, - val cursor: AtCursor = null, - val items: List = persistentListOf(), + val cursor: AtCursor = AtCursor.EMPTY, + val items: List = listOf(), + //@TypeParceler() val query: JsonElement = JsonObject(emptyMap()), ) { companion object { + fun EMPTY(): MorphoData { + return MorphoData( + title = "Home", + uri = AtUri.HOME_URI, + cursor = AtCursor.EMPTY, + items = listOf(), + query = JsonObject(emptyMap()), + ) + } - fun concat( - first: MorphoData, - last: MorphoData, - cursor: AtCursor = last.cursor + fun fromList( + title: String = "Home", + uri: AtUri = AtUri.HOME_URI, + cursor: AtCursor = AtCursor.EMPTY, + items: List, + query: JsonElement = JsonObject(emptyMap()), ): MorphoData { return MorphoData( - items = (first.items union last.items).toPersistentList() - .sortedByDescending { - when (it) { - is MorphoDataItem.Post -> it.post.createdAt - is MorphoDataItem.Thread -> it.thread.post.createdAt - is MorphoDataItem.FeedInfo -> it.feed.indexedAt - is MorphoDataItem.ListInfo -> it.list.indexedAt - is MorphoDataItem.ModLabel -> Moment(Instant.DISTANT_PAST) - is MorphoDataItem.ProfileItem -> Moment(Instant.DISTANT_PAST) - is MorphoDataItem.LabelService -> it.service.indexedAt - else -> { - Moment(Instant.DISTANT_PAST) - } - } - }.toList(), - cursor = cursor, title = first.title, uri = first.uri + title = title, + uri = uri, + cursor = cursor, + items = items, + query = query, + ) + } + + fun fromFeed( + feedPosts: List, + cursor: AtCursor = AtCursor.EMPTY, + title: String = "Home", + uri: AtUri = AtUri.HOME_URI, + query: JsonElement = JsonObject(emptyMap()), + ): MorphoData { + val items = feedPosts.map { item -> + MorphoDataItem.FeedItem.fromFeedViewPost(item) + } + return MorphoData( + title = title, + uri = uri, + cursor = cursor, + items = items, + query = query, ) } + fun concatFeed( + query: JsonElement, + responseCursor: String?, + oldCursor: AtCursor, + feed: List, + data: MorphoData, + uri: AtUri = data.uri, + title: String = data.title, + api: MorphoAgent? = null, + ): Flow> = flow { + val newItems = fromFeed( + feed.toList(), AtCursor(responseCursor, 0), + uri = uri, title = title, query = query).collectThreads().single() + emit(if (oldCursor != AtCursor.EMPTY && data.items.isNotEmpty()) { + val newScroll = maxOf(data.items.size, oldCursor.scroll) + concat(data, newItems, AtCursor(responseCursor, newScroll), query = query) + } else if (oldCursor == AtCursor.EMPTY && data.items.isNotEmpty()) { + concat(newItems, data,AtCursor(responseCursor, 0), query = query) + } else { + newItems + }) + } + + fun concatNonThreadedFeed( + query: JsonElement, + responseCursor: String?, + oldCursor: AtCursor, + feed: List, + data: MorphoData, + uri: AtUri = data.uri, + title: String = data.title, + ): MorphoData { + val newItems = fromFeed( + feed.toList(), AtCursor(responseCursor, 0), + uri = uri, title = title, query = query) + return if (oldCursor != AtCursor.EMPTY && data.items.isNotEmpty()) { + val newScroll = if(oldCursor.scroll == 0) 0 else maxOf(data.items.size, oldCursor.scroll) + concat(data, newItems, AtCursor(responseCursor, newScroll), query = query) + } else if (oldCursor == AtCursor.EMPTY && data.items.isNotEmpty()) { + concat(newItems, data,AtCursor(responseCursor, 0), query = query) + } else { + newItems + } + } + + fun concat( first: MorphoData, - last: List, - cursor: AtCursor = first.cursor + last: MorphoData, + cursor: AtCursor = last.cursor, + query: JsonElement = JsonObject(emptyMap()), ): MorphoData { - return MorphoData( - items = (first.items union last).toPersistentList() - .sortedByDescending { - when (it) { - is MorphoDataItem.Post -> it.post.createdAt - is MorphoDataItem.Thread -> it.thread.post.createdAt - is MorphoDataItem.FeedInfo -> it.feed.indexedAt - is MorphoDataItem.ListInfo -> it.list.indexedAt - is MorphoDataItem.ModLabel -> Moment(Instant.DISTANT_PAST) - is MorphoDataItem.ProfileItem -> Moment(Instant.DISTANT_PAST) - is MorphoDataItem.LabelService -> it.service.indexedAt - else -> { - Moment(Instant.DISTANT_PAST) - } - } - }.toList(), + return first.copy( + items = (first.items + last.items).toPersistentList(), +// .sortedByDescending { +// when (it) { +// is MorphoDataItem.Post -> it.post.createdAt +// is MorphoDataItem.Thread -> it.thread.post.createdAt +// is MorphoDataItem.FeedInfo -> it.feed.indexedAt +// is MorphoDataItem.ListInfo -> it.list.indexedAt +// is MorphoDataItem.ModLabel -> Moment(Instant.DISTANT_PAST) +// is MorphoDataItem.ProfileItem -> Moment(Instant.DISTANT_PAST) +// is MorphoDataItem.LabelService -> it.service.indexedAt +// else -> { +// Moment(Instant.DISTANT_PAST) +// } +// } +// }.toList(), + cursor = cursor, title = first.title, uri = first.uri + ) + } + + fun concat( + first: MorphoData, + last: List, + cursor: AtCursor = first.cursor, + query: JsonElement = JsonObject(emptyMap()), + ): MorphoData { + return first.copy( + items = (first.items + last), cursor = cursor, title = first.title, uri = first.uri ) } @@ -79,28 +195,73 @@ data class MorphoData( fun concat( first: List, last: MorphoData, - cursor: AtCursor = last.cursor + cursor: AtCursor = last.cursor, ): MorphoData { - return MorphoData( - items = (first union last.items).toPersistentList() - .sortedByDescending { - when (it) { - is MorphoDataItem.Post -> it.post.createdAt - is MorphoDataItem.Thread -> it.thread.post.createdAt - is MorphoDataItem.FeedInfo -> it.feed.indexedAt - is MorphoDataItem.ListInfo -> it.list.indexedAt - is MorphoDataItem.ModLabel -> Moment(Instant.DISTANT_PAST) - is MorphoDataItem.ProfileItem -> Moment(Instant.DISTANT_PAST) - is MorphoDataItem.LabelService -> it.service.indexedAt - else -> { - Moment(Instant.DISTANT_PAST) - } - } - }.toList(), + return last.copy( + items = (first + last.items), cursor = cursor, title = last.title, uri = last.uri ) } + fun concat( + posts: List, + feed: MorphoData, + cursor: AtCursor = feed.cursor, + query: JsonElement = JsonObject(emptyMap()), + ): MorphoData { + val new = fromFeed( + feedPosts = posts, + cursor, + feed.title, + feed.uri, + query = feed.query, + ) + return concat(new, feed, cursor, query) + } + + fun fromFeedGenList( + title: String, + uri: AtUri, + feeds: List, + cursor: AtCursor = AtCursor.EMPTY, + ): MorphoData { + return MorphoData( + title = title, + uri = uri, + cursor = cursor, + items = feeds.map { MorphoDataItem.FeedInfo(it) }.toMutableList(), + ) + } + + fun fromProfileList( + title: String, + uri: AtUri, + list: List, + cursor: AtCursor = AtCursor.EMPTY, + ): MorphoData { + return MorphoData( + title = title, + uri = uri, + cursor = cursor, + items = list.map { MorphoDataItem.ProfileItem(it) }.toMutableList(), + ) + } + + fun fromBskyList( + title: String, + uri: AtUri, + lists: List, + cursor: AtCursor = AtCursor.EMPTY, + ): MorphoData { + return MorphoData( + title = title, + uri = uri, + cursor = cursor, + items = lists.map { MorphoDataItem.ListInfo(it) }.toMutableList(), + ) + } + + } val isHome: Boolean @@ -129,6 +290,7 @@ data class MorphoData( uri.atUri.matches(AtUri.ProfileUserListsUriRegex) -> FeedType.PROFILE_USER_LISTS uri.atUri.matches(AtUri.ProfileModServiceUriRegex) -> FeedType.PROFILE_MOD_SERVICE uri.atUri.matches(AtUri.ProfileFeedsListUriRegex) -> FeedType.PROFILE_FEEDS_LIST + uri.atUri.matches(AtUri.ListFeedUriRegex) -> FeedType.LIST_FOLLOWING else -> FeedType.OTHER } @@ -141,29 +303,250 @@ data class MorphoData( is MorphoDataItem.ListInfo -> it.list.cid == cid is MorphoDataItem.ModLabel -> false is MorphoDataItem.ProfileItem -> false - is MorphoDataItem.LabelService -> it.service.cid == cid else -> {false} } } } -} -fun MorphoDataFeed.toMorphoData( - title: String = "", - newUri: AtUri? = null -): MorphoData { - return MorphoData( - title = title, - uri = newUri ?: uri, - cursor = cursor, - items = items - ) + fun collectThreads( + depth: Int = 3, height: Int = 80, + timeRange: Delta = Delta(Duration.parse("4h")), + repliesBumpThreads: Boolean = !isProfileFeed, + api: MorphoAgent? = null, // allows to just use local data + ): Flow> = flow { + val threads = mutableListOf() + val replies = mutableListOf() + val posts = mutableListOf() + val threadCandidates = mutableListOf() + items.fastForEach { item -> + when(item) { + is MorphoDataItem.Post -> { + if (item.isReply) replies.add(item) + else if (item.isOrphan) posts.add(item) + else posts.add(item) + } + is MorphoDataItem.Thread -> { + if (!item.isIncompleteThread) threads.add(item) + else threadCandidates.add(item) + } + else -> return@fastForEach + } + } + replies.fastForEachIndexed { index, reply -> + if (reply == null) return@fastForEachIndexed + if (reply.isOrphan) { + val parent = reply.post.reply?.parentPost + ?: reply.post.reply?.replyRef?.parent?.uri?.let { + if (api != null) { + null // stubbed out before removing + //getPost(it, api).firstOrNull() + } else null + } + val root = reply.post.reply?.rootPost + ?: reply.post.reply?.replyRef?.root?.uri?.let { + if (api != null) { + null // stubbed out before removing + //getPost(it, api).firstOrNull() + } else null + } + replies[index] = MorphoDataItem.Post( + reply.post.copy(reply = reply.post.reply?.copy(parentPost = parent, rootPost = root)), + reply.reason, + isOrphan = root != null && parent != null, + ) + } + val newReply = replies[index] ?: return@fastForEachIndexed // Update in case we changed it above + val replyRef = newReply.post.reply?.replyRef ?: return@fastForEachIndexed + val parent = replyRef.parent.uri + val root = replyRef.root.uri + val inThread = threads.indexOfFirst { it?.containsUri(parent) ?: false || it?.containsUri(root) ?: false } + if (inThread != -1) { + val thread = threads.getOrNull(inThread) ?: return@fastForEachIndexed + threads[inThread] = thread.addReply(newReply.post) + replies[index] = null + } + val inCandidates = threadCandidates.indexOfFirst { it?.containsUri(parent) ?: false || it?.containsUri(root) ?: false } + if (inCandidates != -1) { + val thread = threadCandidates.getOrNull(inCandidates) ?: return@fastForEachIndexed + threadCandidates[inCandidates] = thread.addReply(newReply.post) + replies[index] = null + } + + } + threadCandidates.fastForEachIndexed { index, thread -> + if (thread == null) return@fastForEachIndexed + val rootInThreads = threads.indexOfFirst { t -> t?.containsUri(thread.rootUri) ?: false } + if (rootInThreads == - 1) { + val threadToSplice = threads.getOrNull(rootInThreads) ?: return@fastForEachIndexed + if( + thread.thread.parents.firstOrNull() is ThreadPost.ViewablePost + && threadToSplice.thread.parents.firstOrNull() is ThreadPost.ViewablePost + && thread.rootUri == threadToSplice.rootUri + ) { + if(thread.thread.parents.size == 1 && threadToSplice.thread.parents.size == 1) { + // Both threads have the same, viewable root post and are only one level deep in terms of parents + val newEntry = thread.thread.parents.first() as ThreadPost.ViewablePost + val oldEntry = threadToSplice.thread.parents.first() as ThreadPost.ViewablePost + + val newReplies = (newEntry.replies + oldEntry.replies).distinctBy { it.uri }.toMutableList() + newReplies.add(ThreadPost.ViewablePost(thread.thread.post, null, thread.thread.replies)) + if( thread.getUri() != threadToSplice.getUri() ) + newReplies.add(ThreadPost.ViewablePost(threadToSplice.thread.post, null, threadToSplice.thread.replies)) + val newThread = BskyPostThread( + post = newEntry.post, + parent = null, + replies = newReplies.distinctBy { it.uri }, + ) + threads[rootInThreads] = threadToSplice.copy(thread = newThread, isIncompleteThread = false) + threadCandidates[index] = null + } else if(thread.thread.parents.size == 2 && threadToSplice.thread.parents.size == 2) { + // Both threads have the same, viewable root post and parent chains are both length 2 + val newEntry = thread.thread.parents.first() as ThreadPost.ViewablePost + + val newReplies = mutableListOf() + if(thread.thread.parents.lastOrNull() !is ThreadPost.ViewablePost) return@fastForEachIndexed + if(threadToSplice.thread.parents.lastOrNull() !is ThreadPost.ViewablePost) return@fastForEachIndexed + val newParent = thread.thread.parents.last() as ThreadPost.ViewablePost + val oldParent = threadToSplice.thread.parents.last() as ThreadPost.ViewablePost + val newReply = ThreadPost.ViewablePost(thread.thread.post, null, thread.thread.replies) + val oldReply = ThreadPost.ViewablePost(threadToSplice.thread.post, null, threadToSplice.thread.replies) + newParent.addReply(newReply) + oldParent.addReply(oldReply) + newReplies.add(newReply) + newReplies.add(oldReply) + val newThread = BskyPostThread( + post = newEntry.post, + parent = newParent, + replies = newReplies.distinctBy { it.uri }, + ) + threads[rootInThreads] = threadToSplice.copy(thread = newThread, isIncompleteThread = false) + threadCandidates[index] = null + } + + } + } else { + val inThreads = threads.indexOfFirst { t -> t?.containsUri(thread.thread.post.uri) ?: false } + if (inThreads == - 1) { + val threadToSplice = threads.getOrNull(index) ?: return@fastForEachIndexed + threads[index] = threadToSplice.addReply(ThreadPost.ViewablePost(thread.thread.post, null, thread.thread.replies)) + threadCandidates[index] = null + } + } + } + threadCandidates.fastFilterNotNull() + if (threadCandidates.isNotEmpty()) threads.addAll(threadCandidates) + val newReplies = replies.filterNotNull() + .distinctBy { it.getUri() } + .filterNot { reply -> + if(reply.isRepost) return@filterNot false + if(reply.isQuotePost) return@filterNot false + reply.getUris().any { uri -> threads.any { it?.containsUri(uri) ?: false } } + }.sortedByDescending { when(it.reason) { + is BskyPostReason.BskyPostRepost -> it.reason.indexedAt + else -> it.post.createdAt + } }.iterator() + var newPosts = posts.toList().filterNotNull() + newPosts = newPosts.distinctBy { it.getUri() } + newPosts = newPosts.filterNot { post -> + if(post.isRepost) return@filterNot false + if(post.isQuotePost) return@filterNot false + post.getUris().any { uri -> threads.any { it?.containsUri(uri) ?: false } } + }.sortedByDescending { when(it.reason) { + is BskyPostReason.BskyPostRepost -> it.reason.indexedAt + else -> it.post.createdAt + } } + val newPostsIter = newPosts.iterator() + var newThreads = threads.toList().filterNotNull() + newThreads = newThreads.sortedByDescending { if(!repliesBumpThreads) { + it.rootAccessiblePost.createdAt + } else { + maxOf(it.thread.post.createdAt, + it.thread.replies.fold(it.thread.post.createdAt) { acc, post -> + val postTime = when(post) { + is ThreadPost.ViewablePost -> post.post.createdAt + is ThreadPost.BlockedPost -> Moment(Instant.DISTANT_PAST) + is ThreadPost.NotFoundPost -> Moment(Instant.DISTANT_PAST) + } + maxOf(acc, postTime) + }) + } } + newThreads = newThreads.distinctBy { it.getUri() } + .filterNot { thread -> + thread.getUris().filterNot { uri -> + newThreads.fastAny { it.getUri() == uri } }.size > 1 + } + val newThreadsIter = newThreads.iterator() + val newFeed = mutableListOf() + while(newPostsIter.hasNext() || newThreadsIter.hasNext() || newReplies.hasNext() ) { + if(newPostsIter.hasNext()) newFeed.add(newPostsIter.next()) + if(newThreadsIter.hasNext()) newFeed.add(newThreadsIter.next()) + if(newReplies.hasNext()) newFeed.add(newReplies.next()) + } + val dedupedFeed = newFeed.distinctBy { it.getUri() } + //println("New feed:\n${newFeed.joinToString("\n")}") + val sortedFeed = dedupedFeed.sortedByDescending { + when(it) { + is MorphoDataItem.Post -> when(it.reason) { + is BskyPostReason.BskyPostFeedPost -> it.post.createdAt + is BskyPostReason.BskyPostRepost -> it.reason.indexedAt + is BskyPostReason.SourceFeed -> it.post.createdAt + null -> it.post.createdAt + } + is MorphoDataItem.Thread -> if(!repliesBumpThreads) { + it.rootAccessiblePost.createdAt + } else { + maxOf(it.thread.post.createdAt, + it.thread.replies.fold(it.thread.post.createdAt) { acc, post -> + val postTime = when(post) { + is ThreadPost.ViewablePost -> post.post.createdAt + is ThreadPost.BlockedPost -> Moment(Instant.DISTANT_PAST) + is ThreadPost.NotFoundPost -> Moment(Instant.DISTANT_PAST) + } + maxOf(acc, postTime) + }) + } + } + } + //println("sorted feed:\n${sortedFeed.joinToString("\n")}") + @Suppress("UNCHECKED_CAST") val newData = copy( items = sortedFeed as List) + emit(newData) + }.flowOn(Dispatchers.Default) + + fun dedup(): MorphoData { + val newList = items.fastDistinctBy { when(it) { + is MorphoDataItem.FeedItem -> it.key + is MorphoDataItem.Post -> it.key + is MorphoDataItem.Thread -> it.key + is MorphoDataItem.ListInfo -> it.list.uri + is MorphoDataItem.ModLabel -> it.label.identifier + is MorphoDataItem.ProfileItem -> it.profile.did + else -> {it.hashCode()} + } } + return this.copy(items = newList) + } + + } -fun AtUri.id(api:Butterfly): AtIdentifier { + +suspend fun AtUri.id(agent: MorphoAgent): AtIdentifier { val idString = atUri.substringAfter("at://").split("/")[0] - return if (idString == "me") api.atpUser!!.id else { - // TODO: make this resolve a handle to a DID - if (idString.contains("did:")) Did(idString) else Handle(idString) + return if (idString == "me") agent.id!! else { + if (idString.contains("did:")) Did(idString) + else agent.resolveHandle(Handle(idString)).getOrNull() ?: Handle(idString) + } +} + +fun areSameAuthor(authors: AuthorContext): Boolean { + val authorDid = authors.author.did + if(authors.parentAuthor != null && authors.parentAuthor.did != authorDid) { + return false + } + if(authors.grandParentAuthor != null && authors.grandParentAuthor.did != authorDid) { + return false } -} \ No newline at end of file + if(authors.rootAuthor != null && authors.rootAuthor.did != authorDid) { + return false + } + return true +} diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Presenters.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Presenters.kt new file mode 100644 index 0000000..d337efe --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/Presenters.kt @@ -0,0 +1,140 @@ +package com.morpho.app.model.uidata + +import app.bsky.feed.GetActorFeedsQuery +import app.bsky.graph.GetListsQuery +import app.cash.paging.Pager +import app.cash.paging.cachedIn +import com.morpho.app.data.MorphoAgent +import com.morpho.app.data.MorphoDataSource +import com.morpho.app.model.bluesky.MorphoDataItem +import com.morpho.app.model.bluesky.toFeedGenerator +import com.morpho.app.model.bluesky.toList +import com.morpho.butterfly.AtIdentifier +import com.morpho.butterfly.Cursor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +abstract class Presenter: KoinComponent { + val agent: MorphoAgent by inject() + val presenterScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + abstract fun produceUpdates(events: Flow): Flow +} + + + +abstract class PagedPresenter: Presenter() { + abstract var pager: Pager +} + +class UserListPresenter( + val actor: AtIdentifier, +): PagedPresenter() { + override var pager: Pager = run { + val pagingConfig = MorphoDataSource.defaultConfig + Pager(pagingConfig) { + UserListFeedSource(actor) + } + } + + override fun produceUpdates(events: Flow): Flow = events.map { event -> + when(event) { + is FeedEvent.LoadLists -> AuthorFeedUpdate.Lists(actor, pager.flow.cachedIn(presenterScope)) + else -> AuthorFeedUpdate.Error("Unknown event type: $event") + } + } + +} + +class UserListFeedSource( + val actor: AtIdentifier, +): MorphoDataSource() { + + override suspend fun load(params: LoadParams): LoadResult { + try { + val limit = params.loadSize + val loadCursor = when(params) { + is LoadParams.Append -> params.key + is LoadParams.Prepend -> Cursor.Empty + is LoadParams.Refresh -> Cursor.Empty + } + return agent.api.getLists(GetListsQuery(actor, limit.toLong(), loadCursor.value)).map { response -> + val newCursor = Cursor(response.cursor) + val items = response.lists + .map { MorphoDataItem.ListInfo(it.toList()) } + LoadResult.Page( + data = items, + prevKey = when(params) { + is LoadParams.Append -> loadCursor + is LoadParams.Prepend -> Cursor.Empty + is LoadParams.Refresh -> Cursor.Empty + }, + nextKey = newCursor, + ) + }.onFailure { + return LoadResult.Error(it) + }.getOrDefault(LoadResult.Error(Exception("Load failed"))) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } +} + +class UserFeedsPresenter( + val actor: AtIdentifier, +): PagedPresenter() { + override var pager: Pager = run { + val pagingConfig = MorphoDataSource.defaultConfig + Pager(pagingConfig) { + UserFeedsFeedSource(actor) + } + } + + override fun produceUpdates(events: Flow): Flow = events.map { event -> + when(event) { + is FeedEvent.LoadLists -> AuthorFeedUpdate.Feeds(actor, pager.flow.cachedIn(presenterScope)) + else -> AuthorFeedUpdate.Error("Unknown event type: $event") + } + } + +} + +class UserFeedsFeedSource( + val actor: AtIdentifier, +): MorphoDataSource() { + + override suspend fun load(params: LoadParams): LoadResult { + try { + val limit = params.loadSize + val loadCursor = when(params) { + is LoadParams.Append -> params.key + is LoadParams.Prepend -> Cursor.Empty + is LoadParams.Refresh -> Cursor.Empty + } + return agent.api + .getActorFeeds(GetActorFeedsQuery(actor, limit.toLong(), loadCursor.value)) + .map { response -> + val newCursor = Cursor(response.cursor) + val items = response.feeds + .map { MorphoDataItem.FeedInfo(it.toFeedGenerator()) } + LoadResult.Page( + data = items, + prevKey = when(params) { + is LoadParams.Append -> loadCursor + is LoadParams.Prepend -> Cursor.Empty + is LoadParams.Refresh -> Cursor.Empty + }, + nextKey = newCursor, + ) + }.onFailure { + return LoadResult.Error(it) + }.getOrDefault(LoadResult.Error(Exception("Load failed"))) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ProfilePresenters.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ProfilePresenters.kt new file mode 100644 index 0000000..d7cdd60 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/ProfilePresenters.kt @@ -0,0 +1,271 @@ +package com.morpho.app.model.uidata + +import app.bsky.feed.GetActorFeedsQuery +import app.bsky.graph.GetListsQuery +import com.morpho.app.data.MorphoAgent +import com.morpho.app.model.bluesky.AuthorFilter +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.bluesky.FeedDescriptor +import com.morpho.app.model.bluesky.toLabelService +import com.morpho.app.model.bluesky.toProfile +import com.morpho.app.model.uistate.ContentCardState +import com.morpho.app.model.uistate.ListsOrFeeds +import com.morpho.butterfly.Did +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import org.lighthousegames.logging.logging + +class MyProfilePresenter( + val profileState: ContentCardState.MyProfile, +): Presenter() { + + + val postsPresenter = FeedPresenter( + descriptor = FeedDescriptor.Author(profileState.profile.did, AuthorFilter.PostsNoReplies) + ) + + val postRepliesPresenter = FeedPresenter( + descriptor = FeedDescriptor.Author(profileState.profile.did, AuthorFilter.PostsWithReplies) + ) + val mediaPresenter = FeedPresenter( + descriptor = FeedDescriptor.Author(profileState.profile.did, AuthorFilter.PostsWithMedia) + ) + val likesPresenter = FeedPresenter( + descriptor = FeedDescriptor.Likes(profileState.profile.did) + ) + val listsPresenter = UserListPresenter(profileState.profile.did) + val feedsPresenter = UserFeedsPresenter(profileState.profile.did) + + init { + presenterScope.launch { + profileState.posts.updates.emitAll( + postsPresenter.produceUpdates(merge(profileState.events, profileState.posts.events))) + } + presenterScope.launch { + profileState.postReplies.updates.emitAll( + postRepliesPresenter.produceUpdates(merge(profileState.events, profileState.postReplies.events))) + } + presenterScope.launch { + profileState.media.updates.emitAll(mediaPresenter + .produceUpdates(merge(profileState.events, profileState.media.events))) + } + presenterScope.launch { + profileState.likes.updates.emitAll(likesPresenter + .produceUpdates(merge(profileState.events, profileState.likes.events))) + } + if(profileState.lists != null) presenterScope.launch { + profileState.lists.updates.emitAll(listsPresenter + .produceUpdates(merge(profileState.events, profileState.lists.events))) + } + if(profileState.feeds != null) presenterScope.launch { + profileState.feeds.updates.emitAll(feedsPresenter + .produceUpdates(merge(profileState.events, profileState.feeds.events))) + } + } + + companion object { + val log = logging("ProfilePresenter") + suspend fun initialize( + agent: MorphoAgent, + myProfile: DetailedProfile? = null, + ): ContentCardState.MyProfile? { + val id = agent.id ?: return null + val profile = myProfile ?: agent.getProfile(id).getOrNull()?.toProfile() ?: return null + val hasFeeds = agent.api + .getActorFeeds(GetActorFeedsQuery(id, 1, null)).getOrNull()?.feeds?.isNotEmpty() + ?: false + val hasLists = agent.api + .getLists(GetListsQuery(id, 1, null)).getOrNull()?.lists?.isNotEmpty() ?: false + val maybeLabeler = agent.getLabelers(listOf(profile.did)) + .getOrNull()?.firstOrNull()?.toLabelService(agent) + + return ContentCardState.MyProfile( + profile = profile, + lists = if (hasLists) ContentCardState.ProfileList( + profile = profile, + ListsOrFeeds.Lists, + ) else null, + feeds = if (hasFeeds) ContentCardState.ProfileList( + profile = profile, + ListsOrFeeds.Feeds, + ) else null, + labeler = if (maybeLabeler != null) ContentCardState.ProfileLabeler( + profile = maybeLabeler, + uri = maybeLabeler.uri, + ) else null, + ) + } + suspend fun create( + agent: MorphoAgent, + profile: DetailedProfile? = null, + ): MyProfilePresenter? { + val state = initialize(agent, profile) ?: return null + return MyProfilePresenter(state) + } + } + + + override fun produceUpdates(events: Flow): Flow { + val did = profileState.profile.did + val combined = merge(events, profileState.events) + return combined.map { event -> + when (event) { + + is Event.ComposePost -> UIUpdate.OpenComposer(event.post, event.role) + + else -> { + log.d { "Unhandled event: $event" } + UIUpdate.NoOp + } + } + } as Flow + } +} + +class ProfilePresenter( + val profileState: ContentCardState.FullProfile, +): Presenter() { + + + val postsPresenter = FeedPresenter( + descriptor = FeedDescriptor.Author(profileState.profile.did, AuthorFilter.PostsNoReplies) + ) + + val postRepliesPresenter = FeedPresenter( + descriptor = FeedDescriptor.Author(profileState.profile.did, AuthorFilter.PostsWithReplies) + ) + val mediaPresenter = FeedPresenter( + descriptor = FeedDescriptor.Author(profileState.profile.did, AuthorFilter.PostsWithMedia) + ) + val listsPresenter = UserListPresenter(profileState.profile.did) + val feedsPresenter = UserFeedsPresenter(profileState.profile.did) + + init { + presenterScope.launch { + profileState.posts.updates.emitAll( + postsPresenter.produceUpdates(merge(profileState.events, profileState.posts.events))) + } + presenterScope.launch { + profileState.postReplies.updates.emitAll( + postRepliesPresenter.produceUpdates(merge(profileState.events, profileState.postReplies.events))) + } + presenterScope.launch { + profileState.media.updates.emitAll(mediaPresenter + .produceUpdates(merge(profileState.events, profileState.media.events))) + } + if(profileState.lists != null) presenterScope.launch { + profileState.lists.updates.emitAll(listsPresenter + .produceUpdates(merge(profileState.events, profileState.lists.events))) + } + if(profileState.feeds != null) presenterScope.launch { + profileState.feeds.updates.emitAll(feedsPresenter + .produceUpdates(merge(profileState.events, profileState.feeds.events))) + } + } + companion object { + val log = logging("ProfilePresenter") + suspend fun initialize( + agent: MorphoAgent, + actor: Did, + actorProfile: DetailedProfile? = null, + ): ContentCardState.FullProfile? { + val profile = actorProfile ?: agent.getProfile(actor).getOrNull()?.toProfile() ?: return null + val hasFeeds = agent.api + .getActorFeeds(GetActorFeedsQuery(actor, 1, null)).getOrNull()?.feeds?.isNotEmpty() + ?: false + val hasLists = agent.api + .getLists(GetListsQuery(actor, 1, null)).getOrNull()?.lists?.isNotEmpty() ?: false + val maybeLabeler = agent.getLabelers(listOf(profile.did)) + .getOrNull()?.firstOrNull()?.toLabelService(agent) + + return ContentCardState.FullProfile( + profile = profile, + lists = if (hasLists) ContentCardState.ProfileList( + profile = profile, + ListsOrFeeds.Lists, + ) else null, + feeds = if (hasFeeds) ContentCardState.ProfileList( + profile = profile, + ListsOrFeeds.Feeds, + ) else null, + labeler = if (maybeLabeler != null) ContentCardState.ProfileLabeler( + profile = maybeLabeler, + uri = maybeLabeler.uri, + ) else null, + ) + } + suspend fun create( + agent: MorphoAgent, + actor: Did, + actorProfile: DetailedProfile? = null, + ): ProfilePresenter? { + val state = initialize(agent, actor, actorProfile) ?: return null + return ProfilePresenter(state) + } + } + + + override fun produceUpdates(events: Flow): Flow { + val did = profileState.profile.did + val combined = merge(events, profileState.events) + return combined.map { event -> + when (event) { + is ProfileEvent.Block -> if(did == event.subject) { + agent.block(event.subject) + ActorUpdate.Blocked + } else UIUpdate.NoOp + is ProfileEvent.Follow -> if(did == event.subject) { + agent.follow(event.subject) + ActorUpdate.Followed + } else UIUpdate.NoOp + is ProfileEvent.Mute -> if(did == event.subject) { + agent.mute(event.subject) + ActorUpdate.Muted + } else UIUpdate.NoOp + is ProfileEvent.ReportAccount -> if(did == event.subject) { + ActorUpdate.Reported + } else UIUpdate.NoOp + is ProfileEvent.Unblock -> if(profileState.profile.block?.uri == event.uri) { + agent.unblock(event.uri) + ActorUpdate.Unblocked + } else UIUpdate.NoOp + is ProfileEvent.Unfollow -> if(profileState.profile.following?.uri == event.uri) { + agent.deleteFollow(event.uri) + ActorUpdate.Unfollowed + } else UIUpdate.NoOp + is ProfileEvent.Unmute -> if(profileState.profile.mutedByMe) { + agent.unmute(event.subject) + ActorUpdate.Unmuted + } else UIUpdate.NoOp + is Event.ComposePost -> UIUpdate.OpenComposer(event.post, event.role) + is LabelerEvent.LikeLabeler -> { + agent.like(event.like) + ActorUpdate.Liked + } + is LabelerEvent.SetLabelPref -> { + // TODO: update labeler + UIUpdate.NoOp + } + is LabelerEvent.Subscribe -> { + agent.addLabeler(event.did) + UIUpdate.NoOp + } + is LabelerEvent.UnlikeLabeler -> if (profileState.labeler?.profile?.likeUri == event.uri) { + agent.deleteLike(event.uri) + ActorUpdate.Unliked + } else UIUpdate.NoOp + is LabelerEvent.Unsubscribe -> { + agent.removeLabeler(event.did) + UIUpdate.NoOp + } + else -> { + log.d { "Unhandled event: $event" } + UIUpdate.NoOp + } + } + } as Flow + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/UIUpdate.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/UIUpdate.kt new file mode 100644 index 0000000..1a9974f --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uidata/UIUpdate.kt @@ -0,0 +1,121 @@ +package com.morpho.app.model.uidata + +import app.cash.paging.PagingData +import com.morpho.app.model.bluesky.AuthorFilter +import com.morpho.app.model.bluesky.BskyPost +import com.morpho.app.model.bluesky.BskyPostThread +import com.morpho.app.model.bluesky.MorphoDataItem +import com.morpho.app.ui.common.ComposerRole +import com.morpho.butterfly.AtIdentifier +import com.morpho.butterfly.AtUri +import kotlinx.coroutines.flow.Flow + +sealed interface UIUpdate { + data class OpenComposer( + val initialContent: BskyPost, + val role: ComposerRole, + ): UIUpdate + data object Empty: UIUpdate + data object NoOp: UIUpdate +} + +sealed interface SearchUpdate: UIUpdate { + data object Empty: SearchUpdate + + data class Error(val error: String): SearchUpdate + + data class ProfileSearchResults( + val query: String? = null, + val term: String? = null, + val results: Flow>, + ): SearchUpdate + + data class ProfileSearchTypeahead( + val query: String? = null, + val term: String? = null, + val results: Flow>, + ): SearchUpdate + + data class PostSearchResults( + val query: String? = null, + val results: Flow>, + ): SearchUpdate +} + +sealed interface FeedUpdate: UIUpdate { + data object Empty: FeedUpdate + + data class Error(val error: String): FeedUpdate + + data class Feed( + val uri: AtUri, + val feed: Flow>, + ): FeedUpdate + + data class Peek( + val uri: AtUri, + val post: Flow, + ): FeedUpdate +} + +sealed interface AuthorFeedUpdate: UIUpdate { + + data object Empty: AuthorFeedUpdate + + data class Error(val error: String): AuthorFeedUpdate + + data class Feed( + val actor: AtIdentifier, + val filter: AuthorFilter, + val feed: Flow>, + ): AuthorFeedUpdate + + data class Likes( + val actor: AtIdentifier, + val feed: Flow>, + ): AuthorFeedUpdate + + data class Lists( + val actor: AtIdentifier, + val feed: Flow>, + ): AuthorFeedUpdate + + data class Feeds( + val actor: AtIdentifier, + val feed: Flow>, + ): AuthorFeedUpdate +} + + +sealed interface ThreadUpdate: UIUpdate { + data object Empty: ThreadUpdate + + data class Error(val error: String): ThreadUpdate + + data class Thread( + val results: BskyPostThread, + ): ThreadUpdate +} + +sealed interface MyProfileUpdate: UIUpdate { + data object Empty: MyProfileUpdate + + data class Error(val error: String): MyProfileUpdate + data object Editing: MyProfileUpdate + data object ExitEditing: MyProfileUpdate +} + +sealed interface ActorUpdate: UIUpdate { + data object Empty : ActorUpdate + + data class Error(val error: String) : ActorUpdate + data object Followed : ActorUpdate + data object Unfollowed : ActorUpdate + data object Muted : ActorUpdate + data object Unmuted : ActorUpdate + data object Blocked : ActorUpdate + data object Unblocked : ActorUpdate + data object Reported : ActorUpdate + data object Liked : ActorUpdate + data object Unliked : ActorUpdate +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/ContentCardState.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/ContentCardState.kt index c8ce42c..209260b 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/ContentCardState.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/ContentCardState.kt @@ -1,45 +1,70 @@ package com.morpho.app.model.uistate -import com.morpho.app.model.bluesky.* -import com.morpho.app.model.uidata.MorphoData +import androidx.compose.runtime.Immutable +import com.morpho.app.model.bluesky.AuthorFilter +import com.morpho.app.model.bluesky.BskyLabelService +import com.morpho.app.model.bluesky.BskyList +import com.morpho.app.model.bluesky.BskyPost +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.bluesky.Profile +import com.morpho.app.model.uidata.Event +import com.morpho.app.model.uidata.FeedEvent +import com.morpho.app.model.uidata.FeedUpdate +import com.morpho.app.model.uidata.LabelerEvent +import com.morpho.app.model.uidata.ListEvent +import com.morpho.app.model.uidata.ListPageEvent +import com.morpho.app.model.uidata.ThreadEvent +import com.morpho.app.model.uidata.UIUpdate +import com.morpho.app.util.MutableSharedFlowSerializer +import com.morpho.app.util.MutableStateFlowSerializer import com.morpho.butterfly.AtUri +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.Serializable +@Parcelize +@Serializable +@Immutable +data class ScrollPosition( + val index: Int = 0, + val scrollOffset: Int = 0, +): Parcelable + @Suppress("unused") @Serializable -sealed interface ContentCardState { +sealed interface ContentCardState { val uri: AtUri - val feed: MorphoData - val hasNewPosts: Boolean - val loadingState: ContentLoadingState + @Serializable(with = MutableSharedFlowSerializer::class) + val events: MutableSharedFlow + @Serializable(with = MutableStateFlowSerializer::class) + val updates: MutableStateFlow + val scrollPosition: MutableStateFlow @Serializable - data class Skyline( - override val feed: MorphoData, - override val loadingState: ContentLoadingState = ContentLoadingState.Loading, - override val hasNewPosts: Boolean = false, - ) : ContentCardState, SkylineContentState { - override val uri: AtUri = feed.uri + data class Skyline( + override val uri: AtUri, + override val events: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST), + override val updates: MutableStateFlow = MutableStateFlow(FeedUpdate.Empty), + override val scrollPosition: MutableStateFlow = MutableStateFlow(ScrollPosition()), + ) : ContentCardState { + } @Serializable data class PostThread( val post: BskyPost, - val thread: StateFlow = MutableStateFlow(null).asStateFlow(), - override val loadingState: ContentLoadingState = ContentLoadingState.Loading, - override val hasNewPosts: Boolean = false, - - ): ContentCardState, PostThreadContentState { - + override val events: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST), + override val updates: MutableStateFlow = MutableStateFlow(UIUpdate.Empty), + override val scrollPosition: MutableStateFlow = MutableStateFlow(ScrollPosition()), + ): ContentCardState { override val uri: AtUri = post.uri - override val feed: MorphoData = MorphoData( - uri = post.uri, - title = "${post.author.displayName}'s Thread", - ) - init { require(post.uri.atUri.contains("app.bsky.feed.post")) { "Invalid post uri: $uri" @@ -48,69 +73,110 @@ sealed interface ContentCardState { } @Serializable - data class ProfileTimeline( + data class ProfileTimeline( + val profile: DetailedProfile, + val filter: AuthorFilter? = AuthorFilter.PostsWithReplies, + override val events: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST), + override val updates: MutableStateFlow = MutableStateFlow(FeedUpdate.Empty), + override val scrollPosition: MutableStateFlow = MutableStateFlow(ScrollPosition()), + ) : ContentCardState { + override val uri: AtUri = when(filter) { + AuthorFilter.PostsWithReplies -> AtUri.profileRepliesUri(profile.did) + AuthorFilter.PostsNoReplies -> AtUri.profilePostsUri(profile.did) + AuthorFilter.PostsAuthorThreads -> AtUri.profileRepliesUri(profile.did) + AuthorFilter.PostsWithMedia -> AtUri.profileMediaUri(profile.did) + null -> AtUri.profileLikesUri(profile.did) + } + } + + data class ProfileList( val profile: Profile, - override val feed: MorphoData, - override val loadingState: ContentLoadingState = ContentLoadingState.Loading, - override val hasNewPosts: Boolean = false, - ) : ContentCardState, SkylineContentState { - override val uri: AtUri = feed.uri - /*init { - require( - AtUri.ProfilePostsUriRegex.matches(uri.atUri) || - AtUri.ProfileRepliesUriRegex.matches(uri.atUri) || - AtUri.ProfileMediaUriRegex.matches(uri.atUri) || - AtUri.ProfileLikesUriRegex.matches(uri.atUri) || - AtUri.ProfileFeedsListUriRegex.matches(uri.atUri) || - AtUri.ProfileUserListsUriRegex.matches(uri.atUri) || - AtUri.ProfileModServiceUriRegex.matches(uri.atUri) || - uri == AtUri.MY_PROFILE_URI - ) { "Invalid profile feed uri: $uri" } - }*/ + val listsOrFeeds: ListsOrFeeds = ListsOrFeeds.Lists, + override val events: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST), + override val updates: MutableStateFlow = MutableStateFlow(UIUpdate.Empty), + override val scrollPosition: MutableStateFlow = MutableStateFlow(ScrollPosition()), + ): ContentCardState { + override val uri: AtUri = when(listsOrFeeds) { + ListsOrFeeds.Lists -> AtUri.profileUserListsUri(profile.did) + ListsOrFeeds.Feeds -> AtUri.profileFeedsListUri(profile.did) + } } - @Serializable - data class FullProfile( - val profile: T, - val postsState: StateFlow?> = MutableStateFlow(null).asStateFlow(), - val postRepliesState: StateFlow?> = MutableStateFlow(null).asStateFlow(), - val mediaState: StateFlow?> = MutableStateFlow(null).asStateFlow(), - val likesState: StateFlow?> = MutableStateFlow(null).asStateFlow(), - val listsState: StateFlow?> = MutableStateFlow(null).asStateFlow(), - val feedsState: StateFlow?> = MutableStateFlow(null).asStateFlow(), - val modServiceState: StateFlow?> = MutableStateFlow(null).asStateFlow(), - override val loadingState: ContentLoadingState = ContentLoadingState.Loading, - override val hasNewPosts: Boolean = false, - ) : ContentCardState { - override val uri: AtUri = - when(profile) { - is DetailedProfile -> AtUri.profileUri(profile.did) - is BskyLabelService -> profile.uri - else -> throw IllegalArgumentException("Invalid profile type: $profile") - } - override val feed: MorphoData = MorphoData( - uri = uri, - title = profile.displayName.orEmpty(), - ) + data class ProfileLabeler( + val profile: BskyLabelService, + override val uri: AtUri, + override val events: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST), + override val updates: MutableStateFlow = MutableStateFlow(UIUpdate.Empty), + override val scrollPosition: MutableStateFlow = MutableStateFlow(ScrollPosition()), + ): ContentCardState + data class FullProfile( + val profile: DetailedProfile, + val lists: ProfileList? = null, + val feeds: ProfileList? = null, + val labeler: ProfileLabeler? = null, + val posts: ProfileTimeline = ProfileTimeline(profile, AuthorFilter.PostsNoReplies), + val postReplies: ProfileTimeline = ProfileTimeline(profile, AuthorFilter.PostsWithReplies), + val media: ProfileTimeline = ProfileTimeline(profile, AuthorFilter.PostsWithMedia), + override val events: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST), + override val updates: MutableStateFlow = MutableStateFlow(UIUpdate.Empty), + override val scrollPosition: MutableStateFlow = MutableStateFlow(ScrollPosition()), + ) : ContentCardState { + override val uri: AtUri = AtUri.profileUri(profile.did) + } - val feedsLoaded: Boolean - get() = postsState.value?.loadingState == ContentLoadingState.Idle && - postRepliesState.value?.loadingState == ContentLoadingState.Idle && - mediaState.value?.loadingState == ContentLoadingState.Idle && - ((likesState.value == null) || (likesState.value?.loadingState == ContentLoadingState.Idle)) + data class MyProfile( + val profile: DetailedProfile, + val lists: ProfileList? = null, + val feeds: ProfileList? = null, + val labeler: ProfileLabeler? = null, + val posts: ProfileTimeline = ProfileTimeline(profile, AuthorFilter.PostsNoReplies), + val postReplies: ProfileTimeline = ProfileTimeline(profile, AuthorFilter.PostsWithReplies), + val media: ProfileTimeline = ProfileTimeline(profile, AuthorFilter.PostsWithMedia), + val likes: ProfileTimeline = ProfileTimeline(profile, null), + override val events: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST), + override val updates: MutableStateFlow = MutableStateFlow(UIUpdate.Empty), + override val scrollPosition: MutableStateFlow = MutableStateFlow(ScrollPosition()), + ) : ContentCardState { + override val uri: AtUri = AtUri.profileUri(profile.did) } @Serializable - data class UserList( + data class UserListPage( val list: BskyList, - override val loadingState: ContentLoadingState = ContentLoadingState.Loading, - override val hasNewPosts: Boolean = false, - ) : ContentCardState { + override val events: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST), + override val updates: MutableStateFlow = MutableStateFlow(UIUpdate.Empty), + override val scrollPosition: MutableStateFlow = MutableStateFlow(ScrollPosition()), + ) : ContentCardState { override val uri: AtUri = list.uri - override val feed: MorphoData = MorphoData( - uri = list.uri, - title = list.name, - ) } + + @Serializable + data class FeedPage( + val list: BskyList, + override val events: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST), + override val updates: MutableStateFlow = MutableStateFlow(UIUpdate.Empty), + override val scrollPosition: MutableStateFlow = MutableStateFlow(ScrollPosition()), + ) : ContentCardState { + override val uri: AtUri = list.uri + } +} + +enum class ListsOrFeeds { + Lists, + Feeds } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/LoadingState.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/LoadingState.kt index 5a65c97..b360686 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/LoadingState.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/LoadingState.kt @@ -1,11 +1,19 @@ package com.morpho.app.model.uistate +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Immutable +@Serializable sealed interface UiLoadingState { data object Loading : UiLoadingState data object Idle : UiLoadingState data class Error(val errorMessage: String) : UiLoadingState } + +@Immutable +@Serializable sealed interface ContentLoadingState { data object Loading : ContentLoadingState data object Idle : ContentLoadingState diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/LoginState.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/LoginState.kt index b862fae..16b2b3f 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/LoginState.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/LoginState.kt @@ -5,12 +5,14 @@ import com.morpho.butterfly.auth.AuthInfo import com.morpho.butterfly.auth.Credentials import kotlinx.serialization.Serializable +@Immutable @Serializable enum class LoginScreenMode { SIGN_UP, SIGN_IN } +@Immutable @Serializable sealed interface AuthState { data object NoAuth : AuthState diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/NotificationsState.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/NotificationsState.kt index c865dd9..492f65e 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/NotificationsState.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/NotificationsState.kt @@ -1,40 +1,17 @@ package com.morpho.app.model.uistate import androidx.compose.runtime.Immutable -import com.morpho.app.model.bluesky.NotificationsList -import com.morpho.app.model.bluesky.NotificationsListItem -import com.morpho.app.model.uidata.AtCursor -import com.morpho.app.model.uidata.filterNotifications -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable import org.koin.core.component.KoinComponent @Serializable data class NotificationsUIState( - private val notificationsList: StateFlow = MutableStateFlow(NotificationsList()), - val filterState: StateFlow = MutableStateFlow(NotificationsFilterState()), + val filterState: MutableStateFlow = MutableStateFlow(NotificationsFilterState()), val showPosts: Boolean = true, override val loadingState: UiLoadingState = UiLoadingState.Loading, -): KoinComponent, UiState { - - val cursor:AtCursor - get() = notificationsList.value.cursor - - val notifications: Flow> - get() = notificationsList.map { - filterNotifications(it.notificationsList, filterState.value) - } - - //@NativeCoroutines - val numberUnread: Flow - get() = notifications.map { items -> items.filterNot { it.isRead }.size } - -} - +): KoinComponent, UiState @Immutable @Serializable data class NotificationsFilterState( diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/PostThreadContentState.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/PostThreadContentState.kt index 614a128..ed56b17 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/PostThreadContentState.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/PostThreadContentState.kt @@ -1,8 +1,2 @@ package com.morpho.app.model.uistate -interface PostThreadContentState { - val hasNewPosts: Boolean - val loadingState: ContentLoadingState - val isLoading: Boolean - get() = loadingState == ContentLoadingState.Loading -} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/SkylineState.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/SkylineState.kt index 7326907..d71b309 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/SkylineState.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/SkylineState.kt @@ -1,24 +1,11 @@ package com.morpho.app.model.uistate -import com.morpho.app.model.bluesky.MorphoDataItem -import com.morpho.app.model.uidata.MorphoData +import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable -interface SkylineContentState { - val hasNewPosts: Boolean - val feed: MorphoData - val loadingState: ContentLoadingState - val isLoading: Boolean - get() = loadingState == ContentLoadingState.Loading -} +@Immutable @Serializable -data class SkylineState( - override val feed: MorphoData, - override val loadingState: ContentLoadingState = ContentLoadingState.Loading, - override val hasNewPosts: Boolean = false, -): SkylineContentState - enum class FeedType { HOME, PROFILE_POSTS, @@ -28,5 +15,6 @@ enum class FeedType { PROFILE_USER_LISTS, PROFILE_MOD_SERVICE, PROFILE_FEEDS_LIST, + LIST_FOLLOWING, OTHER, } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/TabbedScreenState.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/TabbedScreenState.kt deleted file mode 100644 index 5b431ee..0000000 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/model/uistate/TabbedScreenState.kt +++ /dev/null @@ -1,54 +0,0 @@ -@file:Suppress("MemberVisibilityCanBePrivate") - -package com.morpho.app.model.uistate - -//import com.rickclephas.kmp.nativecoroutines.NativeCoroutines -import com.morpho.app.model.bluesky.MorphoDataItem -import com.morpho.app.model.uidata.ContentCardMapEntry -import com.morpho.butterfly.AtUri -import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.serialization.Serializable - - -@Serializable -data class TabbedScreenState( - override val loadingState: UiLoadingState = UiLoadingState.Idle, - val tabs: StateFlow> = - MutableStateFlow>(listOf()).asStateFlow(), - val tabStates: List>> = listOf(), -): UiState { - - val tabMap: ImmutableMap> - get() = tabStates.associateBy { it.value.uri } - .filter { entry -> entry.value.value.uri in tabs.value.map { it.uri } } - .mapValues { it.value.value } - .toImmutableMap() - val tabsWithNewPosts: List - get() = tabMap.filterValues { it.hasNewPosts }.keys.toList() - -} - - - -data class TabbedProfileScreenState( - override val loadingState: UiLoadingState = UiLoadingState.Idle, - val tabs: StateFlow> = - MutableStateFlow>(listOf()).asStateFlow(), - val tabStates: List>> = listOf(), -): UiState { - - val tabMap: ImmutableMap> - get() = tabStates.associateBy { it.value.uri } - .filter { entry -> entry.value.value.uri in tabs.value.map { it.uri } } - .mapValues { it.value.value } - .toImmutableMap() - val tabsWithNewPosts: List - get() = tabMap.filterValues { it.hasNewPosts }.keys.toList() - - -} - diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/BaseScreenModel.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/BaseScreenModel.kt index 0271a09..bd77f57 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/BaseScreenModel.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/BaseScreenModel.kt @@ -1,46 +1,177 @@ package com.morpho.app.screens.base +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import app.cash.paging.Pager +import app.cash.paging.cachedIn import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope -import com.morpho.app.data.PreferencesRepository +import com.morpho.app.data.ContentLabelService +import com.morpho.app.data.MorphoAgent +import com.morpho.app.di.UpdateTick import com.morpho.app.model.bluesky.BskyPost -import com.morpho.app.model.uidata.BskyNotificationService -import com.morpho.app.model.uidata.ContentLabelService +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.bluesky.NotificationsSource +import com.morpho.app.model.bluesky.toPost +import com.morpho.app.model.bluesky.toProfile +import com.morpho.app.model.uidata.Event +import com.morpho.app.model.uidata.MyProfilePresenter +import com.morpho.app.model.uidata.ProfilePresenter +import com.morpho.app.model.uidata.UIUpdate import com.morpho.butterfly.AtUri -import com.morpho.butterfly.Butterfly -import com.morpho.butterfly.model.RecordType -import com.morpho.butterfly.model.RecordUnion -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.flow.firstOrNull +import com.morpho.butterfly.Did +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import org.lighthousegames.logging.logging -open class BaseScreenModel : ScreenModel, KoinComponent { - val api: Butterfly by inject() - val preferences: PreferencesRepository by inject() - val notifService: BskyNotificationService by inject() - val labelService: ContentLabelService by inject() +open class BaseScreenModel( + val agent: MorphoAgent, + val labelService: ContentLabelService +) : ScreenModel { + //val agent: MorphoAgent by inject() + //val labelService: ContentLabelService by inject() - val isLoggedIn: Boolean - get() = api.isLoggedIn() + var userProfile: DetailedProfile? by mutableStateOf(null) + protected set + + val kawaiiMode: Boolean + get() = agent.kawaiiMode + + var userDid: Did? by mutableStateOf(agent.id) + protected set + + val globalEvents = MutableSharedFlow( + extraBufferCapacity = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + val isLoggedIn = MutableStateFlow(agent.isLoggedIn) + + val notifications = Pager(NotificationsSource.defaultConfig) { + NotificationsSource() + }.flow.cachedIn(screenModelScope) + + var notifJob: Job? = null companion object { val log = logging() } - fun createRecord(record: RecordUnion) = screenModelScope.launch(Dispatchers.IO) { - api.createRecord(record) + private val notificationsTick = UpdateTick(10000) + init { + screenModelScope.launch { + if(!agent.isLoggedIn) { + while(!agent.isLoggedIn) { + delay(100) + isLoggedIn.value = agent.isLoggedIn + } + } + } + screenModelScope.launch { + while(!isLoggedIn.value) delay(10) + userProfile = userDid?.let { agent.getProfile(it).getOrNull()?.toProfile() } + } + notifJob = screenModelScope.launch { + notificationsTick.tick(true) + } + } + + fun sendGlobalEvent(event: Event) { + globalEvents.tryEmit(event) + } + + open fun logout() { + deinit() + isLoggedIn.value = false + userDid = null + userProfile = null + agent.logout() + } + + open fun switchUser(did: Did) { + screenModelScope.launch { + deinit() + agent.switchUser(did) + userProfile = agent.getProfile(did).getOrNull()?.toProfile() + }.invokeOnCompletion { + userDid = did + notifJob = screenModelScope.launch { + notificationsTick.tick(true) + } + isLoggedIn.value = agent.isLoggedIn + } + + } + + fun getProfilePresenter( + id: Did, + init: Boolean = false, + eventStream: Flow = globalEvents + ): Flow>> = flow { + val profile = agent.getProfile(id).getOrNull()?.toProfile() + val presenter = ProfilePresenter.create(agent, id, profile)?: return@flow + if(!init) emit(Pair(presenter, MutableStateFlow(UIUpdate.Empty))) + else { + val stateFlow = MutableStateFlow(UIUpdate.Empty) + screenModelScope.launch { + stateFlow.emitAll(presenter.produceUpdates(eventStream)) + } + emit(Pair(presenter, stateFlow)) + } + }.distinctUntilChanged() as Flow>> + + fun getMyProfilePresenter( + init: Boolean = false, + eventStream: Flow = globalEvents + ): Flow>> = flow { + val presenter = MyProfilePresenter.create(agent, userProfile)?: return@flow + if(!init) emit(Pair(presenter, MutableStateFlow(UIUpdate.Empty))) + else { + val stateFlow = MutableStateFlow(UIUpdate.Empty) + screenModelScope.launch { + stateFlow.emitAll(presenter.produceUpdates(eventStream)) + } + emit(Pair(presenter, stateFlow)) + + } + }.distinctUntilChanged() as Flow>> + + private val _unreadNotificationsCount = MutableStateFlow(0L) + val unreadNotificationsCount: StateFlow = _unreadNotificationsCount.asStateFlow() + + fun hasUnreadNotifications(): Flow = unreadNotificationsCount.map { it > 0 } + + fun unreadNotificationsCount() = notificationsTick.t.map { + val count = agent.unreadNotificationsCount().getOrDefault(0) + _unreadNotificationsCount.value = count + count + } + + fun updateSeenNotifications() = screenModelScope.launch { + agent.updateSeenNotifications() + _unreadNotificationsCount.value = 0 + globalEvents.emit(Event.UpdateSeenNotifications()) } - fun deleteRecord(type: RecordType, rkey: AtUri) = screenModelScope.launch(Dispatchers.IO) { - api.deleteRecord(type, rkey) + suspend fun getPost(uri: AtUri): Result { + return agent.getPosts(listOf(uri)).map { + if(it.isEmpty()) Result.failure(Exception("Post not found")) + else Result.success(it.first().toPost()) + }.getOrDefault(Result.failure(Exception("Post not found"))) } - suspend fun getPost(uri: AtUri): BskyPost? { - return com.morpho.app.model.uidata.getPost(uri, api).firstOrNull() + open fun deinit() { + notifJob?.cancel() } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/tabbed/NavigationTabs.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/tabbed/NavigationTabs.kt index 43344e0..83c3e55 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/tabbed/NavigationTabs.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/tabbed/NavigationTabs.kt @@ -1,44 +1,69 @@ package com.morpho.app.screens.base.tabbed import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.DynamicFeed +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.NotificationsNone +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* -import cafe.adriel.voyager.core.lifecycle.LifecycleEffect -import cafe.adriel.voyager.core.model.rememberScreenModel +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi +import cafe.adriel.voyager.core.lifecycle.LifecycleEffectOnce import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.koin.getNavigatorScreenModel +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.koin.koinNavigatorScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.currentOrThrow -import com.morpho.app.model.uistate.ContentCardState +import com.morpho.app.model.bluesky.FeedGenerator +import com.morpho.app.model.bluesky.UserList +import com.morpho.app.model.uidata.MyProfilePresenter +import com.morpho.app.model.uidata.ProfilePresenter import com.morpho.app.screens.main.tabbed.TabbedHomeView import com.morpho.app.screens.main.tabbed.TabbedMainScreenModel import com.morpho.app.screens.notifications.NotificationViewContent -import com.morpho.app.screens.notifications.TabbedNotificationScreenModel import com.morpho.app.screens.profile.TabbedProfileContent -import com.morpho.app.screens.profile.TabbedProfileViewModel +import com.morpho.app.screens.settings.SettingsRootPage +import com.morpho.app.screens.settings.SettingsScreenTransition import com.morpho.app.screens.thread.ThreadTopBar import com.morpho.app.screens.thread.ThreadViewContent import com.morpho.app.ui.common.LoadingCircle import com.morpho.app.ui.common.TabbedScreenScaffold -import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri -import kotlinx.coroutines.flow.StateFlow +import com.morpho.butterfly.Did +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +@Parcelize +@Immutable +@Serializable data class TabScreenOptions( val index: Int, val icon: @Composable () -> Unit, val title: String, -) +): Parcelable -interface TabScreen: Screen { + +interface TabScreen: Screen, Parcelable { val navBar: @Composable (Navigator) -> Unit @@ -50,18 +75,24 @@ interface TabScreen: Screen { } +@Parcelize +@Immutable +@Serializable data class HomeTab( val k: ScreenKey = "HomeTab" ): TabScreen { - override val navBar: @Composable (Navigator) -> Unit = { n -> + @kotlin.jvm.Transient @Transient override val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> TabbedNavBar(options.index, n) } - override val key: ScreenKey = "${k}_${hashCode()}" + override val key: ScreenKey + get() = "${k}_${hashCode()}${uniqueScreenKey}" + @OptIn(ExperimentalVoyagerApi::class) @Composable override fun Content() { - TabbedHomeView() + val sm = LocalNavigator.currentOrThrow.koinNavigatorScreenModel() + TabbedHomeView(sm = sm) } override val options: TabScreenOptions @@ -77,11 +108,15 @@ data class HomeTab( } +@Parcelize +@Immutable +@Serializable data object SearchTab: TabScreen { - override val key: ScreenKey = "searchTab" + override val key: ScreenKey + get() = "searchTab${uniqueScreenKey}" - override val navBar: @Composable (Navigator) -> Unit = { n -> + @kotlin.jvm.Transient @Transient override val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> TabbedNavBar(options.index, n) } @Composable @@ -102,11 +137,15 @@ data object SearchTab: TabScreen { } +@Parcelize +@Immutable +@Serializable data object FeedsTab: TabScreen { - override val key: ScreenKey = "feedsTab" + override val key: ScreenKey + get() = "feedsTab${uniqueScreenKey}" - override val navBar: @Composable (Navigator) -> Unit = { n -> + @kotlin.jvm.Transient @Transient override val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> TabbedNavBar(options.index, n) } @Composable @@ -126,20 +165,24 @@ data object FeedsTab: TabScreen { } +@Parcelize +@Immutable +@Serializable data object NotificationsTab: TabScreen { - override val key: ScreenKey = "notificationsTab" + override val key: ScreenKey + get() = "notificationsTab${uniqueScreenKey}" - override val navBar: @Composable (Navigator) -> Unit = { n -> + @kotlin.jvm.Transient @Transient override val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> TabbedNavBar(options.index, n) } + @OptIn(ExperimentalVoyagerApi::class) @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow NotificationViewContent( navigator, - navigator.getNavigatorScreenModel() ) } @@ -156,22 +199,41 @@ data object NotificationsTab: TabScreen { } +@Parcelize +@Serializable +@Immutable data class ProfileTab( - val id: AtIdentifier, - ): TabScreen { + val id: Did, +): TabScreen { - override val key: ScreenKey = "profileTab_${id}_${hashCode()}" + override val key: ScreenKey + get() = "profileTab_${id}_${uniqueScreenKey}" - override val navBar: @Composable (Navigator) -> Unit = { n -> + @kotlin.jvm.Transient @Transient override val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> TabbedNavBar(options.index, n) } - @OptIn(ExperimentalMaterial3Api::class) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) @Composable override fun Content() { - val screenModel = rememberScreenModel { TabbedProfileViewModel(id) } - val ownProfile = remember { screenModel.api.atpUser?.id == id } - TabbedProfileContent(ownProfile, screenModel) - + val sm = LocalNavigator.currentOrThrow.koinNavigatorScreenModel() + val eventStream = sm.globalEvents + var myProfilePresenter by remember { mutableStateOf(null) } + var profilePresenter by remember { mutableStateOf(null) } + LifecycleEffectOnce { + sm.screenModelScope.launch { + sm.getMyProfilePresenter().first().also { it -> myProfilePresenter = it.first } + sm.getProfilePresenter(id, true).first().also { it -> profilePresenter = it.first } + } + } + if(profilePresenter != null && myProfilePresenter != null) { + val profileState = profilePresenter!!.profileState + val myProfileState = myProfilePresenter!!.profileState + TabbedProfileContent( + profileState = profileState, + myProfileState = myProfileState, + eventCallback = { eventStream.tryEmit(it) } + ) + } } @@ -188,60 +250,82 @@ data class ProfileTab( } +@Parcelize +@Immutable +@Serializable data class ThreadTab( val uri: AtUri, ): TabScreen { - override val key: ScreenKey = "threadTab_${uri}_${hashCode()}" + override val key: ScreenKey + get() = "threadTab_${uri}_$uniqueScreenKey}" - override val navBar: @Composable (Navigator) -> Unit = { n -> - TabbedNavBar(options.index, n) - } - @Composable - override fun Content() { - val navigator = LocalNavigator.currentOrThrow - val sm = navigator.getNavigatorScreenModel() - var threadState: StateFlow? by remember { mutableStateOf(null)} - LifecycleEffect( - onStarted = { - sm.screenModelScope.launch { threadState = sm.loadThread(uri) } - } - ) - if(threadState != null) { - ThreadViewContent(threadState!!, navigator, sm) - } else { - TabbedScreenScaffold( + @kotlin.jvm.Transient @Transient override val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> + TabbedNavBar(options.index, n) + } + @OptIn(ExperimentalVoyagerApi::class) + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val sm = navigator.koinNavigatorScreenModel() + val threadState by sm.getThread(uri).collectAsState(null) + if(threadState != null) { + ThreadViewContent(threadState!!, navigator) + } else { + TabbedScreenScaffold( navBar = { navBar(navigator) }, + content = { _, _ -> LoadingCircle() }, topContent = { ThreadTopBar(navigator = navigator) }, - content = { _ -> LoadingCircle() } - ) - } + state = threadState, + modifier = Modifier, + ) } + } - override val options: TabScreenOptions - @Composable get() { - return TabScreenOptions( - index = 6, - icon = { Icon(Icons.Default.NotificationsNone, contentDescription = "Thread", - tint = MaterialTheme.colorScheme.onBackground) }, - title = "Thread" - ) - } + override val options: TabScreenOptions + @Composable get() { + return TabScreenOptions( + index = 6, + icon = { Icon(Icons.Default.NotificationsNone, contentDescription = "Thread", + tint = MaterialTheme.colorScheme.onBackground) }, + title = "Thread" + ) + } } - +@Parcelize +@Immutable +@Serializable data object MyProfileTab: TabScreen { - override val key: ScreenKey = "myProfileTab" + override val key: ScreenKey + get() = "myProfileTab${uniqueScreenKey}" - override val navBar: @Composable (Navigator) -> Unit = { n -> + override val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> TabbedNavBar(options.index, n) } - @OptIn(ExperimentalMaterial3Api::class) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) @Composable override fun Content() { - TabbedProfileContent(true) + val sm = LocalNavigator.currentOrThrow.koinNavigatorScreenModel() + val eventStream = sm.globalEvents + var myProfilePresenter by remember { mutableStateOf(null) } + LifecycleEffectOnce { + sm.screenModelScope.launch { + sm.getMyProfilePresenter().first().also { it -> myProfilePresenter = it.first } + } + } + if(myProfilePresenter != null) { + + val myProfileState = myProfilePresenter!!.profileState + TabbedProfileContent( + ownProfile = true, + profileState = null, + myProfileState = myProfileState, + eventCallback = { eventStream.tryEmit(it) } + ) + } } @@ -256,4 +340,100 @@ data object MyProfileTab: TabScreen { } +} + +@Parcelize +@Immutable +@Serializable +data object SettingsTab: TabScreen { + override val key: ScreenKey + get() = "SettingsTab${uniqueScreenKey}" + + override val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> + TabbedNavBar(options.index, n) + } + + @Composable + override fun Content() { + val sm = LocalNavigator.currentOrThrow.koinNavigatorScreenModel() + val navigator = LocalNavigator.currentOrThrow + Navigator( + SettingsRootPage, + ){ nav -> + SettingsScreenTransition( + navigator = nav, + sm = sm, + parentNav = navigator, + modifier = Modifier + ) + } + } + + override val options: TabScreenOptions + @Composable get() { + return TabScreenOptions( + index = 6, + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onBackground) }, + title = "Settings" + ) + } +} + +@Parcelize +@Immutable +@Serializable +data class FeedPageTab( + val feed: FeedGenerator, +): TabScreen { + override val key: ScreenKey + get() = "FeedPageTab_${uniqueScreenKey}" + + override val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> + TabbedNavBar(options.index, n) + } + + @Composable + override fun Content() { + TODO("Not yet implemented") + } + + override val options: TabScreenOptions + @Composable get() { + return TabScreenOptions( + index = 6, + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onBackground) }, + title = feed.displayName + ) + } +} + +@Parcelize +@Immutable +@Serializable +data class UserListPageTab( + val list: UserList, +): TabScreen { + override val key: ScreenKey + get() = "UserListPageTab_${uniqueScreenKey}" + + override val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> + TabbedNavBar(options.index, n) + } + + @Composable + override fun Content() { + TODO("Not yet implemented") + } + + override val options: TabScreenOptions + @Composable get() { + return TabScreenOptions( + index = 6, + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onBackground) }, + title = list.name + ) + } } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/tabbed/TabbedBaseScreen.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/tabbed/TabbedBaseScreen.kt index 25dd5df..a23582d 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/tabbed/TabbedBaseScreen.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/base/tabbed/TabbedBaseScreen.kt @@ -1,7 +1,15 @@ package com.morpho.app.screens.base.tabbed import androidx.compose.foundation.layout.size -import androidx.compose.material3.* +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -12,36 +20,42 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.koin.koinNavigatorScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabNavigator import cafe.adriel.voyager.navigator.tab.TabOptions -import com.morpho.app.model.uidata.BskyDataService -import com.morpho.app.model.uidata.BskyNotificationService -import com.morpho.app.screens.main.tabbed.SlideTabTransition +import com.morpho.app.screens.main.tabbed.TabbedMainScreenModel +import com.morpho.app.ui.common.SlideTabTransition import com.morpho.app.ui.theme.roundedTopR +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize import io.ktor.util.reflect.instanceOf -import org.koin.compose.koinInject +import kotlinx.serialization.Serializable import kotlin.math.min - -data object TabbedBaseScreen: Tab { +@Parcelize +@Serializable +data object TabbedBaseScreen: Tab, Parcelable { override val key: ScreenKey = "TabbedBaseScreen_${hashCode()}" - @OptIn(ExperimentalMaterial3Api::class) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) @Composable override fun Content() { Navigator( HomeTab("startHome"), + disposeBehavior = NavigatorDisposeBehavior( + disposeNestedNavigators = false + ) ) { navigator -> - /*LaunchedEffect(Unit) { navigator.replaceAll(HomeTab("startHome2")) }*/ SlideTabTransition(navigator) } - } override val options: TabOptions @@ -72,14 +86,15 @@ fun TabNavigationItem( when { nav.lastItem.key == tab.key -> return@Tab newIndex == 0 -> nav.replaceAll(tab) + nav.items.contains(tab) -> nav.popUntil { it == tab } else -> nav.push(tab) } }, icon = { when (tab) { is NotificationsTab -> { - val notifService = koinInject() - val unread by notifService.unreadCountFlow().collectAsState(0) + val sm = navigator.koinNavigatorScreenModel() + val unread by sm.unreadNotificationsCount.collectAsState(0) BadgedBox( badge = { if (unread > 0) { @@ -100,11 +115,9 @@ fun TabNavigationItem( } is HomeTab -> { - val dataService = koinInject() - val hasNew by dataService.checkIfNewTimeline().collectAsState(false) BadgedBox( badge = { - if (hasNew) { + if (false) { /// TODO: put this back in later Badge( modifier = Modifier.size(4.dp), containerColor = MaterialTheme.colorScheme.secondary @@ -133,7 +146,7 @@ fun TabbedNavBar( selectedTabIndex = min(selectedTab, 4), modifier = Modifier.clip( roundedTopR.medium - ), + ).systemBarsPadding(), indicator = { if (selectedTab <= 4) { TabRowDefaults.PrimaryIndicator( diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/login/LoginScreen.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/login/LoginScreen.kt index d7ceb51..b2e1d9a 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/login/LoginScreen.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/login/LoginScreen.kt @@ -1,11 +1,29 @@ package com.morpho.app.screens.login -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager @@ -15,16 +33,25 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.koin.getScreenModel import cafe.adriel.voyager.navigator.tab.LocalTabNavigator import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions import com.morpho.app.model.uistate.AuthState import com.morpho.app.screens.base.tabbed.TabbedBaseScreen import com.morpho.app.ui.common.LoadingCircle +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import morpho.composeapp.generated.resources.BlueSkyKawaii +import morpho.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.painterResource +import org.koin.compose.koinInject + +@Parcelize +@Serializable +data object LoginScreen: Tab, Parcelable { -data object LoginScreen: Tab { override val key: ScreenKey = hashCode().toString() + "TabbedLoginScreen" @Composable @@ -34,9 +61,9 @@ data object LoginScreen: Tab { val focusManager = LocalFocusManager.current val snackbarHostState = remember { SnackbarHostState() } val tabNavigator = LocalTabNavigator.current - val screenModel = getScreenModel() + val screenModel = koinInject() - if(screenModel.isLoggedIn) { + if(screenModel.isLoggedIn.value) { tabNavigator.current = TabbedBaseScreen } @@ -184,6 +211,9 @@ fun LoginView( innerPadding: PaddingValues ) { var appPWOverride by rememberSaveable { mutableStateOf(false) } + + val kawaiiMode = remember { screenModel.kawaiiMode } + Text( text = "Login to Bluesky", style = MaterialTheme.typography.headlineMedium, @@ -258,6 +288,17 @@ fun LoginView( }) { Text("Login") } + + if(kawaiiMode) { + Image( + painter = painterResource(Res.drawable.BlueSkyKawaii), + contentDescription = "Bluesky Kawaii", + modifier = Modifier + .fillMaxSize() + .padding(30.dp) + ) + } + Spacer(modifier = Modifier.height(80.dp)) } fun isAppPassword(password: String) : Boolean { diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/login/LoginScreenModel.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/login/LoginScreenModel.kt index 32f6b35..4199ec5 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/login/LoginScreenModel.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/login/LoginScreenModel.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import cafe.adriel.voyager.core.model.screenModelScope +import com.morpho.app.data.ContentLabelService +import com.morpho.app.data.MorphoAgent import com.morpho.app.model.uistate.AuthState import com.morpho.app.model.uistate.LoginState import com.morpho.app.model.uistate.UiLoadingState @@ -15,7 +17,10 @@ import com.morpho.butterfly.auth.Server import kotlinx.coroutines.launch import org.lighthousegames.logging.logging -class LoginScreenModel: BaseScreenModel() { +class LoginScreenModel( + agent: MorphoAgent, + labelService: ContentLabelService, +): BaseScreenModel(agent, labelService) { var loginState: LoginState by mutableStateOf(LoginState()) var email by mutableStateOf("") @@ -39,7 +44,7 @@ class LoginScreenModel: BaseScreenModel() { if(checkValidUrl(service) != null) Server.CustomServer(service) else Server.BlueskySocial } screenModelScope.launch { - api.makeLoginRequest(credentials, server).onSuccess { + agent.login(credentials, server).onSuccess { loginState = loginState.copy( loadingState = UiLoadingState.Idle, authState = AuthState.Success(it) diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/MainScreenModel.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/MainScreenModel.kt index ebad341..5331789 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/MainScreenModel.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/MainScreenModel.kt @@ -1,554 +1,108 @@ package com.morpho.app.screens.main -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Home -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import app.bsky.actor.GetProfileQuery -import app.bsky.feed.GetFeedGeneratorsQuery -import app.bsky.feed.GetPostThreadQuery -import app.bsky.feed.GetPostThreadResponseThreadUnion -import app.bsky.feed.GetPostsQuery +import androidx.compose.runtime.mutableStateListOf +import app.bsky.actor.SavedFeed import cafe.adriel.voyager.core.model.screenModelScope -import cafe.adriel.voyager.core.stack.mutableStateStackOf -import com.morpho.app.data.BskyUserPreferences -import com.morpho.app.model.bluesky.* -import com.morpho.app.model.uidata.* +import com.morpho.app.data.ContentLabelService +import com.morpho.app.data.MorphoAgent +import com.morpho.app.model.bluesky.FeedSourceInfo +import com.morpho.app.model.bluesky.toFeedSourceInfo +import com.morpho.app.model.uidata.FeedEvent +import com.morpho.app.model.uidata.FeedPresenter import com.morpho.app.model.uistate.ContentCardState -import com.morpho.app.model.uistate.ContentLoadingState -import com.morpho.app.model.uistate.FeedType import com.morpho.app.screens.base.BaseScreenModel -import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri -import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.runBlocking -import org.koin.core.component.inject +import com.morpho.butterfly.Did +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch import org.lighthousegames.logging.logging -@Suppress("UNCHECKED_CAST") -// TODO: Revisit these casts if we can, but they should be safe -open class MainScreenModel: BaseScreenModel() { - protected val dataService: BskyDataService by inject() +open class MainScreenModel( + agent: MorphoAgent, + labelService: ContentLabelService, +): BaseScreenModel( + agent = agent, + labelService = labelService, +) { - protected val _pinnedFeeds = mutableListOf() - protected val _savedFeeds = mutableListOf() - protected val _feedStates = mutableListOf>>() - val feedStates: List>> - get() = _feedStates.toList() - protected val _threadStates = mutableListOf>() - protected val _profileStates = mutableListOf>>() - protected val _profileFeeds = mutableListOf>>() + val feedSources = mutableStateListOf() + val feedPresenters = mutableMapOf>() + val pinnedFeeds: List + get() = agent.prefs.saved.filter { it.pinned } - val history = mutableStateStackOf() - val userPrefs = MutableStateFlow(null) - protected val _cursors = mutableMapOf>() - public val cursors: ImmutableMap> - get() = _cursors.toImmutableMap() + val feedStates = mutableMapOf>() - var userId: AtIdentifier? by mutableStateOf(null) - protected set + var initialized = false - var currentUser: DetailedProfile? by mutableStateOf(null) - protected set - - protected var initialized = false companion object { - val log = logging() - } - - suspend fun init(populateFeeds: Boolean = true) = runBlocking { - if(initialized) return@runBlocking - initialized = true - userId = api.atpUser?.id - if(userId != null){ - if(preferences.prefs.firstOrNull().isNullOrEmpty()){ - val prefs = userId?.let { - preferences.getPreferences(it, true) - }?.getOrNull() - log.d { "Preferences: $prefs" } - if(prefs != null) { - userPrefs.value = preferences.getFullPrefsLocal(userId!!).getOrNull() - currentUser = userPrefs.value?.user?.getProfile() - } else { - log.e { "Failed to get preferences" } - } - } else if(preferences.getUser(userId!!).isFailure) { - currentUser = userId?.let { GetProfileQuery(it) }?.let { - api.api.getProfile(it).getOrNull()?.toProfile() - } - val prefs = userId?.let { - api.api.getPreferences().getOrNull()?.toPreferences() - } - if(prefs != null && currentUser != null) { - preferences.setPreferences(BskyUser.makeUser(currentUser!!), prefs) - } else { - log.e { "Failed to get preferences" } - } - } else { - log.d { "Preferences already set maybe?" } - } - currentUser = userPrefs.value?.user?.getProfile() - if(userPrefs.value == null) { - userPrefs.value = preferences.getFullPrefs(userId!!).getOrNull() - } - if(currentUser == null) { - currentUser = userId?.let { GetProfileQuery(it) }?.let { - api.api.getProfile(it).getOrNull()?.toProfile() - } - } - preferences.userPrefs(userId!!).collect { userPrefs.value = it } - } - if(populateFeeds) initFeeds() - } - - fun getFeedInfo(uri: AtUri) : FeedInfo? { - when { - uri == AtUri.HOME_URI -> return FeedInfo(uri, "Home", "Your home feed", icon = Icons.Default.Home) - else -> { - _pinnedFeeds.firstOrNull { it.uri == uri }?.let { - return FeedInfo(uri, it.displayName, it.description, it.avatar, feed = it) - } - _savedFeeds.firstOrNull { it.uri == uri }?.let { - return FeedInfo(uri, it.displayName, it.description, it.avatar, feed = it) - } - // TODO: Get the feed info from the data service - return null - } - } - } - - protected open suspend fun initFeeds() { - val tlFlow = if(userId != null) { - initTimeline(initAtCursor()).first() - } else null - - if (tlFlow == null) { - log.e { "Failed to initialize timeline" } - // Init some default feeds - } - - val savedFeedsPref = userPrefs.value?.preferences?.savedFeeds - if (savedFeedsPref != null) { - api.api.getFeedGenerators(GetFeedGeneratorsQuery(savedFeedsPref.pinned)).onSuccess { resp -> - _pinnedFeeds.addAll(resp.feeds.map{ it.toFeedGenerator() }) - _pinnedFeeds.forEach { feedGen -> - val flow = - initFeed(feedGen, initAtCursor(), force = true, start = true).first() - if (flow == null) { log.e { "Failed to initialize feed: ${feedGen.displayName}" } } - } - } - api.api.getFeedGenerators(GetFeedGeneratorsQuery(savedFeedsPref.saved)).onSuccess { resp -> - _savedFeeds.addAll(resp.feeds.map{ it.toFeedGenerator() }) - _savedFeeds.forEach { feedGen -> - val flow = - initFeed(feedGen, initAtCursor(), force = true, start = false).first() - if (flow == null) { log.e { "Failed to initialize feed: ${feedGen.displayName}" } } - } - } - } else { - // Init some default feeds - api.api.getFeedGenerators(GetFeedGeneratorsQuery( - persistentListOf( - AtUri("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"), - AtUri("at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/discover"), - AtUri("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends"), - AtUri("at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/feed-of-feeds"), - ) - )).onSuccess { resp -> - _pinnedFeeds.addAll(resp.feeds.map{ it.toFeedGenerator() }) - _pinnedFeeds.forEach { feedGen -> - val flow = - initFeed(feedGen, initAtCursor(), force = true, start = true).first() - if (flow == null) { log.e { "Failed to initialize feed: ${feedGen.displayName}" } } - } - } - } + val log = logging("MainScreenModel") } - fun updateFeed(uri: AtUri, newCursor: AtCursor = null): Boolean { - val cursor = _cursors[uri] ?: return false - return cursor.tryEmit(newCursor) - } + private var presenterJob: Job? = null - fun updateFeed(feed: FeedGenerator, newCursor: AtCursor = null): Boolean { - return updateFeed(feed.uri, newCursor) + init { + initialize() } - fun updateFeed(entry: ContentCardMapEntry, newCursor: AtCursor = null): Boolean { - return updateFeed(entry.uri, newCursor) - } + protected fun initialize() { + screenModelScope.launch { + while(!isLoggedIn.value) delay(10) - open suspend fun peekLatest(entry: ContentCardMapEntry, update: SharedFlow? = null): StateFlow = flow { - val feed = - _feedStates.firstOrNull { it.value.uri == entry.uri } - ?: _profileFeeds.firstOrNull { it.value.uri == entry.uri } - ?: _profileStates.firstOrNull { it.value.uri == entry.uri } - ?: _threadStates.firstOrNull { it.value.uri == entry.uri } - if(feed == null) { emit(null); return@flow } - if(update == null) dataService.peekLatest(feed.value.feed).onEach { emit(it) } - else dataService.peekLatest(feed.value.feed, update).onEach { emit(it) } - }.stateIn(screenModelScope) + feedSources.addAll(pinnedFeeds.mapNotNull { feed -> feed.toFeedSourceInfo(agent).getOrNull() }) + feedPresenters.putAll(feedSources.map { source -> + source.uri to FeedPresenter(source.feedDescriptor) + }) + feedStates.putAll(feedSources.map { source -> + source.uri to ContentCardState.Skyline(source.uri) + }) - suspend fun loadThread(uri: AtUri): StateFlow? { - val state = _threadStates.firstOrNull { it.value.uri == uri } - if(state != null) return state - val post = - api.api.getPosts(GetPostsQuery(persistentListOf(uri))).map { it.posts.firstOrNull()?.toPost() }.getOrNull() - ?: return null - return loadThread(ContentCardState.PostThread(post, MutableStateFlow(null).asStateFlow(), ContentLoadingState.Loading)) - } - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun loadThread(state: ContentCardState.PostThread): StateFlow = flow { - val r = api.api.getPostThread(GetPostThreadQuery(state.uri, 15, 200)).map { response -> - response.thread.let { thread -> - when (thread) { - is GetPostThreadResponseThreadUnion.BlockedPost -> { - ContentCardState.PostThread( - state.post, - MutableStateFlow(null).asStateFlow(), - ContentLoadingState.Error("Blocked post") - ) - } - - is GetPostThreadResponseThreadUnion.NotFoundPost -> { - ContentCardState.PostThread( - state.post, - MutableStateFlow(null).asStateFlow(), - ContentLoadingState.Error("Post not found") - ) - } - is GetPostThreadResponseThreadUnion.ThreadViewPost -> { - ContentCardState.PostThread( - thread.value.toPost(), - MutableStateFlow(thread.value.toThread()).asStateFlow(), - ContentLoadingState.Idle + presenterJob = screenModelScope.launch { + for(source in feedSources) { + val cardState = feedStates[source.uri]?: continue + val presenter = feedPresenters[source.uri] ?: continue + screenModelScope.launch { + cardState.updates.emitAll( + presenter.produceUpdates(merge(globalEvents, cardState.events)) ) } } - } - } - emit(r.getOrDefault(state.copy(loadingState = ContentLoadingState.Error("Failed to load thread")))) - }.stateIn(screenModelScope) - - private fun indexOf(state: ContentCardState): Int { - return when(state) { - is ContentCardState.FullProfile<*> -> _profileStates.indexOfFirst { it.value.uri == state.uri } - is ContentCardState.PostThread -> _threadStates.indexOfFirst { it.value.uri == state.uri } - is ContentCardState.ProfileTimeline -> _profileFeeds.indexOfFirst { it.value.uri == state.uri } - is ContentCardState.Skyline<*> -> _feedStates.indexOfFirst { it.value.uri == state.uri } - is ContentCardState.UserList -> TODO() - } - } - - suspend fun initFeed( - feed: ContentCardMapEntry.Feed, - force: Boolean = false, - start: Boolean = true, - limit: Long = 100, - ): Flow?> = flow { - val info = getFeedInfo(feed.uri) - if(info == null) { emit(null); return@flow } - val feedService = dataService.dataFlows[feed.uri] - - // Delete the feed if it's already there, initializing from scratch - if(force && feedService != null) dataService.removeFeed(feed.uri) - _cursors[feed.uri] = feed.cursorFlow - if(start) feed.cursorFlow.emit(null) - - val feedState = _feedStates - .firstOrNull { it.value.uri == feed.uri } - val newFeed = dataService - .feed(info, feed.cursorFlow, limit) - .handleToState(MorphoData(info.name, feed.uri, feed.cursorFlow.replayCache.lastOrNull())) - - if (feedState == null) { - _feedStates.add(newFeed) - emit(newFeed.value) - - } else { - val i = _feedStates.indexOf(feedState) - _feedStates[i] = newFeed.filterNotNull().stateIn(screenModelScope) - emit(newFeed.value) - } - } - - suspend fun initFeed( - feed: FeedGenerator, - cursor: MutableSharedFlow, - force: Boolean = false, - start: Boolean = true, - limit: Long = 100, - ): Flow?> = flow { - val feedService = dataService.dataFlows[feed.uri] - val info = FeedInfo(feed.uri, feed.displayName, feed.description, feed.avatar, feed = feed) - // Delete the feed if it's already there, initializing from scratch - if(force && feedService != null) dataService.removeFeed(feed.uri) - _cursors[feed.uri] = cursor - if(start) cursor.emit(null) - - val feedState = _feedStates - .firstOrNull { it.value.uri == feed.uri } - val newFeed = dataService - .feed(info, cursor, limit) - .handleToState(MorphoData(feed.displayName, feed.uri, cursor.replayCache.lastOrNull())) - - if (feedState == null) { - _feedStates.add(newFeed) - emit(newFeed.value) - } else { - val i = _feedStates.indexOf(feedState) - _feedStates[i] = newFeed - emit(newFeed.value) - } - } - - - suspend fun initTimeline( - timeline: ContentCardMapEntry.Home, - force: Boolean = false, - ): Flow?> = flow { - if(timeline.uri != AtUri.HOME_URI) { emit(null); return@flow } - val prefs = if(preferences.prefs.firstOrNull().isNullOrEmpty()) { - log.d { "No preferences found"} - MutableStateFlow(BskyFeedPref()) - } else { - log.d { "Preferences found"} - userPrefs.map { - it?.preferences?.feedViewPrefs?.get("home") ?: BskyFeedPref() - }.stateIn(screenModelScope, SharingStarted.Lazily, BskyFeedPref()) - } - val feedService = dataService.dataFlows[timeline.uri] - log.d { "Timeline service: $feedService"} - // Delete the feed if it's already there, initializing from scratch - if(force && feedService != null) dataService.removeFeed(timeline.uri) - _cursors[timeline.uri] = timeline.cursorFlow - timeline.cursorFlow.emit(null) - val feedState = _feedStates - .firstOrNull { it.value.uri == timeline.uri } - log.d { "Timeline state: $feedState"} - val newFeed = dataService - .timeline(timeline.cursorFlow, 100, prefs) - .handleToState(MorphoData(cursor = timeline.cursorFlow.replayCache.lastOrNull())) - - if (feedState == null) { - _feedStates.add(newFeed) - emit(newFeed.value) - } else { - val i = _feedStates.indexOf(feedState) - _feedStates[i] = newFeed - emit(newFeed.value) - } - } - - suspend fun initTimeline( - cursor: MutableSharedFlow, - force: Boolean = false, - ): Flow?> = flow { - val uri = AtUri.HOME_URI - val prefs = userPrefs.map { - it?.preferences?.feedViewPrefs?.get("home") ?: BskyFeedPref() - }.filterNotNull().stateIn(screenModelScope) - val feedService = dataService.dataFlows[uri] + } + initialized = true - // Delete the feed if it's already there, initializing from scratch - if(force && feedService != null) dataService.removeFeed(uri) - _cursors[uri] = cursor - cursor.emit(null) - val feedState = _feedStates - .firstOrNull { it.value.uri == uri } - val newFeed = dataService - .timeline(cursor, 100, prefs) - .handleToState(MorphoData(cursor = cursor.replayCache.lastOrNull())) - if (feedState == null) { - _feedStates.add(newFeed) - emit(newFeed.value) - } else { - val i = _feedStates.indexOf(feedState) - _feedStates[i] = newFeed - emit(newFeed.value) } } - suspend fun initProfileTabContent( - feed: ContentCardMapEntry, - force: Boolean = false, - limit: Long = 100, - ): Flow?> = flow { - // Has to be a profile feed - if(!feed.uri.isProfileFeed) { emit(null); return@flow } - val id = feed.uri.id(api) - val feedService = dataService.dataFlows[feed.uri] - // Delete the feed if it's already there, initializing from scratch - if(force && feedService != null) dataService.removeFeed(feed.uri) - _cursors[feed.uri] = feed.cursorFlow - - val feedState = - if(_profileStates.firstOrNull { it.value.profile.did == id } != null) { - _profileStates.firstOrNull { it.value.profile.did == id } - } else _profileFeeds.firstOrNull { it.value.uri == feed.uri } - val profile = if(_profileStates.firstOrNull{ it.value.profile.did == id } != null) { - _profileStates.firstOrNull{ it.value.profile.did == id }?.value?.profile - } else api.api.getProfile(GetProfileQuery(id)).getOrNull()?.toProfile() - if (profile == null) { emit(null); return@flow } - val newFeed = dataService - .profileTabContent(id, feed.feedType, feed.cursorFlow, limit) - .handleToState(profile, MorphoData(feed.title, feed.uri, feed.cursorFlow.replayCache.lastOrNull())) - if (feedState == null) { - _profileFeeds.add(newFeed) - emit(_profileFeeds.last().value) - } else { - val i = _profileFeeds.indexOf(feedState) - _profileFeeds[i] = newFeed - emit(_profileFeeds[i].value) - } - feed.cursorFlow.emit(null) + override fun deinit() { + super.deinit() + presenterJob?.cancel() + feedSources.clear() + feedStates.clear() + initialized = false } - suspend fun initProfileContent( - profile: ContentCardMapEntry.Profile, - force: Boolean = false, - fill: Boolean = false, - ): Flow?> = flow { - val feedService = dataService.dataFlows[profile.uri] - // Delete the feed if it's already there, initializing from scratch - if(force && feedService != null) dataService.removeFeed(profile.uri) - val feedState = _profileStates.firstOrNull { it.value.profile.did == profile.uri.id(api) } - val p = if(_profileStates.firstOrNull{ it.value.profile.did == profile.id } != null) { - _profileStates.firstOrNull{ it.value.profile.did == profile.id }?.value?.profile - } else api.api.getProfile(GetProfileQuery(profile.id)).getOrNull()?.toProfile() - if (p == null) { emit(null); return@flow } - val newProfile = if(fill) { - - val postsCursor: MutableSharedFlow = initAtCursor() - _cursors[AtUri.profilePostsUri(p.did)] = postsCursor - val posts = dataService - .authorFeed(p.did, FeedType.PROFILE_POSTS, postsCursor.asSharedFlow(), 50) - .handleToState(p, MorphoData("Posts", AtUri.profilePostsUri(p.did), postsCursor.replayCache.lastOrNull())) - - val repliesCursor: MutableSharedFlow = initAtCursor() - _cursors[AtUri.profileRepliesUri(p.did)] = repliesCursor - val replies = dataService - .authorFeed(p.did, FeedType.PROFILE_REPLIES, repliesCursor.asSharedFlow(), 50) - .handleToState(p, MorphoData("Replies", AtUri.profileRepliesUri(p.did), repliesCursor.replayCache.lastOrNull())) - - val mediaCursor: MutableSharedFlow = initAtCursor() - _cursors[AtUri.profileMediaUri(p.did)] = mediaCursor - val media = dataService - .authorFeed(p.did, FeedType.PROFILE_MEDIA, mediaCursor.asSharedFlow(), 50) - .handleToState(p, MorphoData("Media", AtUri.profileMediaUri(p.did), mediaCursor.replayCache.lastOrNull())) - - val likesCursor: MutableSharedFlow = initAtCursor() - _cursors[AtUri.profileLikesUri(p.did)] = likesCursor - val likes = dataService - .profileLikes(p.did, likesCursor.asSharedFlow(), 50) - .handleToState(p, MorphoData("Likes", AtUri.profileLikesUri(p.did), likesCursor.replayCache.lastOrNull())) + override fun logout() { + deinit() + super.logout() - val listsCursor: MutableSharedFlow = initAtCursor() - _cursors[AtUri.profileUserListsUri(p.did)] = listsCursor - val lists = dataService - .profileLists(p.did, listsCursor.asSharedFlow(), 50) - .handleToState(p, MorphoData("Lists", AtUri.profileUserListsUri(p.did), listsCursor.replayCache.lastOrNull())) - - val feedsCursor: MutableSharedFlow = initAtCursor() - _cursors[AtUri.profileFeedsListUri(p.did)] = feedsCursor - val feeds = dataService - .profileFeedsList(p.did, feedsCursor.asSharedFlow(), 50) - .handleToState(p, MorphoData("Feeds", AtUri.profileFeedsListUri(p.did), feedsCursor.replayCache.lastOrNull())) - - if (p is BskyLabelService) { - val servicesCursor: MutableSharedFlow = initAtCursor() - _cursors[AtUri.profileModServiceUri(p.did)] = servicesCursor - val services = dataService - .profileServiceView(p.did, servicesCursor.map { Unit } - .shareIn(screenModelScope, SharingStarted.Lazily) - ).handleToState(p, MorphoData("Labels", AtUri.profileModServiceUri(p.did), servicesCursor.replayCache.lastOrNull())) - servicesCursor.emit(null) - ContentCardState.FullProfile( - p, - posts.stateIn(screenModelScope), - replies.stateIn(screenModelScope), - media.stateIn(screenModelScope), - likes.stateIn(screenModelScope), - lists.stateIn(screenModelScope), - feeds.stateIn(screenModelScope), - services.stateIn(screenModelScope), - ContentLoadingState.Idle - ) - } else { - postsCursor.emit(null) - ContentCardState.FullProfile( - p, - posts.stateIn(screenModelScope), - replies.stateIn(screenModelScope), - media.stateIn(screenModelScope), - likes.stateIn(screenModelScope), - lists.stateIn(screenModelScope), - feeds.stateIn(screenModelScope), - loadingState = ContentLoadingState.Idle - ) - } - - } else { - ContentCardState.FullProfile(p, loadingState = ContentLoadingState.Loading) - } - if (feedState == null) { - _profileStates.add(MutableStateFlow(newProfile).asStateFlow() as StateFlow>) - emit(_profileStates.last().value) - } else { - val i = _profileStates.indexOf(feedState) - _profileStates[i] = MutableStateFlow(newProfile).asStateFlow() as StateFlow> - emit(_profileStates[i].value) - } + initialize() } - fun unloadContent(state: ContentCardState): MorphoData? { - - when(state) { - is ContentCardState.Skyline<*> -> { - _feedStates.removeAll { it.value.uri == state.uri } - } - is ContentCardState.PostThread -> { - _threadStates.removeAll { it.value.uri == state.uri } - } - is ContentCardState.FullProfile<*> -> { - _profileStates.removeAll { it.value.uri == state.uri } - unloadContent(state.postsState.value as ContentCardState) - unloadContent(state.postRepliesState.value as ContentCardState) - unloadContent(state.mediaState.value as ContentCardState) - unloadContent(state.likesState.value as ContentCardState) - } - is ContentCardState.ProfileTimeline -> { - _profileFeeds.removeAll { it.value.uri == state.uri } - } - - is ContentCardState.UserList -> TODO() - } - return dataService.removeFeed(state.uri) as MorphoData? - } + override fun switchUser(did: Did) { + deinit() + super.switchUser(did) - protected open fun unloadContent(entry: ContentCardMapEntry): MorphoData? { - return unloadContent(entry.uri) + initialize() } - protected fun unloadContent(uri: AtUri): MorphoData? { - val state = _feedStates.firstOrNull { it.value.uri == uri } - ?: _threadStates.firstOrNull { it.value.uri == uri } - ?: _profileStates.firstOrNull { it.value.uri == uri } - ?: _profileFeeds.firstOrNull { it.value.uri == uri } - ?: return null - return unloadContent(state.value) as MorphoData? - } } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/tabbed/TabbedHomeView.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/tabbed/TabbedHomeView.kt index cbe6466..5c49146 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/tabbed/TabbedHomeView.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/tabbed/TabbedHomeView.kt @@ -1,5 +1,7 @@ package com.morpho.app.screens.main.tabbed + + import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.Spring @@ -8,131 +10,210 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset -import androidx.compose.runtime.* +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.lifecycle.LifecycleEffect +import app.cash.paging.compose.collectAsLazyPagingItems +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.core.stack.StackEvent -import cafe.adriel.voyager.koin.getNavigatorScreenModel +import cafe.adriel.voyager.koin.koinNavigatorScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.TabOptions import cafe.adriel.voyager.transitions.ScreenTransition import cafe.adriel.voyager.transitions.ScreenTransitionContent import coil3.annotation.ExperimentalCoilApi -import com.morpho.app.model.bluesky.MorphoDataItem +import com.morpho.app.data.ContentLabelService +import com.morpho.app.data.MorphoAgent +import com.morpho.app.model.uidata.Event import com.morpho.app.model.uistate.ContentCardState -import com.morpho.app.model.uistate.UiLoadingState +import com.morpho.app.model.uistate.ScrollPosition import com.morpho.app.screens.base.tabbed.TabScreen import com.morpho.app.ui.common.LoadingCircle import com.morpho.app.ui.common.TabbedScreenScaffold import com.morpho.app.ui.common.TabbedSkylineFragment import com.morpho.app.ui.elements.AvatarShape import com.morpho.app.ui.elements.OutlinedAvatar -import kotlinx.coroutines.flow.StateFlow +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import morpho.composeapp.generated.resources.BlueSkyKawaii +import morpho.composeapp.generated.resources.Res import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource +import org.koin.compose.koinInject import kotlin.math.max import kotlin.math.min import cafe.adriel.voyager.navigator.tab.Tab as NavTab -@Suppress("UNCHECKED_CAST") -@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) -@Composable -fun TabScreen.TabbedHomeView() { +@Composable +public fun CurrentSkylineScreen( + sm: TabbedMainScreenModel, + paddingValues: PaddingValues, + state: ContentCardState?, + modifier: Modifier +) { val navigator = LocalNavigator.currentOrThrow - val sm = navigator.getNavigatorScreenModel() + val currentScreen = navigator.lastItem as SkylineTab - var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } - var insets = WindowInsets.navigationBars.asPaddingValues() + navigator.saveableState("currentScreen") { + currentScreen.Content( + sm = sm, + paddingValues = paddingValues, + state = state, + modifier = modifier + ) + } +} - LifecycleEffect( - onStarted = { - sm.initTabs() - }, - onDisposed = {}, + +abstract class SkylineTab: NavTab { + + @Composable + abstract fun Content( + sm: TabbedMainScreenModel, + paddingValues: PaddingValues, + state: ContentCardState?, + modifier: Modifier ) - val tabs = rememberSaveable( - sm.tabFlow.value, sm.uiState.loadingState, sm.uiState.tabs.value.size - ) { - List(sm.uiState.tabs.value.size) { index -> - HomeSkylineTab( - index = index.toUShort(), - screenModel = sm, - state = sm.uiState.tabStates[index] - as StateFlow>, - paddingValues = insets, - icon = { - if(sm.uiState.tabs.value[index].avatar != null) { - OutlinedAvatar( - url = sm.uiState.tabs.value[index].avatar!!, - size = 20.dp, - avatarShape = AvatarShape.Rounded, - modifier = Modifier.padding(end = 8.dp), - ) - } - } - ) - } - } - val tabsCreated = remember(tabs.size, sm.uiState.loadingState) { - tabs.isNotEmpty() && sm.uiState.loadingState == UiLoadingState.Idle - } - if (tabsCreated) { - Navigator(tabs.first()) { nav -> - TabbedScreenScaffold( - navBar = { navBar(navigator) }, - topContent = { - HomeTabRow( - tabs = tabs, - tabIndex = selectedTabIndex, - onChanged = { index -> - if (index == selectedTabIndex) return@HomeTabRow - if(index < selectedTabIndex) { - if (nav.items.contains(tabs[index])) { - nav.popUntil {it == tabs[index] } - } else nav.replace(tabs[index]) - } else if(index > selectedTabIndex) nav.push(tabs[index]) - selectedTabIndex = index - } - ) - }, - content = { - insets = it + @OptIn(ExperimentalVoyagerApi::class) + @Composable + final override fun Content() = Content( + TabbedMainScreenModel(koinInject(), koinInject()), + PaddingValues(0.dp), null, Modifier) +} - SlideTabTransition(nav) - } - ) + +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class, + ExperimentalVoyagerApi::class +) +@Composable +fun TabScreen.TabbedHomeView( + navigator: Navigator = LocalNavigator.currentOrThrow, + sm: TabbedMainScreenModel = navigator.koinNavigatorScreenModel() +) { + //ProvideNavigatorLifecycleKMPSupport { + + var selectedTabIndex by rememberSaveable { mutableIntStateOf(sm.timelineIndex) } + + val tabs by derivedStateOf { + sm.tabs.mapIndexed { index, tab -> + HomeSkylineTab( + index = index.toUShort(), title = tab.title, avatar = tab.avatar + ) + } } - } else LoadingCircle() + val tabsCreated by derivedStateOf { sm.loaded && sm.tabs.isNotEmpty() && sm.isLoggedIn.value } + if (tabsCreated) { + Navigator( + tabs.first(), + key = "homeFeedsNavigator", + disposeBehavior = NavigatorDisposeBehavior( + //disposeNestedNavigators = false, + ) + ) { nav -> + val tabUri = sm.uriForTab(selectedTabIndex) + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + TabbedScreenScaffold( + navBar = { navBar(navigator) }, + content = { insets, state -> + + SkylineTabTransition(nav, sm, insets, state) + }, + topContent = { + HomeTabRow( + tabs = tabs, + modifier = Modifier.statusBarsPadding(), + tabIndex = selectedTabIndex, + onChanged = { index -> + if (index == selectedTabIndex) return@HomeTabRow + + if(index < selectedTabIndex) { + if (nav.items.contains(tabs[index])) { + nav.popUntil {it == tabs[index] } + } else nav.replace(tabs[index]) + } else if(index > selectedTabIndex) nav.push(tabs[index]) + selectedTabIndex = index + + }, + drawerState = drawerState, + kawaiiMode = sm.kawaiiMode, + ) + }, + state = sm.feedStates[tabUri] as ContentCardState.Skyline?, + modifier = Modifier, + drawerState = drawerState, + profile = sm.userProfile, + ) + } + + } else LoadingCircle() + // } } +@OptIn(ExperimentalVoyagerApi::class) @Composable -fun SlideTabTransition( +fun SkylineTabTransition( navigator: Navigator, + sm: TabbedMainScreenModel, + insets: PaddingValues = PaddingValues(0.dp), + state: ContentCardState?, modifier: Modifier = Modifier, animationSpec: FiniteAnimationSpec = spring( stiffness = Spring.StiffnessMediumLow, visibilityThreshold = IntOffset.VisibilityThreshold ), - content: ScreenTransitionContent = { it.Content() } + content: ScreenTransitionContent = { + CurrentSkylineScreen(sm, insets, state, modifier) + } ) { - ScreenTransition( navigator = navigator, modifier = modifier, content = content, + disposeScreenAfterTransitionEnd = true, transition = { val (initialOffset, targetOffset) = when (navigator.lastEvent) { StackEvent.Pop -> ({ size: Int -> -size }) to ({ size: Int -> size }) @@ -154,91 +235,140 @@ fun HomeTabRow( modifier: Modifier = Modifier, tabIndex: Int = 0, onChanged: (Int) -> Unit = {}, + drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + kawaiiMode: Boolean = false, ) { var selectedTabIndex by rememberSaveable { mutableIntStateOf(tabIndex) } + val scope = rememberCoroutineScope() - SecondaryScrollableTabRow( - selectedTabIndex = selectedTabIndex, - modifier = modifier.fillMaxWidth(),//.zIndex(1f), - edgePadding = 10.dp, - indicator = { tabPositions -> - if(tabPositions.isNotEmpty()) { - TabRowDefaults.SecondaryIndicator( - Modifier.tabIndicatorOffset(tabPositions[max(0, min(selectedTabIndex, tabs.lastIndex))]) - ) - } - } - ) { - tabs.forEachIndexed { index, tab -> - Tab( - selected = selectedTabIndex == index, + TopAppBar( + modifier = Modifier.fillMaxWidth(), + navigationIcon = { + IconButton( onClick = { - selectedTabIndex = max(0, min(index, tabs.lastIndex)) - onChanged(max(0, min(index, tabs.lastIndex))) + if(drawerState.isClosed) scope.launch { drawerState.open() } + else scope.launch { drawerState.close() } }, - //icon = { tab.icon() }, - text = { - Row( - verticalAlignment = Alignment.CenterVertically, - ){ - tab.icon() - Text( - text = tab.state.value.feed.title, - //style = MaterialTheme.typography.titleSmall, - ) - } } - ) - } - } + modifier = if(kawaiiMode) Modifier.size(90.dp) else Modifier + ) { + if(kawaiiMode) { + Image( + painterResource(Res.drawable.BlueSkyKawaii), + contentDescription = "open navigation drawer (but kawaii)", + ) + } else { + Icon(Icons.Default.Menu, contentDescription = "open navigation drawer") + } + } + }, + title = { + SecondaryScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 10.dp, +// indicator = { tabPositions -> +// if(tabPositions.isNotEmpty()) { +// TabRowDefaults.SecondaryIndicator( +// Modifier.tabIndicatorOffset(tabPositions[max(0, min(selectedTabIndex, tabs.lastIndex))]) +// ) +// } +// }, + divider = {}, + //modifier = Modifier.offset(y = 8.dp), + ) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = selectedTabIndex == index, + onClick = { + selectedTabIndex = max(0, min(index, tabs.lastIndex)) + onChanged(max(0, min(index, tabs.lastIndex))) + }, + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 12.dp) + ){ + if(tab.avatar != null) { + OutlinedAvatar( + url = tab.avatar, + size = 20.dp, + avatarShape = AvatarShape.Rounded, + modifier = Modifier.padding(end = 8.dp), + ) + } + Text( + text = tab.title, + ) + } } + ) + } + } + }, + ) } +@Parcelize @Serializable -data class HomeSkylineTab( +data class HomeSkylineTab @OptIn(ExperimentalVoyagerApi::class) constructor( val index: UShort, - val screenModel: TabbedMainScreenModel, - val state: StateFlow>, - val paddingValues: PaddingValues = PaddingValues(0.dp), - val icon: @Composable () -> Unit = {}, -): NavTab { - @OptIn(ExperimentalMaterial3Api::class) + val title: String, + val avatar: String? = null, +): SkylineTab(), Parcelable { + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) @Composable - override fun Content() { - TabbedSkylineFragment( - screenModel, state, paddingValues, - refresh = { cursor -> - screenModel.refreshTab(index.toInt(), cursor) - }, + override fun Content( + sm: TabbedMainScreenModel, + paddingValues: PaddingValues, + state: ContentCardState?, + modifier: Modifier + ) { + if(state == null) return + val data = sm.tabPagers[state.uri]?.collectAsLazyPagingItems() + val listState = rememberLazyListState( + state.scrollPosition.value.index, + state.scrollPosition.value.scrollOffset ) + + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { + state.scrollPosition.value = ScrollPosition( + index = listState.firstVisibleItemIndex, + scrollOffset = listState.firstVisibleItemScrollOffset + ) + } + } + + + if(data != null) { + TabbedSkylineFragment( + paddingValues = paddingValues, + isProfileFeed = false, + uiUpdate = state.updates, + eventCallback = { sm.sendGlobalEvent(it) }, + getContentHandling = { post -> sm.labelService.getContentHandlingForPost(post).map { it.first } }, + pager = data, + listState = listState, + agent = sm.agent, + ) + } else { + LoadingCircle() + } + } - override val key: ScreenKey = "${state.value.uri.atUri}${hashCode()}" + override val key: ScreenKey + get() = "${title}$uniqueScreenKey" @OptIn(ExperimentalResourceApi::class, ExperimentalCoilApi::class) override val options: TabOptions @Composable get() { - /*val (avatar, icon) = screenModel - .getFeedInfo(screenModel.uriForTab(index = index.toInt())) - ?.let { feedInfo -> - feedInfo.avatar to feedInfo.icon - } ?: (null to null) - val tabIcon = if(avatar != null) rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(avatar) - .crossfade(true) - .build(), - ) else if(icon != null) rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(icon) - .crossfade(true) - .build(), - ) - else null*/ - return TabOptions( index = index, - title = state.value.feed.title, + title = title, //icon = tabIcon, ) } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/tabbed/TabbedMainScreenModel.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/tabbed/TabbedMainScreenModel.kt index de2feb4..860ed0f 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/tabbed/TabbedMainScreenModel.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/main/tabbed/TabbedMainScreenModel.kt @@ -1,204 +1,113 @@ package com.morpho.app.screens.main.tabbed import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.util.fastForEach -import app.bsky.feed.GetFeedGeneratorsQuery +import app.bsky.feed.GetPostThreadResponseThreadUnion +import app.cash.paging.PagingData +import app.cash.paging.cachedIn +import app.cash.paging.compose.LazyPagingItems import cafe.adriel.voyager.core.model.screenModelScope -import com.morpho.app.model.bluesky.FeedGenerator +import com.morpho.app.data.ContentLabelService +import com.morpho.app.data.MorphoAgent import com.morpho.app.model.bluesky.MorphoDataItem -import com.morpho.app.model.bluesky.Profile -import com.morpho.app.model.bluesky.toFeedGenerator -import com.morpho.app.model.uidata.AtCursor +import com.morpho.app.model.bluesky.toContentCardMapEntry +import com.morpho.app.model.bluesky.toThread import com.morpho.app.model.uidata.ContentCardMapEntry -import com.morpho.app.model.uidata.MorphoData +import com.morpho.app.model.uidata.FeedPresenter +import com.morpho.app.model.uidata.ThreadUpdate import com.morpho.app.model.uistate.ContentCardState -import com.morpho.app.model.uistate.TabbedScreenState -import com.morpho.app.model.uistate.UiLoadingState import com.morpho.app.screens.main.MainScreenModel import com.morpho.butterfly.AtUri -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.* +import com.morpho.butterfly.Did +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable +import org.koin.core.KoinApplication.Companion.init import org.lighthousegames.logging.logging -@Suppress("UNCHECKED_CAST") -@Serializable -class TabbedMainScreenModel : MainScreenModel() { +class TabbedMainScreenModel( + agent: MorphoAgent, labelService: ContentLabelService, +) : MainScreenModel(agent, labelService) { - var uiState: TabbedScreenState by mutableStateOf(TabbedScreenState(loadingState = UiLoadingState.Loading)) - private set + private val _tabs = mutableListOf() + val tabs: List + get() = _tabs.toList() + val tabPagers = mutableStateMapOf>>() + + val timelineIndex: Int + get() = agent.prefs.timelineIndex ?: 0 + + var loaded by mutableStateOf(false) - private val tabs = mutableListOf() - val _tabFlow = MutableStateFlow(tabs.toList()) - val tabFlow: StateFlow> - get() = _tabFlow.asStateFlow() companion object { - val log = logging() + val log = logging("TabbedMainScreenModel") } - fun uriForTab(index: Int): AtUri { - return tabs[index].uri + init { + initializeTabs() } - fun initTabs() = screenModelScope.launch { - if (initialized) return@launch - init(false) - initialized = true - val home = initHomeTab() - val savedFeedsPref = userPrefs.value?.preferences?.savedFeeds - tabs.clear() - val newFeeds = mutableListOf>>() - if(home.isSuccess) { - val homeState = _feedStates.firstOrNull { - it.value.uri == home.getOrThrow().first.uri - } - if (homeState != null && home.getOrNull()?.second != null) { - tabs.add(home.getOrThrow().first) - _tabFlow.value = tabs.toImmutableList() - newFeeds.add(homeState as StateFlow>) - //uiState = uiState.copy(loadingState = UiLoadingState.Idle, tabs = tabFlow, tabStates = newFeeds.toImmutableList()) - } else { - log.e { "Failed to initialize home tab state" } - log.d { - "Home tab: ${home.getOrNull()?.first}\n" + - "Home state: ${homeState?.value}" - } - } - } - if (savedFeedsPref != null) { - log.d { "Pinned feeds: ${savedFeedsPref.pinned}" } - api.api.getFeedGenerators(GetFeedGeneratorsQuery(savedFeedsPref.pinned)) - .map { resp -> - _pinnedFeeds.addAll(resp.feeds.map { it.toFeedGenerator() }) - _pinnedFeeds.associateBy { _pinnedFeeds.indexOf(it) }.mapValues { feedGen -> - initFeedTab(feedGen.value) - } - }.getOrNull()?.forEach { (index, pair) -> - val feed = pair.getOrNull() - if (feed != null) { - feedStates.firstOrNull { - it.value.uri == feed.first.uri - }?.let { state -> - tabs.add(feed.first) - newFeeds.add(state as StateFlow>) - } - } else { - log.e { "Failed to initialize feed tab at index $index" } - } - } - } else if(false) { // Temporarily disabled - // Init some default feeds - api.api.getFeedGenerators(GetFeedGeneratorsQuery( - persistentListOf( - AtUri("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"), - AtUri("at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/discover"), - AtUri("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends"), - AtUri("at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/feed-of-feeds"), - ) - )).onSuccess { resp -> - _pinnedFeeds.addAll(resp.feeds.map{ it.toFeedGenerator() }) - _pinnedFeeds.associateBy { _pinnedFeeds.indexOf(it) }.mapValues { feedGen -> - val result = initFeedTab(feedGen.value) - if (result.isFailure) { MainScreenModel.log.e { "Failed to initialize feed: ${feedGen.value.displayName}" } } - else { - feedStates.firstOrNull { - it.value.uri == result.getOrNull()?.first?.uri - }?.let { state -> - tabs.add(result.getOrNull()?.first!!) - newFeeds.add(state as StateFlow>) - } - } + private fun initializeTabs() { + screenModelScope.launch { + while(!initialized) delay(10) + feedSources.filter { it.pinned == true }.forEach { info -> + _tabs.add(info.toContentCardMapEntry()) + (feedPresenters[info.uri]?.pager?.flow?.cachedIn(screenModelScope) + as Flow>).let { + tabPagers[info.uri] = it } } - } else { - log.d { "Saved Feeds: $savedFeedsPref" } - log.d { - "Prefs ${preferences.prefs.firstOrNull()}" - } - } - _tabFlow.value = tabs.toImmutableList() - uiState = uiState.copy(loadingState = UiLoadingState.Idle, tabs = tabFlow, tabStates = newFeeds.toImmutableList()) - uiState.tabStates.fastForEach { + loaded = true } } - fun refreshTab(index: Int, cursor: AtCursor = null) :Boolean { - return if(index < 0 || index > tabs.lastIndex) false - else updateFeed(tabs[index], cursor) + fun uriForTab(index: Int): AtUri { + return tabs[index].uri } - - suspend fun initHomeTab(): - Result>> { - val home = ContentCardMapEntry.Home - _cursors[home.uri] = home.cursorFlow - val f = initTimeline(home, force = false).first() - - return if(f != null) { - Result.success(Pair(home, f)) - } else { - val ul = unloadContent(home) - log.e { "Failed to initialize home tab" } - log.v { "Deleted Feed: ${ul?.items}" } - Result.failure(Exception("Failed to initialize home tab")) + fun getThread(uri: AtUri): Flow = flow { + val post = getPost(uri).getOrNull() + if(post != null) { + val state = ContentCardState.PostThread(post) + val thread = when(val thread = agent.getPostThread(uri,).getOrNull()?.thread) { + is GetPostThreadResponseThreadUnion.ThreadViewPost -> thread.value.toThread() + else -> null + } + if(thread != null) state.updates.emit(ThreadUpdate.Thread(thread)) + emit(state) } - } - suspend fun initFeedTab( - feed:FeedGenerator - ): Result>> { - val title = feed.displayName - val tab = ContentCardMapEntry.Feed(feed.uri, title, avatar = feed.avatar) - _cursors[tab.uri] = tab.cursorFlow - val f = initFeed(feed, tab.cursorFlow, force = true, start = false, limit = 50).firstOrNull() - return if(f != null) { - Result.success(Pair(tab, f)) - } else { - val ul = unloadContent(tab) - log.e { "Failed to initialize feed tab $title" } - log.v { "Deleted Feed: ${ul?.items}" } - Result.failure(Exception("Failed to initialize feed tab $title")) - } } - suspend fun initProfileTab(profile: Profile): Result>> { - val title = profile.displayName ?: profile.handle.handle - val tab = ContentCardMapEntry.Profile(profile.did, AtUri.profileUri(profile.did), title) - _cursors[tab.uri] = tab.cursorFlow - val f = initProfileContent(tab, force = true, fill = true).first() - return if(f != null) { - Result.success(Pair(tab, f)) - } else { - val ul = unloadContent(tab) - log.e { "Failed to initialize profile tab $title" } - log.v { "Deleted Feed: ${ul?.items}" } - Result.failure(Exception("Failed to initialize profile tab $title")) - } + override fun deinit() { + super.deinit() + tabPagers.clear() + _tabs.clear() + loaded = false } - override fun unloadContent(entry: ContentCardMapEntry): MorphoData? { - val maybeTab = uiState.tabMap[entry.uri] - return if(maybeTab == null) { - history.popUntil { it == entry } - unloadContent(entry.uri) - } else { - unloadContent(maybeTab) - } + + override fun logout() { + deinit() + super.logout() + initialize() + initializeTabs() } - fun unloadTab(index: Int): MorphoData? { - if(index < 0 || index > tabs.lastIndex) return null - val uri = tabs[index].uri - val state = uiState.tabMap[uri] ?: return null - return unloadContent(state) + override fun switchUser(did: Did) { + deinit() + super.switchUser(did) + initialize() + initializeTabs() } + } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/notifications/NotificationsView.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/notifications/NotificationsView.kt index b70f759..636b542 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/notifications/NotificationsView.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/notifications/NotificationsView.kt @@ -1,6 +1,8 @@ package com.morpho.app.screens.notifications +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.rememberLazyListState @@ -11,98 +13,109 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDrawerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.Dp import androidx.constraintlayout.compose.ConstraintLayout +import app.cash.paging.LoadStateError +import app.cash.paging.LoadStateLoading +import app.cash.paging.compose.collectAsLazyPagingItems +import app.cash.paging.compose.itemKey +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.core.model.screenModelScope -import cafe.adriel.voyager.koin.getNavigatorScreenModel +import cafe.adriel.voyager.koin.koinNavigatorScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.currentOrThrow import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.DraftPost import com.morpho.app.model.bluesky.NotificationsListItem -import com.morpho.app.model.uidata.getPost +import com.morpho.app.model.uistate.NotificationsUIState import com.morpho.app.screens.base.tabbed.ProfileTab import com.morpho.app.screens.base.tabbed.TabScreen -import com.morpho.app.screens.base.tabbed.ThreadTab +import com.morpho.app.screens.main.tabbed.TabbedMainScreenModel import com.morpho.app.ui.common.BottomSheetPostComposer import com.morpho.app.ui.common.ComposerRole +import com.morpho.app.ui.common.LoadingCircle import com.morpho.app.ui.common.TabbedScreenScaffold import com.morpho.app.ui.elements.WrappedLazyColumn import com.morpho.app.ui.elements.doMenuOperation import com.morpho.app.ui.notifications.NotificationsElement import com.morpho.app.ui.notifications.NotificationsFilterElement +import com.morpho.app.ui.post.PlaceholderSkylineItem import com.morpho.app.util.ClipboardManager -import com.morpho.butterfly.model.RecordUnion -import kotlinx.collections.immutable.persistentListOf +import com.morpho.butterfly.AtUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch import org.koin.compose.getKoin -@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class, + ExperimentalVoyagerApi::class +) @Composable fun TabScreen.NotificationViewContent( navigator: Navigator = LocalNavigator.currentOrThrow, - sm: TabbedNotificationScreenModel = navigator.getNavigatorScreenModel() + ) { - val numberUnread by sm.uiState.value.numberUnread.collectAsState(0) + val sm = navigator.koinNavigatorScreenModel() var showSettings by remember { mutableStateOf(false) } - val hasUnread = remember(numberUnread) { numberUnread > 0 } + val hasUnread by sm.hasUnreadNotifications().collectAsState(initial = false) val listState = rememberLazyListState() val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + val pager = sm.notifications.collectAsLazyPagingItems() + var uiState by rememberSaveable { mutableStateOf(NotificationsUIState()) } + val toMarkRead = mutableStateListOf() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) TabbedScreenScaffold( navBar = { navBar(navigator) }, - topContent = { - NotificationsTopBar( - navigator = navigator, - onSettingsClicked = { - showSettings = it - scope.launch { - listState.animateScrollToItem(0) - } - }, - showSettings = showSettings, - hasUnread = hasUnread, - markAsRead = { sm.markAllRead() } - ) - }, - content = { insets -> + drawerState = drawerState, + profile = sm.userProfile, + content = { insets, state -> val refreshing by remember { mutableStateOf(false)} val refreshState = rememberPullRefreshState( refreshing, - { - sm.notifService.updateNotificationsSeen() - sm.refreshNotifications(null) - } - ) - val notifications by sm.uiState.value.notifications.collectAsState(persistentListOf()) + { sm.updateSeenNotifications() + pager.refresh() }) var repostClicked by remember { mutableStateOf(false)} var initialContent: BskyPost? by remember { mutableStateOf(null) } var showComposer by remember { mutableStateOf(false)} var composerRole by remember { mutableStateOf(ComposerRole.StandalonePost)} - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + // Probably pull this farther up, // but this means if you don't explicitly cancel you don't lose the post var draft by remember{ mutableStateOf(DraftPost()) } val clipboardManager = getKoin().get() - val cursor by rememberUpdatedState(sm.uiState.value.cursor) - LaunchedEffect( - notifications.isNotEmpty() && - !listState.canScrollForward && - !refreshing - ) { - sm.refreshNotifications(cursor) - } ConstraintLayout( @@ -130,67 +143,91 @@ fun TabScreen.NotificationViewContent( Column { HorizontalDivider(Modifier.fillMaxWidth(),thickness = Dp.Hairline) NotificationsFilterElement( - sm.uiState.value.filterState, + uiState.filterState, onFilterClicked = { - sm.notifService.updateFilter(it).invokeOnCompletion { - // forcing a refresh should reload the list with new filters - sm.refreshNotifications(cursor) - } + uiState.filterState.value = it + pager.refresh() } ) HorizontalDivider(Modifier.fillMaxWidth(),thickness = Dp.Hairline) } } } - items( - count = notifications.size, - key = { index -> notifications[index].hashCode() }, - contentType = { - NotificationsListItem + val refreshLoadState = pager.loadState.refresh + val appendLoadState = pager.loadState.append + + when { + refreshLoadState is LoadStateError || appendLoadState is LoadStateError -> { + item { Text("$refreshLoadState\n$appendLoadState") } + item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + TextButton(onClick = { pager.retry() }) { + Text("Retry") + } } } } - ) { index -> - NotificationsElement( - item = notifications[index], - showPost = sm.uiState.value.showPosts, - getPost = { getPost(it, sm.api)}, - onUnClicked = { type, rkey -> - sm.api.deleteRecord(type, rkey) - }, - onAvatarClicked = { - navigator.push(ProfileTab(it)) - }, - onRepostClicked = { - initialContent = it - repostClicked = true - }, - onReplyClicked = { - initialContent = it - composerRole = ComposerRole.Reply - showComposer = true - }, - onMenuClicked = { option, post -> doMenuOperation(option, post, clipboardManager = clipboardManager ) }, - onLikeClicked = { - sm.api.createRecord(RecordUnion.Like(it)) - }, - onPostClicked = { - navigator.push(ThreadTab(it)) - }, - // If someone hides their read notifications, - // we don't want to just mark them as read unprompted. - // Might cause them to disappear unexpectedly. - readOnLoad = !sm.uiState.value.filterState.value.showAlreadyRead, - markRead = { sm.markAsRead(it) } - ) - } - item { - TextButton( - onClick = { - sm.refreshNotifications(cursor) + refreshLoadState is LoadStateLoading -> { item { LoadingCircle() } } + else -> { + + + items( + count = pager.itemCount, + key = pager.itemKey { + it.hashCode() + }, + contentType = { + NotificationsListItem + } + ) { index -> + if (state != null) { + when(val item = pager[index]) { + is NotificationsListItem -> { + NotificationsElement( + item = item, + showPost = state.showPosts, + getPost = { sm.getPost(it).getOrNull() }, + onUnClicked = { type, rkey -> + sm.agent.deleteRecord(type, rkey) + }, + onAvatarClicked = { + navigator.push(ProfileTab(it)) + }, + onRepostClicked = { + initialContent = it + repostClicked = true + }, + onReplyClicked = { + initialContent = it + composerRole = ComposerRole.Reply + showComposer = true + }, + onMenuClicked = { option, post -> + doMenuOperation( + option, post, + clipboardManager = clipboardManager, + uriHandler = uriHandler + ) + }, + onLikeClicked = { sm.agent.like(it) }, + // If someone hides their read notifications, + // we don't want to just mark them as read unprompted. + // Might cause them to disappear unexpectedly. + readOnLoad = !state.filterState.value.showAlreadyRead, + markRead = { toMarkRead.add(it) }, + resolveHandle = { handle -> + sm.agent.resolveHandle( + handle + ).getOrNull() + } + ) + } + null -> { + PlaceholderSkylineItem() + } + } + + } + } - ) { - Text("Load More") } - } } if(showComposer) { @@ -206,8 +243,8 @@ fun TabScreen.NotificationViewContent( }, onSend = { finishedDraft -> sm.screenModelScope.launch(Dispatchers.IO) { - val post = finishedDraft.createPost(sm.api) - sm.api.createRecord(RecordUnion.MakePost(post)) + val post = finishedDraft.createPost(sm.agent) + sm.agent.post(post) } showComposer = false }, @@ -220,7 +257,26 @@ fun TabScreen.NotificationViewContent( centerHorizontallyTo(parent) }, backgroundColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.primary) } - } + }, + topContent = { + NotificationsTopBar( + navigator = navigator, + onSettingsClicked = { + showSettings = it + scope.launch { + listState.animateScrollToItem(0) + } + }, + showSettings = showSettings, + hasUnread = hasUnread, + markAsRead = { + sm.updateSeenNotifications() + }, + drawerState = drawerState, + ) + }, + state = uiState, + modifier = Modifier, ) } @@ -231,13 +287,18 @@ fun NotificationsTopBar( onSettingsClicked : (Boolean) -> Unit = {}, showSettings: Boolean = false, hasUnread: Boolean = false, - markAsRead: () -> Unit = {} + markAsRead: () -> Unit = {}, + drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed), ) { var show by remember { mutableStateOf(showSettings) } + val scope = rememberCoroutineScope() CenterAlignedTopAppBar( title = { Text("Notifications") }, navigationIcon = { - IconButton(onClick = { navigator.pop() }) { + IconButton(onClick = { + if(drawerState.isClosed) scope.launch { drawerState.open() } + else scope.launch { drawerState.close() } + }) { Icon(Icons.Default.Menu, contentDescription = "Menu") } }, diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/notifications/TabbedNotificationScreenModel.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/notifications/TabbedNotificationScreenModel.kt deleted file mode 100644 index 7b855ad..0000000 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/notifications/TabbedNotificationScreenModel.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.morpho.app.screens.notifications - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import cafe.adriel.voyager.core.model.screenModelScope -import com.morpho.app.model.uidata.AtCursor -import com.morpho.app.model.uidata.initAtCursor -import com.morpho.app.model.uistate.NotificationsUIState -import com.morpho.app.model.uistate.UiLoadingState -import com.morpho.app.screens.base.BaseScreenModel -import com.morpho.butterfly.AtUri -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch - -class TabbedNotificationScreenModel: BaseScreenModel() { - - private val cursorFlow = initAtCursor() - - private var showPosts by mutableStateOf(true) - private var _uiState: MutableStateFlow = - MutableStateFlow( - NotificationsUIState( - notifService.notifications, - notifService.filter, - showPosts, - UiLoadingState.Loading - ) - ) - - init { - screenModelScope.launch { - val f = notifService.notifications(cursorFlow).map { it.getOrNull() } - cursorFlow.emit(null) - f.collect { - if(it != null) { - _uiState.update { - NotificationsUIState( - notifService.notifications, - notifService.filter, - showPosts, - UiLoadingState.Idle - ) - } - } - } - } - } - - val uiState: StateFlow - get() = _uiState.asStateFlow() - - fun markAllRead() { - notifService.updateNotificationsSeen() - } - - fun markAsRead(uri: AtUri) { - notifService.markAsRead(uri) - } - - fun refreshNotifications(cursor: AtCursor): Boolean { - return cursorFlow.tryEmit(cursor) - } -} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/profile/TabbedProfileView.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/profile/TabbedProfileView.kt index a29c590..3f2976b 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/profile/TabbedProfileView.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/profile/TabbedProfileView.kt @@ -1,47 +1,66 @@ package com.morpho.app.screens.profile import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberDrawerState +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.lifecycle.LifecycleEffect +import app.cash.paging.compose.collectAsLazyPagingItems +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.core.screen.ScreenKey -import cafe.adriel.voyager.koin.getNavigatorScreenModel +import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow -import cafe.adriel.voyager.navigator.tab.* +import cafe.adriel.voyager.navigator.tab.LocalTabNavigator +import cafe.adriel.voyager.navigator.tab.TabDisposable +import cafe.adriel.voyager.navigator.tab.TabNavigator +import cafe.adriel.voyager.navigator.tab.TabOptions import coil3.annotation.ExperimentalCoilApi -import com.morpho.app.model.bluesky.BskyLabelService -import com.morpho.app.model.bluesky.DetailedProfile -import com.morpho.app.model.bluesky.MorphoDataItem -import com.morpho.app.model.bluesky.Profile +import com.morpho.app.model.bluesky.FeedDescriptor +import com.morpho.app.model.uidata.AuthorFeedUpdate +import com.morpho.app.model.uidata.Event +import com.morpho.app.model.uidata.FeedEvent +import com.morpho.app.model.uidata.FeedUpdate +import com.morpho.app.model.uidata.UIUpdate import com.morpho.app.model.uistate.ContentCardState -import com.morpho.app.model.uistate.UiLoadingState import com.morpho.app.screens.base.tabbed.TabScreen import com.morpho.app.ui.common.LoadingCircle import com.morpho.app.ui.common.TabbedProfileScreenScaffold import com.morpho.app.ui.common.TabbedSkylineFragment import com.morpho.app.ui.profile.DetailedProfileFragment -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.StateFlow +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import kotlinx.serialization.Serializable import org.jetbrains.compose.resources.ExperimentalResourceApi import cafe.adriel.voyager.navigator.tab.Tab as NavTab @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) @Composable -fun TabbedProfileTopBar( - profile: Profile?, - ownProfile: Boolean, +fun MyTabbedProfileTopBar( + profile: ContentCardState.MyProfile, scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState()), tabs: List, + onTabChanged: (Int) -> Unit = {}, onBackClicked: () -> Unit, tabIndex: Int = 0, ) { @@ -51,57 +70,82 @@ fun TabbedProfileTopBar( .fillMaxWidth() .nestedScroll(scrollBehavior.nestedScrollConnection), ) { - when(profile != null) { - true -> { - when(profile) { - is DetailedProfile -> DetailedProfileFragment( - profile = profile, - myProfile = ownProfile, - isTopLevel = true, - scrollBehavior = scrollBehavior, - onBackClicked = onBackClicked, - ) - is BskyLabelService -> { TODO("Make different title card for label services")} - else -> { /* Shouldn't happen */ } - } + DetailedProfileFragment( + profile = profile.profile, + myProfile = true, + isTopLevel = true, + scrollBehavior = scrollBehavior, + onBackClicked = onBackClicked, + ) - SecondaryScrollableTabRow( - selectedTabIndex = selectedTabIndex, - edgePadding = 4.dp, - modifier = Modifier.fillMaxWidth() + SecondaryScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 4.dp, + modifier = Modifier.fillMaxWidth() + ) { + tabs.forEachIndexed { index, tab -> + ProfileTabItem( + tab, index ) { - tabs.forEachIndexed { index, tab -> - ProfileTabItem( - tab, index.toUShort() - ) { selectedTabIndex = index } - } + selectedTabIndex = index + onTabChanged(selectedTabIndex) } } - false -> { - // Loading - } } + } +} +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun TabbedProfileTopBar( + profile: ContentCardState.FullProfile, + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState()), + tabs: List, + onTabChanged: (Int) -> Unit = {}, + onBackClicked: () -> Unit, + tabIndex: Int = 0, +) { + var selectedTabIndex by rememberSaveable { mutableIntStateOf(tabIndex) } + Column( + modifier = Modifier + .fillMaxWidth() + .nestedScroll(scrollBehavior.nestedScrollConnection), + ) { + DetailedProfileFragment( + profile = profile.profile, + myProfile = true, + isTopLevel = true, + scrollBehavior = scrollBehavior, + onBackClicked = onBackClicked, + ) + + SecondaryScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 4.dp, + modifier = Modifier.fillMaxWidth() + ) { + tabs.forEachIndexed { index, tab -> + ProfileTabItem( + tab, index + ) { + selectedTabIndex = index + onTabChanged(selectedTabIndex) + } + } + } } } @Composable fun ProfileTabItem( tab: ProfileSkylineTab, - currentIndex: UShort, + currentIndex: Int, onClick: () -> Unit = {}, ) { val navigator = LocalTabNavigator.current - val title = rememberSaveable { - tab.state?.value?.feed?.title.orEmpty() - } val tabModifier = Modifier - .padding( - bottom = 12.dp, - top = 6.dp, - start = 6.dp, - end = 6.dp - ) + .padding(bottom = 12.dp, top = 6.dp, start = 6.dp, end = 6.dp) Tab( selected = currentIndex == tab.index, onClick = { @@ -109,151 +153,243 @@ fun ProfileTabItem( navigator.current = tab }, ) { - Text( - text = title, - modifier = tabModifier - ) + Text(text = tab.title, modifier = tabModifier) } } -@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class, + ExperimentalVoyagerApi::class +) @Composable fun TabScreen.TabbedProfileContent( - ownProfile: Boolean, - sm: TabbedProfileViewModel = LocalNavigator.currentOrThrow.getNavigatorScreenModel(), + ownProfile: Boolean = false, + myProfileState: ContentCardState.MyProfile, + profileState: ContentCardState.FullProfile?, + eventCallback: (Event) -> Unit = {}, ) { - + //ProvideNavigatorLifecycleKMPSupport { val navigator = LocalNavigator.currentOrThrow - - LifecycleEffect( - onStarted = { - sm.initProfile() - }, - onDisposed = {}, - ) - /*val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( - state = rememberTopAppBarState(), - snapAnimationSpec = spring( - stiffness = Spring.StiffnessMediumLow, - dampingRatio = Spring.DampingRatioNoBouncy - ), - //flingAnimationSpec = exponentialDecay() - )*/ + var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) - var insets = WindowInsets.navigationBars.asPaddingValues() - val tabs = rememberSaveable( - sm.tabFlow.value, - sm.profileUiState.loadingState, - sm.profileUiState.tabs.value.size - ) { - List(sm.profileUiState.tabs.value.size) { index -> - ProfileSkylineTab( - index = index.toUShort(), - screenModel = sm, - state = sm.profileUiState.tabStates[index] as StateFlow>?, - paddingValues = insets, - ownProfile = ownProfile, - ) - } - } - val tabsCreated = rememberSaveable(tabs.size, sm.profileUiState.loadingState) { - tabs.isNotEmpty() && sm.profileUiState.loadingState == UiLoadingState.Idle + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val tabs = remember(myProfileState, profileState) { + if (ownProfile) myProfileState.toTabList() else profileState?.toTabList() ?: listOf() } - if (tabsCreated) { - TabNavigator( - tab = tabs.first(), - tabDisposable = { TabDisposable(navigator = it, tabs = tabs) } - ) { - TabbedProfileScreenScaffold( - navBar = { navBar(navigator) }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topContent = { - TabbedProfileTopBar( - sm.profileState?.profile, true, scrollBehavior, tabs.toImmutableList(), - onBackClicked = { navigator.pop() } - ) - }, - content = { - insets = it - CurrentTab() - }, - scrollBehavior = scrollBehavior, - ) - } - } else { + TabNavigator( + tab = tabs.first(), + disposeNestedNavigators = true, + key = "profileTabsNavigator", + tabDisposable = { TabDisposable(navigator = it, tabs = tabs) } + ) { TabbedProfileScreenScaffold( navBar = { navBar(navigator) }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topContent = { - if (sm.profileState?.profile != null) { - DetailedProfileFragment( - profile = sm.profileState?.profile!! as DetailedProfile, - myProfile = ownProfile, - isTopLevel = true, + if(ownProfile) { + MyTabbedProfileTopBar( + profile = myProfileState, scrollBehavior = scrollBehavior, - onBackClicked = { navigator.pop() } + tabs = tabs, + onBackClicked = { navigator.pop() }, + onTabChanged = { index -> + selectedTabIndex = index + }, + tabIndex = selectedTabIndex, ) - } else { - TopAppBar( - title = { Text("Loading...") } + } else if(profileState != null) { + TabbedProfileTopBar( + profile = profileState, + scrollBehavior = scrollBehavior, + tabs = tabs, + onBackClicked = { navigator.pop() }, + onTabChanged = { index -> + selectedTabIndex = index + }, + tabIndex = selectedTabIndex, ) - } + } else LoadingCircle() }, - content = { - LoadingCircle() + content = { insets, state -> + CurrentProfileScreen(eventCallback, insets, state, Modifier) }, + state = if(ownProfile) myProfileState.indexToState(selectedTabIndex) + else profileState?.indexToState(selectedTabIndex), scrollBehavior = scrollBehavior, + profile = myProfileState.profile, + drawerState = drawerState, ) } + //} +} + +@Composable +public fun CurrentProfileScreen( + eventCallback: (Event) -> Unit, + paddingValues: PaddingValues, + state: ContentCardState?, + modifier: Modifier +) { + val navigator = LocalNavigator.currentOrThrow + val currentScreen = navigator.lastItem as ProfileTabScreen + + navigator.saveableState("currentScreen") { + currentScreen.Content( + eventCallback = eventCallback, + paddingValues = paddingValues, + state = state, + modifier = modifier + ) + } } +abstract class ProfileTabScreen: NavTab { + + @Composable + abstract fun Content( + eventCallback: (Event) -> Unit, + paddingValues: PaddingValues, + state: ContentCardState?, + modifier: Modifier + ) + + @OptIn(ExperimentalVoyagerApi::class) + @Composable + final override fun Content() = Content( + eventCallback = {}, PaddingValues(0.dp),null, Modifier + ) + +} + +@Parcelize +@Serializable data class ProfileSkylineTab( - val index: UShort, - val screenModel: TabbedProfileViewModel, - val state: StateFlow>?, - val paddingValues: PaddingValues = PaddingValues(0.dp), + val index: Int, val ownProfile: Boolean = false, -): NavTab { - @OptIn(ExperimentalMaterial3Api::class) + val title: String, +): ProfileTabScreen(), Parcelable { + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) @Composable - override fun Content() { - TabbedSkylineFragment(screenModel, state, paddingValues, refresh = { - state?.value?.uri?.let { it1 -> screenModel.updateFeed(it1, it) } - }, isProfileFeed = true) + override fun Content( + eventCallback: (Event) -> Unit, + paddingValues: PaddingValues, + state: ContentCardState?, + modifier: Modifier + ) { + if(state == null) return + when(state) { + is ContentCardState.ProfileTimeline -> if(state.filter != null) { + state.events.tryEmit(FeedEvent.Load(FeedDescriptor.Author(state.profile.did, state.filter))) + } else state.events.tryEmit(FeedEvent.Load(FeedDescriptor.Likes(state.profile.did))) + is ContentCardState.ProfileList -> state.events + .tryEmit(FeedEvent.LoadLists(state.profile.did, state.listsOrFeeds)) + is ContentCardState.ProfileLabeler -> {} + else -> {} + } + val data = when(val feedState = state.updates.collectAsState(initial = UIUpdate.Empty).value) { + is AuthorFeedUpdate.Feed -> feedState.feed.collectAsLazyPagingItems() + is AuthorFeedUpdate.Feeds -> feedState.feed.collectAsLazyPagingItems() + is AuthorFeedUpdate.Likes -> feedState.feed.collectAsLazyPagingItems() + is AuthorFeedUpdate.Lists -> feedState.feed.collectAsLazyPagingItems() + is FeedUpdate.Feed -> feedState.feed.collectAsLazyPagingItems() + else -> null + } + if(data != null) { + TabbedSkylineFragment( + paddingValues, + isProfileFeed = true, + uiUpdate = state.updates, + eventCallback = eventCallback, + pager = data, + ) + } else { + LoadingCircle() + } } override val key: ScreenKey - get() = "${state?.value?.uri?.atUri.orEmpty()}${hashCode()}" + get() = "${title}$uniqueScreenKey" @OptIn(ExperimentalResourceApi::class, ExperimentalCoilApi::class) override val options: TabOptions @Composable get() { - /* Curious if this works for tab icons - val icon = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalPlatformContext.current) - .fallback(ImageRequest.Builder(LocalPlatformContext.current) - .data(imageResource(Res.drawable.placeholder_pfp).asSkiaBitmap()) - .build().fallbackFactory) - .data(state.profile.avatar) - .crossfade(true) - .build(), - ) - */ - - val name = rememberSaveable { - if (state?.value?.profile?.displayName != null && state.value.profile.displayName!!.isNotEmpty()) { - state.value.profile.displayName!! - } else { state?.value?.profile?.handle?.handle.orEmpty() } - } return TabOptions( - index = index, - title = name, + index = index.toUShort(), + title = title, //icon = icon, ) } } + +fun countProfileTabs(profileState: ContentCardState.FullProfile): Int { + var count = 3 + if(profileState.lists != null) count++ + if(profileState.feeds != null) count++ + if(profileState.labeler != null) count++ + return count +} +fun countMyProfileTabs(profileState: ContentCardState.MyProfile): Int { + var count = 4 + if(profileState.lists != null) count++ + if(profileState.feeds != null) count++ + if(profileState.labeler != null) count++ + return count +} + + +fun ContentCardState.FullProfile.toTabList(): List { + val tabs = mutableListOf() + if(labeler != null) tabs.add(ProfileSkylineTab(0, false, "Labels")) + var index = if(labeler != null) 1 else 0 + tabs.add(ProfileSkylineTab(index++, false, "Posts")) + tabs.add(ProfileSkylineTab(index++, false, "Replies")) + tabs.add(ProfileSkylineTab(index++, false, "Media")) + if(lists != null) tabs.add(ProfileSkylineTab(index++, false, "Lists")) + if(feeds != null) tabs.add(ProfileSkylineTab(index, false, "Feeds")) + return tabs.toList() +} + +fun ContentCardState.MyProfile.toTabList(): List { + val tabs = mutableListOf() + if(labeler != null) tabs.add(ProfileSkylineTab(0, true, "Labels")) + var index = if(labeler != null) 1 else 0 + tabs.add(ProfileSkylineTab(index++, true, "Posts")) + tabs.add(ProfileSkylineTab(index++, true, "Replies")) + tabs.add(ProfileSkylineTab(index++, true, "Media")) + if(lists != null) tabs.add(ProfileSkylineTab(index++, true, "Lists")) + if(feeds != null) tabs.add(ProfileSkylineTab(index++, true, "Feeds")) + if(labeler != null) tabs.add(ProfileSkylineTab(index, true, "Labels")) + return tabs.toList() +} + +fun ContentCardState.MyProfile.indexToState(index: Int): ContentCardState? { + return when(index) { + 0 -> labeler ?: posts + 1 -> if(labeler == null) postReplies else posts + 2 -> if(labeler == null) media else postReplies + 3 -> if(labeler == null) likes else media + 4 -> if(labeler == null) lists ?: feeds else likes + 5 -> if(labeler == null) feeds else lists ?: feeds + 6 -> if(labeler == null) null else feeds + else -> throw IllegalArgumentException("Invalid index: $index") + } +} + +fun ContentCardState.FullProfile.indexToState(index: Int): ContentCardState? { + return when(index) { + 0 -> labeler ?: posts + 1 -> if(labeler == null) postReplies else posts + 2 -> if(labeler == null) media else postReplies + 3 -> if(labeler == null) lists ?: feeds else media + 4 -> if(labeler == null) feeds else lists ?: feeds + 5 -> if(labeler == null) null else feeds + else -> throw IllegalArgumentException("Invalid index: $index") + } +} + + diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/profile/TabbedProfileViewModel.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/profile/TabbedProfileViewModel.kt deleted file mode 100644 index a2d053c..0000000 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/profile/TabbedProfileViewModel.kt +++ /dev/null @@ -1,252 +0,0 @@ -package com.morpho.app.screens.profile - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import app.bsky.actor.GetProfileQuery -import cafe.adriel.voyager.core.model.screenModelScope -import com.morpho.app.model.bluesky.* -import com.morpho.app.model.uidata.ContentCardMapEntry -import com.morpho.app.model.uidata.MorphoData -import com.morpho.app.model.uistate.ContentCardState -import com.morpho.app.model.uistate.TabbedProfileScreenState -import com.morpho.app.model.uistate.UiLoadingState -import com.morpho.app.screens.main.MainScreenModel -import com.morpho.butterfly.AtIdentifier -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import org.lighthousegames.logging.logging - -@Suppress("UNCHECKED_CAST") -// TODO: Revisit these casts if we can, but they should be safe -class TabbedProfileViewModel( - val id: AtIdentifier? = null -): MainScreenModel() { - - companion object { - val log = logging() - } - var profileUiState: TabbedProfileScreenState by mutableStateOf( - TabbedProfileScreenState(loadingState = UiLoadingState.Loading)) - private set - - var profileState: ContentCardState.FullProfile? by mutableStateOf(null) - private set - - private val tabs = mutableListOf() - - private val _tabFlow = MutableStateFlow(tabs.toList()) - val tabFlow: StateFlow> - get() = _tabFlow.asStateFlow() - - var profileId: AtIdentifier? by mutableStateOf(null) - private set - - var myProfile: Boolean = false - private set - - - - - fun initProfile() = screenModelScope.launch { - if(initialized) return@launch - init(false) - if(id != null) { - profileId = id - myProfile = api.atpUser?.id == id - } else { - profileId = api.atpUser?.id - myProfile = true - } - log.d { "Profile of: $profileId"} - initialized = true - if(profileId == null) { - profileUiState = profileUiState.copy( - loadingState = UiLoadingState.Error("Profile not found") - ) - return@launch - } - - profileId?.let { GetProfileQuery(it) }?.let { query -> - api.api.getProfile(query) - .onSuccess { resp -> - profileState = loadProfile(resp.toProfile()) - log.d { "Profile loaded: ${resp.toProfile()}" } - if (profileState != null) { - val tabStates = mutableListOf>>() - when(profileState!!.profile) { - is DetailedProfile -> { - if (profileState?.postsState != null) { - tabs.add( - ContentCardMapEntry.Feed( - profileState!!.postsState.value!!.uri, - profileState!!.postsState.value!!.feed.title, - cursors[profileState!!.postsState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.postsState as StateFlow>) - } - if (profileState?.postRepliesState != null) { - tabs.add( - ContentCardMapEntry.PostThread( - profileState!!.postRepliesState.value!!.uri, - profileState!!.postRepliesState.value!!.feed.title, - cursors[profileState!!.postRepliesState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.postRepliesState as StateFlow>) - } - if (profileState?.mediaState != null) { - tabs.add( - ContentCardMapEntry.Feed( - profileState!!.mediaState.value!!.uri, - profileState!!.mediaState.value!!.feed.title, - cursors[profileState!!.mediaState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.mediaState as StateFlow>) - } - if(myProfile && profileState?.likesState != null) { - tabs.add( - ContentCardMapEntry.Feed( - profileState!!.likesState.value!!.uri, - profileState!!.likesState.value!!.feed.title, - cursors[profileState!!.likesState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.likesState as StateFlow>) - } - if (profileState?.feedsState != null) { - tabs.add( - ContentCardMapEntry.FeedList( - profileState!!.feedsState.value!!.uri, - profileState!!.feedsState.value!!.feed.title, - cursors[profileState!!.feedsState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.feedsState as StateFlow>) - } - if (profileState?.listsState != null) { - tabs.add( - ContentCardMapEntry.UserList( - profileState!!.listsState.value!!.uri, - profileState!!.listsState.value!!.feed.title, - cursors[profileState!!.listsState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.listsState as StateFlow>) - } - } - is BskyLabelService -> { - if (profileState?.modServiceState != null) { - tabs.add( - ContentCardMapEntry.ServiceList( - profileState!!.modServiceState.value!!.uri, - profileState!!.modServiceState.value!!.feed.title, - cursors[profileState!!.modServiceState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.modServiceState as StateFlow>) - } - if (profileState?.listsState != null) { - tabs.add( - ContentCardMapEntry.UserList( - profileState!!.listsState.value!!.uri, - profileState!!.listsState.value!!.feed.title, - cursors[profileState!!.listsState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.listsState as StateFlow>) - } - if (profileState?.postsState != null) { - tabs.add( - ContentCardMapEntry.Feed( - profileState!!.postsState.value!!.uri, - profileState!!.postsState.value!!.feed.title, - cursors[profileState!!.postsState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.postsState as StateFlow>) - } - if (profileState?.postRepliesState != null) { - tabs.add( - ContentCardMapEntry.PostThread( - profileState!!.postRepliesState.value!!.uri, - profileState!!.postRepliesState.value!!.feed.title, - cursors[profileState!!.postRepliesState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.postRepliesState as StateFlow>) - } - if (profileState?.feedsState != null) { - tabs.add( - ContentCardMapEntry.FeedList( - profileState!!.feedsState.value!!.uri, - profileState!!.feedsState.value!!.feed.title, - cursors[profileState!!.feedsState.value!!.uri] - ?: MutableStateFlow(null) - ) - ) - tabStates.add(profileState!!.feedsState as StateFlow>) - } - } - else -> {} - } - _tabFlow.value = tabs.toImmutableList() - log.d { "Tabs: ${tabs.map { it.title }}"} - profileUiState = profileUiState.copy( - tabs = tabFlow, - tabStates = tabStates.toImmutableList(), - loadingState = UiLoadingState.Idle - ) - } - }.onFailure { - profileUiState = profileUiState - .copy( - loadingState = UiLoadingState - .Error("Profile not loaded") - ) - log.e(it) { "Profile not loaded. Error: $it" } - } - } - - } - - suspend fun loadProfile(profile: DetailedProfile): ContentCardState.FullProfile? { - val profileEntry = ContentCardMapEntry.Profile(profile.did) - return initProfileContent(profileEntry, force = true, fill = true).first() - } - - - override fun unloadContent(entry: ContentCardMapEntry): MorphoData? { - val maybeTab = profileUiState.tabMap[entry.uri] - return if(maybeTab == null) { - history.popUntil { it == entry } - unloadContent(entry.uri) - } else { - unloadContent(maybeTab) - } - } - - fun unloadTab(index: Int): MorphoData? { - if(index < 0 || index > tabs.lastIndex) return null - val uri = tabs[index].uri - val state = profileUiState.tabMap[uri] ?: return null - return unloadContent(state) - } - -} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/settings/TabbedSettingsView.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/settings/TabbedSettingsView.kt new file mode 100644 index 0000000..1c64ed6 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/settings/TabbedSettingsView.kt @@ -0,0 +1,412 @@ +package com.morpho.app.screens.settings + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBackIosNew +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.core.stack.StackEvent +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.transitions.ScreenTransition +import cafe.adriel.voyager.transitions.ScreenTransitionContent +import com.morpho.app.data.ContentLabelService +import com.morpho.app.data.MorphoAgent +import com.morpho.app.screens.base.tabbed.SettingsTab +import com.morpho.app.screens.base.tabbed.TabbedNavBar +import com.morpho.app.screens.main.tabbed.TabbedMainScreenModel +import com.morpho.app.ui.common.TabbedScreenScaffold +import com.morpho.app.ui.settings.AccessibilitySettings +import com.morpho.app.ui.settings.AppearanceSettings +import com.morpho.app.ui.settings.FeedPreferences +import com.morpho.app.ui.settings.LanguageSettings +import com.morpho.app.ui.settings.ModerationSettingsFragment +import com.morpho.app.ui.settings.SettingsFragment +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import org.koin.compose.koinInject + +@Composable +public fun CurrentSettingsScreen( + sm: TabbedMainScreenModel, + parentNav: Navigator = LocalNavigator.currentOrThrow, + modifier: Modifier +) { + val navigator = LocalNavigator.currentOrThrow + val currentScreen = navigator.lastItem as SettingsScreen + + navigator.saveableState("currentScreen") { + currentScreen.Content( + sm = sm, + parentNav = parentNav, + modifier = modifier + ) + } +} + + +abstract class SettingsScreen: Screen { + open val title: String = "Settings" + + val navBar: @Composable (@Contextual Navigator) -> Unit = { n -> + TabbedNavBar(SettingsTab.options.index, n) + } + + @Composable + abstract fun Content( + sm: TabbedMainScreenModel, + parentNav: Navigator, + modifier: Modifier + ) + + @OptIn(ExperimentalVoyagerApi::class) + @Composable + final override fun Content() = + Content(TabbedMainScreenModel(koinInject(), koinInject()), + LocalNavigator.currentOrThrow, Modifier) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsTopBar( + title: String = "Settings", + navigator: Navigator = LocalNavigator.currentOrThrow +) { + CenterAlignedTopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navigator.pop() }) { + Icon(Icons.Default.ArrowBackIosNew, contentDescription = "Back") + } + } + ) +} + +@Parcelize +@Serializable +data object SettingsRootPage: SettingsScreen(), Parcelable { + override val title: String = "Settings" + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) + @Composable + override fun Content( + sm: TabbedMainScreenModel, + parentNav: Navigator, + modifier: Modifier + ) { + val navigator = LocalNavigator.currentOrThrow + TabbedScreenScaffold( + navBar = { navBar(parentNav) }, + content = { insets, nav -> + SettingsFragment( + modifier = Modifier.padding(insets), + navigator = nav!!, + sm = sm, + ) + }, + topContent = { + SettingsTopBar(title = title, navigator = navigator) + }, + state = navigator, + modifier = modifier, + ) + } + + override val key: ScreenKey + get() = "SettingsRootPage_$uniqueScreenKey" +} + +@Parcelize +@Serializable +data object AccessibilitySettingsScreen: SettingsScreen(), Parcelable { + override val title: String = "Accessibility" + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) + @Composable + override fun Content( + sm: TabbedMainScreenModel, + parentNav: Navigator, + modifier: Modifier + ) { + val navigator = LocalNavigator.currentOrThrow + TabbedScreenScaffold( + navBar = { navBar(parentNav) }, + content = { insets, _ -> + AccessibilitySettings( + agent = sm.agent, + modifier = Modifier.padding(insets), + distinguish = false, + ) + }, + topContent = { + SettingsTopBar(title = title, navigator = navigator) + }, + state = navigator, + modifier = modifier, + ) + } + + override val key: ScreenKey + get() = "AccessibilitySettings_$uniqueScreenKey" + +} + +@Parcelize +@Serializable +data object AppearanceSettingsScreen: SettingsScreen(), Parcelable { + override val title: String = "Appearance" + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) + @Composable + override fun Content( + sm: TabbedMainScreenModel, + parentNav: Navigator, + modifier: Modifier + ) { + val navigator = LocalNavigator.currentOrThrow + TabbedScreenScaffold( + navBar = { navBar(parentNav) }, + content = { insets, _ -> + AppearanceSettings( + agent = sm.agent, + modifier = Modifier.padding(insets), + distinguish = false, + ) + }, + topContent = { + SettingsTopBar(title = title, navigator = navigator) + }, + state = navigator, + modifier = modifier, + ) + } + + override val key: ScreenKey + get() = "AppearanceSettings_$uniqueScreenKey" +} + +@Parcelize +@Serializable +data object NotificationsSettingsScreen: SettingsScreen(), Parcelable { + override val title: String = "Notifications" + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) + @Composable + override fun Content( + sm: TabbedMainScreenModel, + parentNav: Navigator, + modifier: Modifier + ) { + val navigator = LocalNavigator.currentOrThrow + TabbedScreenScaffold( + navBar = { navBar(parentNav) }, + content = { insets, nav -> + SettingsFragment( + sm = sm, + modifier = Modifier.padding(insets), + navigator = nav!! + ) + }, + topContent = { + SettingsTopBar(title = title, navigator = navigator) + }, + state = navigator, + modifier = modifier, + ) + } + + override val key: ScreenKey + get() = "NotificationsSettings_$uniqueScreenKey" +} + +@OptIn(ExperimentalVoyagerApi::class) +@Composable +fun SettingsScreenTransition( + navigator: Navigator, + sm: TabbedMainScreenModel, + parentNav: Navigator, + modifier: Modifier, + animationSpec: FiniteAnimationSpec = spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold + ), + content: ScreenTransitionContent = { + CurrentSettingsScreen(sm, parentNav, modifier) + } +) { + ScreenTransition( + navigator = navigator, + modifier = modifier, + content = content, + disposeScreenAfterTransitionEnd = true, + transition = { + val (initialOffset, targetOffset) = when (navigator.lastEvent) { + StackEvent.Pop -> ({ size: Int -> -size }) to ({ size: Int -> size }) + StackEvent.Replace -> ({ size: Int -> -size }) to ({ size: Int -> size }) + else -> ({ size: Int -> size }) to ({ size: Int -> -size }) + } + + slideInHorizontally(animationSpec, initialOffset) togetherWith + slideOutHorizontally(animationSpec, targetOffset) + + } + ) +} + + +@Parcelize +@Serializable +data object ModerationSettingsScreen: SettingsScreen(), Parcelable { + override val title: String = "Moderation" + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) + @Composable + override fun Content( + sm: TabbedMainScreenModel, + parentNav: Navigator, + modifier: Modifier + ) { + val navigator = LocalNavigator.currentOrThrow + TabbedScreenScaffold( + navBar = { navBar(parentNav) }, + content = { insets, nav -> + ModerationSettingsFragment( + agent = sm.agent, + modifier = Modifier.padding(insets), + navigator = nav!! + + ) + }, + topContent = { + SettingsTopBar(title = title, navigator = navigator) + }, + state = navigator, + modifier = modifier, + ) + } + + override val key: ScreenKey + get() = "ModerationSettings_$uniqueScreenKey" +} + +@Parcelize +@Serializable +data object LanguageSettingsScreen: SettingsScreen(), Parcelable { + override val title: String = "Language" + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) + @Composable + override fun Content( + sm: TabbedMainScreenModel, + parentNav: Navigator, + modifier: Modifier + ) { + val navigator = LocalNavigator.currentOrThrow + TabbedScreenScaffold( + navBar = { navBar(parentNav) }, + content = { insets, _ -> + LanguageSettings( + agent = sm.agent, + modifier = Modifier.padding(insets), + distinguish = false, + ) + }, + topContent = { + SettingsTopBar(title = title, navigator = navigator) + }, + state = navigator, + modifier = modifier, + ) + } + + override val key: ScreenKey + get() = "LanguageSettings_$uniqueScreenKey" +} + +@Parcelize +@Serializable +data object ThreadSettingsScreen: SettingsScreen(), Parcelable { + override val title: String = "Thread" + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) + @Composable + override fun Content( + sm: TabbedMainScreenModel, + parentNav: Navigator, + modifier: Modifier + ) { + val navigator = LocalNavigator.currentOrThrow + TabbedScreenScaffold( + navBar = { navBar(parentNav) }, + content = { insets, nav -> + SettingsFragment( + sm = sm, + modifier = Modifier.padding(insets), + navigator = nav!! + ) + }, + topContent = { + SettingsTopBar(title = title, navigator = navigator) + }, + state = navigator, + modifier = modifier, + ) + } + + override val key: ScreenKey + get() = "ThreadSettings_$uniqueScreenKey" +} + +@Parcelize +@Serializable +data object FeedSettingsScreen: SettingsScreen(), Parcelable { + override val title: String = "Feed" + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalVoyagerApi::class) + @Composable + override fun Content( + sm: TabbedMainScreenModel, + parentNav: Navigator, + modifier: Modifier + ) { + val navigator = LocalNavigator.currentOrThrow + TabbedScreenScaffold( + navBar = { navBar(parentNav) }, + content = { insets, _ -> + FeedPreferences( + agent = sm.agent, + modifier = Modifier.padding(insets), + distinguish = false, + ) + }, + topContent = { + SettingsTopBar(title = title, navigator = navigator) + }, + state = navigator, + modifier = modifier, + ) + } + + override val key: ScreenKey + get() = "FeedSettings_$uniqueScreenKey" +} diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/thread/ThreadView.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/thread/ThreadView.kt index fdb4b27..ef6b6ff 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/thread/ThreadView.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/screens/thread/ThreadView.kt @@ -4,63 +4,107 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBackIosNew -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.koin.getNavigatorScreenModel +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi +import cafe.adriel.voyager.core.model.rememberNavigatorScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.koin.koinNavigatorScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.currentOrThrow import com.atproto.repo.StrongRef +import com.morpho.app.data.MorphoAgent import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.BskyPostThread import com.morpho.app.model.bluesky.DraftPost +import com.morpho.app.model.uidata.ThreadUpdate import com.morpho.app.model.uistate.ContentCardState import com.morpho.app.screens.base.tabbed.ProfileTab import com.morpho.app.screens.base.tabbed.TabScreen import com.morpho.app.screens.base.tabbed.ThreadTab import com.morpho.app.screens.main.MainScreenModel -import com.morpho.app.ui.common.* +import com.morpho.app.ui.common.BottomSheetPostComposer +import com.morpho.app.ui.common.ComposerRole +import com.morpho.app.ui.common.LoadingCircle +import com.morpho.app.ui.common.RepostQueryDialog +import com.morpho.app.ui.common.TabbedScreenScaffold import com.morpho.app.ui.elements.doMenuOperation import com.morpho.app.ui.thread.ThreadFragment +import com.morpho.app.ui.utils.ItemClicked import com.morpho.app.util.ClipboardManager +import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri -import com.morpho.butterfly.Butterfly +import com.morpho.butterfly.Did import com.morpho.butterfly.model.RecordType import com.morpho.butterfly.model.RecordUnion import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import org.koin.compose.getKoin -@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class, + ExperimentalVoyagerApi::class +) @Composable fun TabScreen.ThreadViewContent( - threadState: StateFlow, - navigator:Navigator = LocalNavigator.currentOrThrow, - sm:MainScreenModel = navigator.getNavigatorScreenModel() + cardState: ContentCardState.PostThread, + navigator: Navigator = LocalNavigator.currentOrThrow, + ) { - val thread by threadState.value.thread.collectAsState() + val sm = navigator.koinNavigatorScreenModel() + val threadState by cardState.updates.filterIsInstance().collectAsState(ThreadUpdate.Empty) + TabbedScreenScaffold( navBar = { navBar(navigator) }, + content = { insets, state -> + when(state) { + is ThreadUpdate.Empty -> { + LoadingCircle() + } + + is ThreadUpdate.Error -> { + Text("Error: ${state.error}") + } + + is ThreadUpdate.Thread -> { + ThreadView( + thread = state.results, + insets = insets, + navigator = navigator, + createRecord = { sm.screenModelScope.launch { sm.agent.createRecord(it) } }, + deleteRecord = { type, uri -> sm.screenModelScope.launch { + sm.agent.deleteRecord(type, uri) + } }, + resolveHandle = { handle -> sm.agent.resolveHandle(handle).getOrNull() } + ) + } + else -> { + Text("Unknown state: $state") + } + } + + }, topContent = { ThreadTopBar(navigator = navigator) }, - content = { insets -> - if(thread != null) { - ThreadView( - thread!!, - insets = insets, - navigator = navigator, - createRecord = { sm.createRecord(it) }, - deleteRecord = { type, uri -> sm.deleteRecord(type, uri) } - ) - } else { - LoadingCircle() - } - - } + state = threadState, + modifier = Modifier, ) } @@ -85,6 +129,7 @@ fun ThreadView( navigator: Navigator = LocalNavigator.currentOrThrow, createRecord: (RecordUnion) -> Unit = { }, deleteRecord: (RecordType, AtUri) -> Unit = { _, _ -> }, + resolveHandle: suspend (AtIdentifier) -> Did?, ) { var repostClicked by remember { mutableStateOf(false)} var initialContent: BskyPost? by remember { mutableStateOf(null) } @@ -96,22 +141,36 @@ fun ThreadView( var draft by remember{ mutableStateOf(DraftPost()) } val clipboard = getKoin().get() val scope = rememberCoroutineScope() - ThreadFragment(thread = thread, - contentPadding = insets, - onItemClicked = { navigator.push(ThreadTab(it)) }, - onProfileClicked = { navigator.push(ProfileTab(it)) }, - onUnClicked = {type, uri -> deleteRecord(type, uri)}, - onRepostClicked = { - initialContent = it - repostClicked = true - }, - onReplyClicked = { - initialContent = it - composerRole = ComposerRole.Reply - showComposer = true - }, - onMenuClicked = { option, post -> doMenuOperation(option, post, clipboardManager = clipboard) }, - onLikeClicked = { createRecord(RecordUnion.Like(it)) }, + val uriHandler = LocalUriHandler.current + ThreadFragment( + thread = thread, + contentPadding = insets, + onItemClicked = ItemClicked( + uriHandler = uriHandler, + navigator = navigator, + ), + onProfileClicked = { + scope.launch { + val did = resolveHandle(it) + if(did != null) navigator.push(ProfileTab(did)) + } + }, + onUnClicked = { type, uri -> deleteRecord(type, uri) }, + onRepostClicked = { + initialContent = it + repostClicked = true + }, + onReplyClicked = { + initialContent = it + composerRole = ComposerRole.Reply + showComposer = true + }, + onMenuClicked = { option, post -> + doMenuOperation(option, post, + clipboardManager = clipboard, + uriHandler = uriHandler + ) }, + onLikeClicked = { createRecord(RecordUnion.Like(it)) }, ) if(repostClicked) { RepostQueryDialog( @@ -121,7 +180,6 @@ fun ThreadView( }, onRepost = { repostClicked = false - composerRole = ComposerRole.QuotePost initialContent?.let { post -> RecordUnion.Repost( StrongRef(post.uri,post.cid) @@ -129,13 +187,14 @@ fun ThreadView( }?.let { createRecord(it) } }, onQuotePost = { + composerRole = ComposerRole.QuotePost showComposer = true repostClicked = false } ) } if(showComposer) { - val api = getKoin().get() + val agent = getKoin().get() BottomSheetPostComposer( onDismissRequest = { showComposer = false }, sheetState = sheetState, @@ -149,7 +208,7 @@ fun ThreadView( }, onSend = { finishedDraft -> scope.launch(Dispatchers.IO) { - val post = finishedDraft.createPost(api) + val post = finishedDraft.createPost(agent) createRecord(RecordUnion.MakePost(post)) } showComposer = false diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/NavDrawer.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/NavDrawer.kt new file mode 100644 index 0000000..e6befaa --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/NavDrawer.kt @@ -0,0 +1,264 @@ +package com.morpho.app.ui.common + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material3.Badge +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.koin.koinNavigatorScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.navigator.tab.TabNavigator +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.screens.base.tabbed.FeedsTab +import com.morpho.app.screens.base.tabbed.HomeTab +import com.morpho.app.screens.base.tabbed.MyProfileTab +import com.morpho.app.screens.base.tabbed.NotificationsTab +import com.morpho.app.screens.base.tabbed.SearchTab +import com.morpho.app.screens.base.tabbed.SettingsTab +import com.morpho.app.screens.base.tabbed.TabScreen +import com.morpho.app.screens.main.tabbed.TabbedMainScreenModel +import com.morpho.app.ui.elements.AvatarShape +import com.morpho.app.ui.elements.OutlinedAvatar +import com.morpho.app.util.openBrowser +import io.ktor.util.reflect.instanceOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun NavDrawer( + profile: DetailedProfile? = null, + navigator: Navigator = if (LocalNavigator.current?.parent?.instanceOf(TabNavigator::class) == true) { + LocalNavigator.currentOrThrow + } else LocalNavigator.currentOrThrow.parent!!, + drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + navDrawerContent: @Composable ColumnScope.(drawerState: DrawerState, navigator: Navigator) -> Unit = { + drawer, nav -> + NavDrawerItems(drawerState = drawer, navigator = nav) + }, + content: @Composable () -> Unit, +) { + ModalNavigationDrawer( + gesturesEnabled = true, + drawerState = drawerState, + drawerContent = { + val uriHandler = LocalUriHandler.current + ModalDrawerSheet( + Modifier.width(300.dp) + ) { + val hPad = 16.dp + FlowRow( + verticalArrangement = Arrangement.Bottom, + horizontalArrangement = Arrangement.Start, + ) { + OutlinedAvatar( + url = profile?.avatar.orEmpty(), + contentDescription = + "Avatar for ${profile?.displayName.orEmpty()} ${profile?.handle?.handle.orEmpty()}", + modifier = Modifier.padding(start = hPad, top = hPad, bottom = 4.dp), + size = 80.dp, + avatarShape = AvatarShape.Rounded, + outlineColor = MaterialTheme.colorScheme.background, + onClicked = { navigator.push(MyProfileTab) } + ) + Column( + modifier = Modifier.align(Alignment.Bottom).padding(vertical = 4.dp) + ) { + if(profile?.displayName != null) { + Text( + text = profile.displayName, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = hPad, vertical = 0.dp), + ) + } + Text( + text = "@${profile?.handle?.handle?: "Invalid Handle"}", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = hPad, vertical = 0.dp), + ) + } + } + Row( + modifier = Modifier.padding(horizontal = hPad) + ) { + TextButton( + onClick = { /*TODO*/ }, + contentPadding = PaddingValues(vertical = 4.dp, horizontal = 4.dp), + modifier = Modifier + .heightIn(min = 20.dp, max = 48.dp) + .defaultMinSize(minWidth = 10.dp) + ) { + Text( + text = "${profile?.followersCount}", + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = " followers", + fontSize = MaterialTheme.typography.labelMedium.fontSize.times(0.9), + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelMedium, + ) + } + TextButton( + onClick = { /*TODO*/ }, + contentPadding = PaddingValues(vertical = 4.dp, horizontal = 4.dp), + modifier = Modifier + .heightIn(min = 20.dp, max = 48.dp) + .defaultMinSize(minWidth = 10.dp) + ) { + Text( + text = "${profile?.followsCount}", + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = " following", + fontSize = MaterialTheme.typography.labelMedium.fontSize.times(0.9), + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelMedium, + ) + } + } + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + navDrawerContent(drawerState, navigator) + Spacer(Modifier.weight(1f)) + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) { + + TextButton( + onClick = { + openBrowser("https://github.com/morpho-app/Morpho/issues/new", uriHandler) + }, + colors = ButtonDefaults.buttonColors(), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.padding(horizontal = 8.dp), + + ) { + Icon( + imageVector = Icons.Default.Feedback, + contentDescription = "", + //tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterVertically) + .padding(end = 8.dp) + ) + Text( + "Feedback", + //color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + TextButton( + onClick = { + openBrowser("https://github.com/morpho-app/Morpho", uriHandler) + }, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + + Text( + "Help", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + ) { + content() + } +} + +@Composable +fun NavDrawerItem( + tab: TabScreen, + navigator: Navigator = LocalNavigator.currentOrThrow, + drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + scope: CoroutineScope = rememberCoroutineScope(), + badge: @Composable() (() -> Unit)? = null, +) { + val nav = if (navigator.instanceOf(TabNavigator::class)) { + navigator.parent!! + } else navigator + val selected = nav.lastItem.key == tab.key + NavigationDrawerItem( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 4.dp), + icon = { tab.options.icon() }, + label = { Text(tab.options.title) }, + selected = selected, + badge = badge, + shape = MaterialTheme.shapes.medium, + onClick = { + if(selected) scope.launch { drawerState.close() } + nav.popUntil { it == tab } + nav.push(tab) + }, + ) +} + +@Composable +fun ColumnScope.NavDrawerItems( + navigator: Navigator = LocalNavigator.currentOrThrow, + drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed), +) { + NavDrawerItem(SearchTab, drawerState = drawerState, navigator = navigator) + NavDrawerItem(HomeTab("home"), drawerState = drawerState, navigator = navigator) + NavDrawerItem(NotificationsTab, drawerState = drawerState, navigator = navigator, + badge = { + val sm = LocalNavigator.currentOrThrow.koinNavigatorScreenModel() + val unread by sm.unreadNotificationsCount().collectAsState(0) + if(unread > 0) { + Badge( + containerColor = MaterialTheme.colorScheme.secondary + ) + } + }) + NavDrawerItem(FeedsTab, drawerState = drawerState, navigator = navigator) + NavDrawerItem(MyProfileTab, drawerState = drawerState, navigator = navigator) + NavDrawerItem(SettingsTab, drawerState = drawerState, navigator = navigator) +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/PostComposer.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/PostComposer.kt index 172b26e..1c90c7a 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/PostComposer.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/PostComposer.kt @@ -15,14 +15,10 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import app.bsky.feed.PostReplyRef -import com.atproto.repo.StrongRef import com.morpho.app.data.toSharedImage import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.DraftImage import com.morpho.app.model.bluesky.DraftPost -import com.morpho.app.model.bluesky.toProfileViewBasic -import com.morpho.app.model.uidata.getReplyRefs import com.morpho.app.ui.post.ComposerPostFragment import io.github.vinceglb.filekit.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.core.PickerMode @@ -30,7 +26,6 @@ import io.github.vinceglb.filekit.core.PickerType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.flow.singleOrNull import kotlinx.coroutines.launch @@ -67,7 +62,7 @@ inline fun BottomSheetPostComposer( if (!sheetState.isVisible) { onDismissRequest() } } }, containerColor = MaterialTheme.colorScheme.background, sheetState = sheetState, - windowInsets = WindowInsets.navigationBars.union(WindowInsets.ime), + contentWindowInsets = { WindowInsets.navigationBars.union(WindowInsets.ime) }, ){ PostComposer( @@ -108,38 +103,6 @@ fun PostComposer( ) { val focusManager = LocalFocusManager.current var postText by rememberSaveable { mutableStateOf(draft.text) } - val localReplyRef = remember { - if(role == ComposerRole.Reply) { - if (initialContent != null) { - val root: StrongRef = if (initialContent.reply?.root != null) { - StrongRef(initialContent.reply.root.uri,initialContent.reply.root.cid) - } else if (initialContent.reply?.parent != null) { - StrongRef(initialContent.reply.parent.uri, initialContent.reply.parent.cid) - } else { - StrongRef(initialContent.uri,initialContent.cid) - } - val parent: StrongRef = StrongRef(initialContent.uri, initialContent.cid) - val grandParentAuthor = (if (initialContent.reply?.parent != null) { - initialContent.reply.grandparentAuthor - } else { - initialContent.author - })?.toProfileViewBasic() - PostReplyRef(root, parent, grandParentAuthor) - } else if (draft.reply != null) { - StrongRef(draft.reply.uri, draft.reply.cid) - } else null - } else null - } - var replyRef by remember { mutableStateOf(localReplyRef) } - // TODO: Probably put this somewhere saner, but for now this works - LaunchedEffect(localReplyRef) { - val uri = initialContent?.uri ?: draft.reply?.uri - if (role == ComposerRole.Reply && localReplyRef == null && uri != null) { - getReplyRefs(uri).singleOrNull()?.getOrNull()?.let { - replyRef = it - } - } - } val submitText = rememberSaveable { when(role) { ComposerRole.StandalonePost -> "Post" @@ -179,7 +142,7 @@ fun PostComposer( onUpdate( DraftPost( text = postText, - reply = if (replyRef != null && role == ComposerRole.Reply) initialContent else null, + reply = if (role == ComposerRole.Reply) initialContent else null, quote = if (role == ComposerRole.QuotePost) initialContent else null, images = postImages ) @@ -224,7 +187,7 @@ fun PostComposer( .imePadding(), verticalArrangement = Arrangement.Top ) { - if (replyRef != null && initialContent != null) { + if (role == ComposerRole.Reply && initialContent != null) { ComposerPostFragment( post = initialContent, modifier = Modifier diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineFragment.kt index 2f5d3f8..b6e487e 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineFragment.kt @@ -1,74 +1,109 @@ package com.morpho.app.ui.common import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Create +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedIconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout +import app.bsky.actor.Visibility +import app.cash.paging.LoadStateError +import app.cash.paging.LoadStateLoading +import app.cash.paging.compose.LazyPagingItems +import app.cash.paging.compose.itemKey +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import com.atproto.repo.StrongRef import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.MorphoDataItem -import com.morpho.app.model.uidata.AtCursor -import com.morpho.app.model.uidata.ContentHandling -import com.morpho.app.model.uistate.ContentCardState -import com.morpho.app.model.uistate.ContentLoadingState import com.morpho.app.ui.elements.MenuOptions import com.morpho.app.ui.elements.WrappedLazyColumn +import com.morpho.app.ui.lists.FeedListEntryFragment +import com.morpho.app.ui.lists.UserListEntryFragment +import com.morpho.app.ui.post.PlaceholderSkylineItem import com.morpho.app.ui.post.PostFragment -import com.morpho.butterfly.AtIdentifier +import com.morpho.app.ui.profile.CompactProfileFragment +import com.morpho.app.ui.settings.ContentLabelSelector +import com.morpho.app.ui.utils.ItemClicked +import com.morpho.app.ui.utils.OnItemClicked import com.morpho.butterfly.AtUri +import com.morpho.butterfly.ContentHandling import com.morpho.butterfly.model.RecordType -import io.ktor.util.encodeBase64 -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch typealias OnPostClicked = (AtUri) -> Unit -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) @Composable -fun SkylineFragment ( - content: StateFlow>, +fun SkylineFragment ( modifier: Modifier = Modifier, - onItemClicked: OnPostClicked, - onProfileClicked: (AtIdentifier) -> Unit = {}, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), onPostButtonClicked: () -> Unit = {}, - refresh: (AtCursor) -> Unit = {}, onReplyClicked: (BskyPost) -> Unit = { }, onRepostClicked: (BskyPost) -> Unit = { }, onLikeClicked: (StrongRef) -> Unit = { }, onMenuClicked: (MenuOptions, BskyPost) -> Unit = { _, _ -> }, onUnClicked: (type: RecordType, uri: AtUri) -> Unit = { _, _ -> }, + onLabelChoiceSelected: (Visibility) -> Unit = { }, getContentHandling: (BskyPost) -> List = { listOf() }, contentPadding: PaddingValues = PaddingValues(0.dp), isProfileFeed: Boolean = false, + debuggable: Boolean = false, + pager: LazyPagingItems, + listState: LazyListState = rememberLazyListState(), + scope: CoroutineScope = rememberCoroutineScope(), ) { - val currentRefresh by rememberUpdatedState(refresh) - - val state = content.collectAsState() - val loading = state.value.loadingState - val cursor by rememberUpdatedState(state.value.feed.cursor) - - val scope = rememberCoroutineScope() - var refreshing by remember { mutableStateOf(false) } - - val listState: LazyListState = rememberLazyListState() - - val data = remember(loading, state, cursor, refreshing) { - state.value.feed - } val scrolledDownSome by remember { derivedStateOf { listState.firstVisibleItemIndex > 5 @@ -81,33 +116,15 @@ fun SkylineFragment ( } } - fun refreshPull() = scope.launch { - refreshing = true - launch { currentRefresh(null) } - .invokeOnCompletion { refreshing = false } - - } - - LaunchedEffect( - data.items.isNotEmpty() && - loading == ContentLoadingState.Idle && - !listState.canScrollForward && - !refreshing && - scrolledDownSome - ) { - currentRefresh(cursor) - } - - - val refreshState = rememberPullRefreshState(refreshing, ::refreshPull) + val refreshing by remember { mutableStateOf(false) } + val refreshState = rememberPullRefreshState(refreshing, {pager.refresh()}) ConstraintLayout( modifier = if(isProfileFeed) { Modifier - .fillMaxWidth() - .systemBarsPadding() + .fillMaxSize().systemBarsPadding() } else { Modifier @@ -128,9 +145,15 @@ fun SkylineFragment ( top.linkTo(parent.top) bottom.linkTo(parent.bottom) }, - //flingBehavior = rememberSnapFlingBehavior(lazyListState = listState), + flingBehavior = rememberSnapFlingBehavior(lazyListState = listState), contentPadding = if(isProfileFeed) { - contentPadding + //contentPadding + PaddingValues( + bottom = contentPadding.calculateBottomPadding(), +// top = WindowInsets.safeContent.only(WindowInsetsSides.Top).asPaddingValues() +// .calculateTopPadding() + top = contentPadding.calculateTopPadding() + ) } else { PaddingValues( bottom = contentPadding.calculateBottomPadding(), @@ -141,7 +164,7 @@ fun SkylineFragment ( verticalArrangement = Arrangement.Top, state = listState ) { - if(!isProfileFeed) { + if(false) { item { Row( modifier = Modifier.fillMaxWidth(), @@ -153,7 +176,7 @@ fun SkylineFragment ( .weight(0.4f) ) IconButton( - onClick = { refreshPull() }, + onClick = {pager.refresh()}, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onSurfaceVariant @@ -187,58 +210,114 @@ fun SkylineFragment ( } } } - items( - data.items, key = { - when(it) { - is MorphoDataItem.Post -> "post_${it.post.uri}_${it.post.hashCode()}_${it.post.cid}".encodeBase64() - is MorphoDataItem.Thread -> "thread_${it.thread.post.uri}_${it.thread.hashCode()}_${it.thread.post.cid}".encodeBase64() - else -> "${it.hashCode()}".encodeBase64() - } - }, - contentType = { - when(it) { - is MorphoDataItem.Post -> MorphoDataItem.Post::class - is MorphoDataItem.Thread -> MorphoDataItem.Thread::class - else -> {} - } + val refreshLoadState = pager.loadState.refresh + val appendLoadState = pager.loadState.append + + when { + refreshLoadState is LoadStateError || appendLoadState is LoadStateError -> { + item { Text("$refreshLoadState\n$appendLoadState") } + item { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + TextButton(onClick = { pager.retry() }) { + Text("Retry") + } } } } - ) {item -> - when(item) { - is MorphoDataItem.Thread -> { - SkylineThreadFragment( - thread = item.thread, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp, horizontal = 4.dp), - onItemClicked = onItemClicked, - onProfileClicked = onProfileClicked, - onUnClicked = onUnClicked, - onRepostClicked = onRepostClicked, - onReplyClicked = onReplyClicked, - onMenuClicked = onMenuClicked, - onLikeClicked = onLikeClicked, - getContentHandling = getContentHandling, - ) - } - is MorphoDataItem.Post -> { - PostFragment( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp, horizontal = 4.dp), - post = item.post, - onItemClicked = onItemClicked, - onProfileClicked = onProfileClicked, - elevate = true, - onUnClicked = onUnClicked, - onRepostClicked = onRepostClicked, - onReplyClicked = onReplyClicked, - onMenuClicked = onMenuClicked, - onLikeClicked = onLikeClicked, - getContentHandling = getContentHandling, - ) - } + refreshLoadState is LoadStateLoading -> { item { LoadingCircle() } } + else -> { + items( + count = pager.itemCount, + key = pager.itemKey { + it.hashCode() + }, + contentType = { + MorphoDataItem + } + ) { index -> + when(val item = pager[index]) { + is MorphoDataItem.Thread -> { + SkylineThreadFragment( + thread = item.thread, + modifier = if(debuggable) Modifier.border(1.dp, Color.White) + else Modifier + .fillMaxWidth() + //.padding(horizontal = 4.dp), + .padding(vertical = 2.dp, horizontal = 4.dp), + onItemClicked = onItemClicked, + onProfileClicked = { onItemClicked.onProfileClicked(it) }, + onUnClicked = onUnClicked, + onRepostClicked = onRepostClicked, + onReplyClicked = onReplyClicked, + onMenuClicked = onMenuClicked, + onLikeClicked = onLikeClicked, + getContentHandling = getContentHandling, + debuggable = debuggable, + ) + } + is MorphoDataItem.Post -> { + PostFragment( + modifier = if(debuggable) Modifier.border(1.dp, Color.Blue) + else Modifier + .fillMaxWidth() + //.padding(horizontal = 4.dp), + .padding(vertical = 2.dp, horizontal = 4.dp), + post = item.post, + onItemClicked = onItemClicked, + onProfileClicked = { onItemClicked.onProfileClicked(it) }, + elevate = true, + onUnClicked = onUnClicked, + onRepostClicked = onRepostClicked, + onReplyClicked = onReplyClicked, + onMenuClicked = onMenuClicked, + onLikeClicked = onLikeClicked, + getContentHandling = getContentHandling, + ) + } + is MorphoDataItem.FeedInfo -> { + FeedListEntryFragment( + feed = item.feed, + onFeedClicked = { + + }, + likeClicked = { _ , _ -> }, + saveFeedClicked = { _, _ -> }, + hasFeedSaved = false, + ) - else -> {} + } + is MorphoDataItem.ListInfo -> { + UserListEntryFragment( + list = item.list, + onListClicked = { }, + hasListPinned = false, + muteListClicked = { _, _ -> }, + blockListClicked = { _, _ -> }, + ) + } + is MorphoDataItem.ModLabel -> { + ContentLabelSelector( + labelItem = item, + onSelected = onLabelChoiceSelected + ) + } + is MorphoDataItem.ProfileItem -> { + CompactProfileFragment( + profile = item.profile, + onProfileClicked = { onItemClicked.onProfileClicked(it) }, + onItemClicked = onItemClicked, + ) + } + + else -> { + PlaceholderSkylineItem( + modifier = if(debuggable) Modifier.border(1.dp, Color.Black) + else Modifier + .fillMaxWidth() + //.padding(horizontal = 4.dp), + .padding(vertical = 2.dp, horizontal = 4.dp), + elevate = true, + ) + } + } + } } } } @@ -247,7 +326,7 @@ fun SkylineFragment ( OutlinedIconButton( onClick = { scope.launch { - refreshPull() + pager.refresh() if (scrolledDownLots) { listState.scrollToItem(0) } else { diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineThreadFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineThreadFragment.kt index c5ef40e..a3a89b0 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineThreadFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineThreadFragment.kt @@ -1,5 +1,6 @@ package com.morpho.app.ui.common +import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.NavigateNext @@ -8,50 +9,60 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEachIndexed +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import com.atproto.repo.StrongRef import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.BskyPostThread import com.morpho.app.model.bluesky.ThreadPost -import com.morpho.app.model.uidata.ContentHandling import com.morpho.app.ui.elements.MenuOptions import com.morpho.app.ui.post.PostFragment import com.morpho.app.ui.post.PostFragmentRole import com.morpho.app.ui.thread.ThreadItem import com.morpho.app.ui.thread.ThreadTree +import com.morpho.app.ui.utils.ItemClicked +import com.morpho.app.ui.utils.OnItemClicked import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri +import com.morpho.butterfly.ContentHandling import com.morpho.butterfly.model.RecordType @Composable inline fun SkylineThreadFragment( thread: BskyPostThread, modifier: Modifier = Modifier, - crossinline onItemClicked: OnPostClicked = {}, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), crossinline onProfileClicked: (AtIdentifier) -> Unit = {}, crossinline onReplyClicked: (BskyPost) -> Unit = { }, crossinline onRepostClicked: (BskyPost) -> Unit = { }, crossinline onLikeClicked: (StrongRef) -> Unit = { }, noinline onMenuClicked: (MenuOptions, BskyPost) -> Unit = { _, _ -> }, crossinline onUnClicked: (type: RecordType, uri: AtUri) -> Unit = { _, _ -> }, - crossinline getContentHandling: (BskyPost) -> List = { listOf() } + crossinline getContentHandling: (BskyPost) -> List = { listOf() }, + debuggable: Boolean = false, ) { - val threadPost = remember { ThreadPost.ViewablePost(thread.post, thread.replies) } + val threadPost = remember { ThreadPost.ViewablePost(thread.post, null, thread.replies) } val hasReplies = rememberSaveable { threadPost.replies.isNotEmpty() } var showReplies by remember { mutableStateOf(threadPost.replies.size <= 2)} var showFullThread by remember { mutableStateOf(thread.parents.size <= 3)} + val parents = remember { thread.parents.distinctBy { it.uri } } Surface( tonalElevation = if (hasReplies) 1.dp else 0.dp, shape = MaterialTheme.shapes.extraSmall, - modifier = if (hasReplies) Modifier.padding(2.dp) else Modifier.fillMaxWidth() + modifier = if (hasReplies) modifier.padding(2.dp) else modifier.fillMaxWidth() ) { - Column( - ) { - if (thread.parents.isNotEmpty()) { - when (val root = thread.parents[0]) { + Column { + if (parents.isNotEmpty()) { + when (val root = parents[0]) { is ThreadPost.ViewablePost -> if (root.post.uri == thread.post.uri) { Surface( tonalElevation = 1.dp, @@ -62,10 +73,10 @@ inline fun SkylineThreadFragment( ) { PostFragment( post = root.post, - role = PostFragmentRole.ThreadBranchStart, + role = PostFragmentRole.Solo, elevate = true, - modifier = Modifier, - onItemClicked = {onItemClicked(it) }, + modifier = if(debuggable) Modifier.border(1.dp, Color.Cyan) else Modifier, + onItemClicked = onItemClicked, onProfileClicked = { onProfileClicked(it) }, onUnClicked = { type,uri-> onUnClicked(type,uri) }, onRepostClicked = { onRepostClicked(it) }, @@ -86,9 +97,11 @@ inline fun SkylineThreadFragment( modifier = Modifier .padding(4.dp), ) { - if(thread.parents.size > 3) { + if(parents.size > 3) { ThreadItem( item = thread.parents[0], + modifier = if(debuggable) Modifier.border(1.dp, Color.Green) + else Modifier.padding(vertical = 2.dp), role = PostFragmentRole.ThreadBranchStart, indentLevel = 1, elevate = true, @@ -132,7 +145,7 @@ inline fun SkylineThreadFragment( if (showFullThread) { - thread.parents.fastForEachIndexed { index, post -> + parents.fastForEachIndexed { index, post -> val reason = remember { when (post) { is ThreadPost.BlockedPost -> null @@ -144,15 +157,23 @@ inline fun SkylineThreadFragment( } val role = remember { when (index) { - thread.parents.lastIndex -> PostFragmentRole.ThreadBranchEnd - 0 -> PostFragmentRole.ThreadBranchStart + 0 -> PostFragmentRole.Solo + 1 -> PostFragmentRole.ThreadBranchStart + parents.lastIndex -> PostFragmentRole.ThreadBranchEnd else -> PostFragmentRole.ThreadBranchMiddle } } - if (post is ThreadPost.ViewablePost && (index < thread.parents.lastIndex) && (index != 0)) { + if ( + post is ThreadPost.ViewablePost + && post.uri != threadPost.uri + && (index > 0 || parents.lastIndex < 2) + && index < parents.lastIndex + ) { ThreadItem( item = post, role = role, + modifier = if(debuggable) Modifier.border(1.dp, Color.White) + else Modifier.padding(vertical = 2.dp), indentLevel = 1, reason = reason, elevate = true, @@ -168,22 +189,28 @@ inline fun SkylineThreadFragment( } } } - ThreadItem( - item = thread.parents[thread.parents.lastIndex], - role = PostFragmentRole.ThreadBranchEnd, - indentLevel = 1, - elevate = true, - onItemClicked = onItemClicked, - onProfileClicked = onProfileClicked, - onUnClicked = onUnClicked, - onRepostClicked = onRepostClicked, - onReplyClicked = onReplyClicked, - onLikeClicked = onLikeClicked, - onMenuClicked = onMenuClicked, - getContentHandling = getContentHandling - ) + if (parents[parents.lastIndex] is ThreadPost.ViewablePost) { + ThreadItem( + item = parents[parents.lastIndex], + role = PostFragmentRole.ThreadBranchEnd, + indentLevel = 1, + modifier = if (debuggable) Modifier.border( + 1.dp, + Color.Yellow + ) else Modifier.padding(vertical = 2.dp), + elevate = true, + onItemClicked = onItemClicked, + onProfileClicked = onProfileClicked, + onUnClicked = onUnClicked, + onRepostClicked = onRepostClicked, + onReplyClicked = onReplyClicked, + onLikeClicked = onLikeClicked, + onMenuClicked = onMenuClicked, + getContentHandling = getContentHandling + ) + } } else { - thread.parents.fastForEachIndexed { index, post -> + parents.fastForEachIndexed { index, post -> val reason = remember { when (post) { is ThreadPost.BlockedPost -> null @@ -195,15 +222,19 @@ inline fun SkylineThreadFragment( } val role = remember { when (index) { - thread.parents.lastIndex -> PostFragmentRole.ThreadBranchEnd - 0 -> PostFragmentRole.ThreadBranchStart + 0 -> PostFragmentRole.ThreadRootUnfocused + parents.lastIndex -> PostFragmentRole.ThreadBranchEnd else -> PostFragmentRole.ThreadBranchMiddle } } - if (post is ThreadPost.ViewablePost) { + if (post is ThreadPost.ViewablePost + && post.uri != threadPost.uri + ) { ThreadItem( item = post, role = role, + modifier = if(debuggable) Modifier.border(1.dp, Color.Red) + else Modifier.padding(vertical = 2.dp), indentLevel = 1, reason = reason, elevate = true, @@ -221,19 +252,19 @@ inline fun SkylineThreadFragment( } val role = remember { - when (thread.parents.size) { + when (parents.size) { 0 -> PostFragmentRole.Solo - 1 -> PostFragmentRole.ThreadEnd + 1 -> PostFragmentRole.Solo else -> PostFragmentRole.Solo } } ThreadItem( item = threadPost, role = role, - reason = thread.post.reason, + reason = null, elevate = true, - modifier = Modifier - .padding(4.dp), + modifier = if(debuggable) Modifier.border(1.dp, Color.Magenta) + else Modifier.padding(vertical = 2.dp), onItemClicked = onItemClicked, onProfileClicked = onProfileClicked, onUnClicked = onUnClicked, @@ -252,7 +283,7 @@ inline fun SkylineThreadFragment( } } else { val role = remember { - when (thread.parents.size) { + when (parents.size) { 0 -> PostFragmentRole.Solo 1 -> PostFragmentRole.ThreadEnd else -> PostFragmentRole.Solo @@ -261,10 +292,10 @@ inline fun SkylineThreadFragment( ThreadItem( item = threadPost, role = role, - reason = thread.post.reason, + reason = null, elevate = true, - modifier = Modifier - .padding(4.dp), + modifier = if(debuggable) Modifier.border(1.dp, Color.Blue) else Modifier + .padding(top = 4.dp).padding(horizontal = 4.dp), onItemClicked = onItemClicked, onProfileClicked = onProfileClicked, onUnClicked = onUnClicked, @@ -278,6 +309,7 @@ inline fun SkylineThreadFragment( if (hasReplies) { Surface( + modifier = if(debuggable) Modifier.border(1.dp, Color.Black) else Modifier, tonalElevation = 1.dp, //border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondary), shape = MaterialTheme.shapes.extraSmall @@ -285,7 +317,7 @@ inline fun SkylineThreadFragment( Column( modifier = modifier - .padding(4.dp), + //.padding(4.dp), ) { if(threadPost.replies.size > 2) { TextButton( @@ -315,11 +347,11 @@ inline fun SkylineThreadFragment( val replies = remember {threadPost.replies.filterIsInstance()} replies.fastForEach { post: ThreadPost -> if (post is ThreadPost.ViewablePost) { - if (post.replies.isNotEmpty()) { + if (post.replies.isNotEmpty() && replies.size > 1) { ThreadTree( reply = post, indentLevel = 1, - modifier = Modifier.padding(4.dp), - onItemClicked = {onItemClicked(it) }, + modifier = Modifier.padding(start = 4.dp, end = 1.dp, bottom = 2.dp), + onItemClicked = onItemClicked, onProfileClicked = { onProfileClicked(it) }, onUnClicked = { type,uri-> onUnClicked(type,uri) }, onRepostClicked = { onRepostClicked(it) }, @@ -331,9 +363,9 @@ inline fun SkylineThreadFragment( } else { ThreadItem( item = post, - role = PostFragmentRole.ThreadRootUnfocused, + role = PostFragmentRole.ThreadEnd, indentLevel = 1, - modifier = Modifier.padding(4.dp), + modifier = Modifier.padding(start = 4.dp, end = 1.dp, bottom = 2.dp), onItemClicked = onItemClicked, onProfileClicked = onProfileClicked, onUnClicked = onUnClicked, diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineTopAppBar.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineTopAppBar.kt index ebfe567..51f77c5 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineTopAppBar.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/SkylineTopAppBar.kt @@ -71,13 +71,13 @@ fun SkylineTopBar( selectedTabIndex = selectedTab, modifier = modifier.offset(y = (-8).dp, x = 4.dp ), edgePadding = 10.dp, - indicator = { tabPositions -> - if(tabPositions.isNotEmpty()) { - TabRowDefaults.SecondaryIndicator( - Modifier.tabIndicatorOffset(tabPositions[max(0, min(selectedTab, tabList.lastIndex))]) - ) - } - }, +// indicator = { tabPositions -> +// if(tabPositions.isNotEmpty()) { +// TabRowDefaults.SecondaryIndicator( +// Modifier.tabIndicatorOffset(tabPositions[max(0, min(selectedTab, tabList.lastIndex))]) +// ) +// } +// }, ) { tabList.forEachIndexed { index, tab -> Tab(selected = selectedTab == index, diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.kt index 90a7675..9849503 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.kt @@ -1,27 +1,85 @@ package com.morpho.app.ui.common +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.unit.IntOffset +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi +import cafe.adriel.voyager.core.stack.StackEvent +import cafe.adriel.voyager.navigator.CurrentScreen +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.transitions.ScreenTransition +import cafe.adriel.voyager.transitions.ScreenTransitionContent +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.uidata.Event +import com.morpho.app.model.uistate.ContentCardState @Composable -expect fun TabbedScreenScaffold( +expect fun TabbedScreenScaffold( navBar: @Composable () -> Unit, - content: @Composable (PaddingValues) -> Unit, + content: @Composable (PaddingValues, T?) -> Unit, topContent: @Composable () -> Unit, - modifier: Modifier = Modifier, + state: T?, + modifier: Modifier, + drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + profile: DetailedProfile? = null, ) @ExperimentalMaterial3Api @Composable -expect fun TabbedProfileScreenScaffold( +expect fun TabbedProfileScreenScaffold( navBar: @Composable () -> Unit, - content: @Composable (PaddingValues) -> Unit, + content: @Composable (PaddingValues,ContentCardState?) -> Unit, topContent: @Composable (TopAppBarScrollBehavior) -> Unit, + state: ContentCardState?, modifier: Modifier = Modifier, scrollBehavior: TopAppBarScrollBehavior, nestedScrollConnection: NestedScrollConnection = scrollBehavior.nestedScrollConnection, -) \ No newline at end of file + drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed), + profile: DetailedProfile? = null, +) + +@OptIn(ExperimentalVoyagerApi::class) +@Composable +fun SlideTabTransition( + navigator: Navigator, + modifier: Modifier = Modifier, + animationSpec: FiniteAnimationSpec = spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold + ), + content: ScreenTransitionContent = { + CurrentScreen() + } +) { + ScreenTransition( + navigator = navigator, + modifier = modifier, + content = content, + disposeScreenAfterTransitionEnd = true, + transition = { + val (initialOffset, targetOffset) = when (navigator.lastEvent) { + StackEvent.Pop -> ({ size: Int -> -size }) to ({ size: Int -> size }) + StackEvent.Replace -> ({ size: Int -> -size }) to ({ size: Int -> size }) + else -> ({ size: Int -> size }) to ({ size: Int -> -size }) + } + + slideInHorizontally(animationSpec, initialOffset) togetherWith + slideOutHorizontally(animationSpec, targetOffset) + + } + ) +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/TabbedSkylineFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/TabbedSkylineFragment.kt index 410b87f..485abc6 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/TabbedSkylineFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/common/TabbedSkylineFragment.kt @@ -1,42 +1,59 @@ package com.morpho.app.ui.common import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.model.screenModelScope +import app.cash.paging.compose.LazyPagingItems import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.TabNavigator import com.atproto.repo.StrongRef +import com.morpho.app.data.MorphoAgent import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.DraftPost import com.morpho.app.model.bluesky.MorphoDataItem -import com.morpho.app.model.uidata.AtCursor -import com.morpho.app.model.uistate.ContentCardState +import com.morpho.app.model.uidata.Event +import com.morpho.app.model.uidata.UIUpdate import com.morpho.app.screens.base.tabbed.ProfileTab import com.morpho.app.screens.base.tabbed.ThreadTab -import com.morpho.app.screens.main.MainScreenModel import com.morpho.app.ui.elements.doMenuOperation +import com.morpho.app.ui.utils.ItemClicked import com.morpho.app.util.ClipboardManager -import com.morpho.butterfly.model.RecordUnion +import com.morpho.butterfly.ContentHandling import io.ktor.util.reflect.instanceOf +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import org.koin.compose.getKoin @OptIn(ExperimentalMaterial3Api::class) @Composable -fun > TabbedSkylineFragment( - sm: T, - state: StateFlow?, +fun TabbedSkylineFragment( paddingValues: PaddingValues = PaddingValues(0.dp), - refresh: (AtCursor) -> Unit = { }, - isProfileFeed: Boolean = false + isProfileFeed: Boolean = false, + uiUpdate: StateFlow, + eventCallback: (Event) -> Unit = {}, + getContentHandling: (BskyPost) -> List = { listOf() }, + pager: LazyPagingItems, + listState: LazyListState = rememberLazyListState(), + scope: CoroutineScope = rememberCoroutineScope(), + agent: MorphoAgent = getKoin().get(), ) { + val uiState = uiUpdate.collectAsState(initial = UIUpdate.Empty) val navigator = if (LocalNavigator.current?.parent?.instanceOf(TabNavigator::class) == true) { LocalNavigator.currentOrThrow } else LocalNavigator.currentOrThrow.parent!! @@ -45,6 +62,7 @@ fun > TabbedSkylin var showComposer by remember { mutableStateOf(false) } var composerRole by remember { mutableStateOf(ComposerRole.StandalonePost) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val uriHandler = LocalUriHandler.current // Probably pull this farther up, // but this means if you don't explicitly cancel you don't lose the post var draft by remember { mutableStateOf(DraftPost()) } @@ -69,26 +87,35 @@ fun > TabbedSkylin showComposer = true } } - val content = state?.collectAsState() - if(content?.value != null) { - val clipboard = getKoin().get() + val clipboard = getKoin().get() + if(uiState.value !is UIUpdate.Empty) { SkylineFragment( - content = state, - onProfileClicked = { - actor -> //if (isProfileFeed) navigator.popUntilRoot() - navigator.push(ProfileTab(actor)) - }, - onItemClicked = { uri -> navigator.push(ThreadTab(uri)) }, - refresh = { cursor -> refresh(cursor)}, - onUnClicked = { type, rkey -> sm.deleteRecord(type, rkey) }, + onItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = navigator, + profileCallback = { actor -> + scope.launch { + val did = agent.resolveHandle(actor).getOrNull() + if(did != null) navigator.push(ProfileTab(did)) + } + }, + ), + onUnClicked = { type, rkey -> agent.deleteRecord(type, rkey) }, onRepostClicked = { onRepostClicked(it) }, - onMenuClicked = { option, post -> doMenuOperation(option, post, clipboardManager = clipboard) }, + onMenuClicked = { option, post -> + doMenuOperation(option, post, + clipboardManager = clipboard, + uriHandler = uriHandler + ) }, onReplyClicked = { onReplyClicked(it) }, - onLikeClicked = { uri -> sm.createRecord(RecordUnion.Like(uri)) }, + onLikeClicked = { ref -> agent.like(ref) }, onPostButtonClicked = { onPostButtonClicked() }, - getContentHandling = { post -> sm.labelService.getContentHandlingForPost(post)}, + getContentHandling = { post -> getContentHandling(post) }, contentPadding = paddingValues, isProfileFeed = isProfileFeed, + pager = pager, + listState = listState, + scope = scope, ) if(repostClicked) { RepostQueryDialog( @@ -98,14 +125,10 @@ fun > TabbedSkylin }, onRepost = { repostClicked = false - composerRole = ComposerRole.QuotePost - initialContent?.let { post -> - RecordUnion.Repost( - StrongRef(post.uri, post.cid) - ) - }?.let { sm.api.createRecord(it) } + initialContent?.let { agent.repost(StrongRef(it.uri, it.cid)) } }, onQuotePost = { + composerRole = ComposerRole.QuotePost showComposer = true repostClicked = false } @@ -124,18 +147,13 @@ fun > TabbedSkylin draft = DraftPost() }, onSend = { finishedDraft -> - sm.screenModelScope.launch(Dispatchers.IO) { - val post = finishedDraft.createPost(sm.api) - sm.api.createRecord(RecordUnion.MakePost(post)) - } + scope.launch(Dispatchers.IO) { agent.post(finishedDraft.createPost(agent)) } showComposer = false }, onUpdate = { draft = it } ) } - } else { - LoadingCircle() - } + } else LoadingCircle() } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/ContentHider.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/ContentHider.kt index 3994433..4253523 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/ContentHider.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/ContentHider.kt @@ -1,78 +1,112 @@ package com.morpho.app.ui.elements -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastFilter -import com.morpho.app.model.bluesky.LabelAction -import com.morpho.app.model.bluesky.LabelScope -import com.morpho.app.model.uidata.ContentHandling +import com.atproto.label.Blurs +import com.morpho.butterfly.ContentHandling +import com.morpho.butterfly.LabelAction @Composable public fun ContentHider( reasons: List = listOf(), - scope: LabelScope, + scope: Blurs, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { val scopedBehaviours = reasons.filter { it.scope == scope } - val toHide = scopedBehaviours.fastFilter { it.action == LabelAction.Blur || it.action == LabelAction.Alert } + val toHide = scopedBehaviours + .fastFilter { it.action == LabelAction.Blur || it.action == LabelAction.Alert } var hideContent by remember { mutableStateOf( toHide.isNotEmpty() ) } + val reason = toHide.firstOrNull() - if (toHide.isNotEmpty()) { - TextButton( - onClick = { hideContent = !hideContent }, - modifier = Modifier.fillMaxWidth(), - shape = ButtonDefaults.textShape, - colors = ButtonDefaults.elevatedButtonColors(), - elevation = ButtonDefaults.filledTonalButtonElevation() - ) { - Icon( - imageVector = reason?.icon ?: Icons.Default.Info, - contentDescription = reason?.source?.description - ) - DisableSelection { - Text( - text = reason?.source?.name ?: "", - modifier = Modifier.padding(horizontal = 4.dp) - ) - } + val degrees by animateFloatAsState(if (!hideContent) -90f else 90f) + Column { + if (toHide.isNotEmpty()) { + Row(modifier = if (hideContent) Modifier.clip(MaterialTheme.shapes.small) + .clickable { hideContent = !hideContent }.fillMaxWidth().padding(12.dp) + else Modifier + .clip(MaterialTheme.shapes.small.copy(bottomEnd = CornerSize(0.dp), bottomStart = CornerSize(0.dp))) + .clickable { hideContent = !hideContent }.fillMaxWidth().padding(12.dp) + , horizontalArrangement = Arrangement.SpaceBetween) { + Row(verticalAlignment = Alignment.CenterVertically) { + + if(reason?.icon?.labelerAvatar!= null) { + OutlinedAvatar( + url = reason.icon.labelerAvatar!!, + contentDescription = reason.source.description, + modifier = Modifier.size(20.dp), + avatarShape = AvatarShape.Circle, + outlineColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) - Spacer( - modifier = Modifier - .width(1.dp) - .weight(0.3f) - ) - DisableSelection { - Text( - text = if (hideContent) { - "Show" } else { - "Hide" + Icon( + imageVector = reason?.icon?.icon?: Icons.Default.Info, + contentDescription = reason?.source?.description, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + Spacer(modifier = Modifier.width(8.dp)) + Text(reason?.source?.name ?: "", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + DisableSelection { + Image( + Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.rotate(degrees), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant) + ) + + } + } + } + AnimatedVisibility( + visible = !hideContent, + enter = expandVertically( + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntSize.VisibilityThreshold ) + ), + exit = shrinkVertically() + ) { + Column(modifier = Modifier.fillMaxWidth()) { + content() } - } } - if (!hideContent) { - content() - } } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/HighlightIndication.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/HighlightIndication.kt new file mode 100644 index 0000000..01d5e9a --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/HighlightIndication.kt @@ -0,0 +1 @@ +package com.morpho.app.ui.elements diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/OutlinedAvatar.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/OutlinedAvatar.kt index 76d65f3..ae4f156 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/OutlinedAvatar.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/OutlinedAvatar.kt @@ -16,11 +16,14 @@ package com.morpho.app.ui.elements * limitations under the License. */ +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind @@ -28,6 +31,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp @@ -62,6 +66,7 @@ fun OutlinedAvatar( contentDescription: String = "", avatarShape: AvatarShape = AvatarShape.Corner, onClicked: (() -> Unit)? = null, + placeholder: Painter = painterResource(Res.drawable.placeholder_pfp), size: Dp = 30.dp, ) { @@ -70,6 +75,8 @@ fun OutlinedAvatar( AvatarShape.Rounded -> MaterialTheme.shapes.small AvatarShape.Corner -> roundedTopLBotR.small } + val interactionSource = remember { MutableInteractionSource() } + val indication = LocalIndication.current val pxSize = LocalDensity.current.run { (size-outlineSize).toPx()*2 }.toInt() val sB = when(avatarShape) { AvatarShape.Circle -> CircleShape.createOutline( @@ -83,7 +90,12 @@ fun OutlinedAvatar( LocalDensity.current) } val modClicked = if(onClicked != null) { - modifier.clickable { onClicked() } + modifier.clickable( + interactionSource = interactionSource, + indication = indication, + enabled = true, + onClick = { onClicked() } + ) } else modifier val mod = if(outlineSize > 0.dp) { modClicked.size(size).clip(s) @@ -109,8 +121,8 @@ fun OutlinedAvatar( .build(), contentDescription = contentDescription, contentScale = ContentScale.Crop, - fallback = painterResource(Res.drawable.placeholder_pfp), - placeholder = painterResource(Res.drawable.placeholder_pfp), + fallback = placeholder, + placeholder = placeholder, filterQuality = FilterQuality.High, modifier = mod ) diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/OverFlowMenu.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/OverFlowMenu.kt index 8cc5e7b..7059ec5 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/OverFlowMenu.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/OverFlowMenu.kt @@ -5,6 +5,8 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.platform.UriHandler import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.ui.common.sharePost import com.morpho.app.util.ClipboardManager @@ -12,8 +14,10 @@ import com.morpho.app.util.json import com.morpho.app.util.openBrowser import com.morpho.butterfly.AtUri import com.morpho.butterfly.Language +import kotlinx.serialization.Serializable - +@Immutable +@Serializable enum class MenuOptions(val text: String) { Translate("Translate"), Share("Share"), @@ -42,10 +46,14 @@ inline fun doMenuOperation( reportCallback: (AtUri) -> Unit = {}, muteCallback: (AtUri) -> Unit = {}, clipboardManager: ClipboardManager, + uriHandler: UriHandler, ) { when(options) { MenuOptions.Translate -> run { - openBrowser("https://translate.google.com/?sl=auto&tl=${language}&text=${post.text}&op=translate") + openBrowser( + "https://translate.google.com/?sl=auto&tl=${language}&text=${post.text}&op=translate", + uriHandler + ) } MenuOptions.Share -> { sharePost(post) } MenuOptions.MuteThread -> { diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/RichText.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/RichText.kt index eb6ce34..2a9e33b 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/RichText.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/RichText.kt @@ -5,12 +5,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle @@ -28,6 +29,8 @@ import coil3.request.crossfade import com.morpho.app.model.bluesky.BskyFacet import com.morpho.app.model.bluesky.FacetType import com.morpho.app.model.bluesky.RichTextFormat.* +import com.morpho.app.util.BlueskyText +import com.morpho.app.util.makeBlueskyText import com.morpho.app.util.utf16FacetIndex import kotlinx.collections.immutable.persistentListOf import okio.ByteString.Companion.encodeUtf8 @@ -38,16 +41,25 @@ fun RichTextElement( text: String, modifier: Modifier = Modifier, facets: List = persistentListOf(), - onClick: (List) -> Unit = {}, maxLines: Int = 20, - - ) { - val utf8Text = text.encodeUtf8() - val splitText = text.split("◌").listIterator() // special BlueMoji character + onClick: (List) -> Unit = {}, +) { + val layoutResult = remember { mutableStateOf(null) } + val bskyText = if(facets.isEmpty()) { + makeBlueskyText(text) + } else BlueskyText(text, facets) + val utf8Text = bskyText.text.encodeUtf8() + val splitText = bskyText.text.split("◌").listIterator() // special BlueMoji character val formattedText = buildAnnotatedString { pushStyle(SpanStyle(MaterialTheme.colorScheme.onSurface)) + pushStyle(SpanStyle( + fontStyle = MaterialTheme.typography.bodyMedium.fontStyle, + fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,//FontWeight(275), + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + )) append(splitText.next()) - facets.fastForEach { facet -> + + bskyText.facets.fastForEach { facet -> val bounds = text.utf16FacetIndex(utf8Text, facet.start, facet.end) val start = bounds.first val end = bounds.second @@ -56,7 +68,7 @@ fun RichTextElement( is FacetType.ExternalLink -> { addStringAnnotation(tag = "Link", facetType.uri.uri, start, end) addStyle( - style = SpanStyle(MaterialTheme.colorScheme.tertiary), + style = SpanStyle(MaterialTheme.colorScheme.tertiary, fontWeight = FontWeight.Normal), start = start, end = end ) @@ -64,7 +76,7 @@ fun RichTextElement( is FacetType.PollBlueOption -> { addStringAnnotation(tag = "PollBlue", facetType.number.toString(), start, end) addStyle( - style = SpanStyle(MaterialTheme.colorScheme.tertiary), + style = SpanStyle(MaterialTheme.colorScheme.tertiary, fontWeight = FontWeight.Normal), start = start, end = end ) @@ -73,7 +85,7 @@ fun RichTextElement( is FacetType.Tag -> { addStringAnnotation(tag = "Tag", facetType.tag, start, end) addStyle( - style = SpanStyle(MaterialTheme.colorScheme.tertiary), + style = SpanStyle(MaterialTheme.colorScheme.tertiary, fontWeight = FontWeight.Normal), start = start, end = end ) @@ -81,7 +93,7 @@ fun RichTextElement( is FacetType.UserDidMention -> { addStringAnnotation(tag = "Mention", facetType.did.did, start, end) addStyle( - style = SpanStyle(MaterialTheme.colorScheme.tertiary), + style = SpanStyle(MaterialTheme.colorScheme.tertiary, fontWeight = FontWeight.Normal), start = start, end = end ) @@ -89,7 +101,7 @@ fun RichTextElement( is FacetType.UserHandleMention -> { addStringAnnotation(tag = "Mention", facetType.handle.handle, start, end) addStyle( - style = SpanStyle(MaterialTheme.colorScheme.tertiary), + style = SpanStyle(MaterialTheme.colorScheme.tertiary, fontWeight = FontWeight.Normal), start = start, end = end ) @@ -151,7 +163,7 @@ fun RichTextElement( else -> { Pair("", InlineTextContent( - Placeholder(1.sp, 1.sp, PlaceholderVerticalAlign.TextCenter) + Placeholder(0.sp, 0.sp, PlaceholderVerticalAlign.TextCenter) ){}) } } @@ -160,30 +172,40 @@ fun RichTextElement( }.flatten().filter { it.first.isNotEmpty() }.toMap() } - SelectionContainer( - modifier = Modifier.padding(vertical = 6.dp, horizontal = 2.dp) - ) { - val layoutResult = remember { mutableStateOf(null) } - val pressIndicator = Modifier.pointerInput(onClick) { - detectTapGestures { pos -> - layoutResult.value?.let { layoutResult -> - val offset = layoutResult.getOffsetForPosition(pos) - facets.forEach { - if (it.start <= offset && offset <= it.end) { - return@detectTapGestures onClick(it.facetType) - } + + + val pressIndicator = Modifier + .pointerHoverIcon(PointerIcon.Hand) + .pointerInput(onClick) { + + detectTapGestures( + onLongPress = { + + } + ) { pos -> + layoutResult.value?.let { layoutResult -> + val offset = layoutResult.getOffsetForPosition(pos) + facets.forEach { + val extents = formattedText.text.utf16FacetIndex(it.start, it.end) + val start = extents.first + val end = extents.second + if (offset in start..end) { + return@detectTapGestures onClick(it.facetType) } - onClick(listOf()) } + onClick(listOf()) } } - BasicText( - text = formattedText, - inlineContent = inlineContentMap, - maxLines = maxLines, // Sorry @retr0.id, no more 200 line posts. - overflow = TextOverflow.Ellipsis, - modifier = modifier.then(pressIndicator), - ) } + BasicText( + text = formattedText, + inlineContent = inlineContentMap, + maxLines = maxLines, // Sorry @retr0.id, no more 200 line posts. + overflow = TextOverflow.Ellipsis, + onTextLayout = { layoutResult.value = it }, + modifier = modifier.then(pressIndicator) + .padding(vertical = 6.dp, horizontal = 2.dp), + ) + } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/SettingsItems.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/SettingsItems.kt new file mode 100644 index 0000000..9934e86 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/SettingsItems.kt @@ -0,0 +1,139 @@ +package com.morpho.app.ui.elements + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + + +@Composable +fun SettingsGroup( + title: String, + modifier: Modifier = Modifier, + distinguish: Boolean = true, + content: @Composable ColumnScope.() -> Unit +) { + ElevatedCard( + colors = if (distinguish) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) + ) + } else { + CardDefaults.cardColors(containerColor = Color.Transparent) + }, + elevation = if (distinguish) CardDefaults.elevatedCardElevation(4.dp) + else CardDefaults.elevatedCardElevation(0.dp) , + modifier = modifier, + shape = if(distinguish) MaterialTheme.shapes.small else RectangleShape, + ) { + if(title.isNotBlank()) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(12.dp) + .align(Alignment.Start) + ) + HorizontalDivider(Modifier.padding(bottom = 4.dp)) + } + content() + } +} + + + +@Composable +fun SettingsItem( + text: AnnotatedString? = null, + description: AnnotatedString? = null, + modifier: Modifier = Modifier, + spacing: Dp = 0.dp, + content: @Composable (Modifier) -> Unit, +){ + Surface( + modifier = modifier.fillMaxWidth().padding(vertical = spacing), + shape = if(spacing > 0.dp) MaterialTheme.shapes.small else RectangleShape, + color = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + tonalElevation = 2.dp, + ) { + if(text != null && description == null) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + modifier = Modifier.padding(horizontal = 12.dp) + .padding(end = 12.dp) + ) { + Text( + text = text, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(12.dp) + .align(Alignment.Start) + ) + content(Modifier.padding(start = 12.dp, end = 12.dp)) + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.padding(horizontal = 12.dp).fillMaxWidth() + ) { + if(description != null && text != null) { + content(Modifier.padding(horizontal = 12.dp)) + VerticalDivider(Modifier.height(40.dp)) + Column( + modifier = Modifier + .padding(end = 12.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(top = 12.dp, start = 12.dp, end = 12.dp) + .align(Alignment.Start) + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(12.dp) + .align(Alignment.Start) + ) + } + + } else { + content(Modifier.padding(horizontal = 12.dp)) + Text( + text = description?: AnnotatedString(""), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(12.dp) + ) + } + + } + } + } + +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/Wrappers.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/Wrappers.kt index 8abe281..f7bf6a4 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/Wrappers.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/elements/Wrappers.kt @@ -15,8 +15,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable -fun WrappedColumn(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { - Column(modifier = modifier, content = content) +fun WrappedColumn( + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + verticalArrangement: Arrangement.Vertical = Arrangement.SpaceEvenly, + content: @Composable ColumnScope.() -> Unit +) { + Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement, + modifier = modifier, + content = content) } @Composable diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/lists/FeedListEntryFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/lists/FeedListEntryFragment.kt index 69e98a3..a7669ed 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/lists/FeedListEntryFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/lists/FeedListEntryFragment.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -38,6 +39,7 @@ fun FeedListEntryFragment( var saved by remember { mutableStateOf(hasFeedSaved) } var liked by remember { mutableStateOf(feed.likedByMe) } var numLikes by remember { mutableStateOf(feed.likeCount)} + val uriHandler = LocalUriHandler.current Surface ( shadowElevation = 1.dp, tonalElevation = 4.dp, @@ -138,7 +140,9 @@ fun FeedListEntryFragment( } facetTypes.fastForEach { facetType -> when (facetType) { - is FacetType.ExternalLink -> { openBrowser(facetType.uri.uri) } + is FacetType.ExternalLink -> { + openBrowser(facetType.uri.uri, uriHandler) + } is FacetType.Format -> { } is FacetType.PollBlueOption -> {} is FacetType.Tag -> { } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/lists/UserListEntryFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/lists/UserListEntryFragment.kt new file mode 100644 index 0000000..21f0adb --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/lists/UserListEntryFragment.kt @@ -0,0 +1,214 @@ +package com.morpho.app.ui.lists + + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.RssFeed +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import app.bsky.graph.ListType +import com.atproto.repo.StrongRef +import com.morpho.app.model.bluesky.BskyList +import com.morpho.app.model.bluesky.FacetType +import com.morpho.app.model.bluesky.UserList +import com.morpho.app.model.bluesky.UserListBasic +import com.morpho.app.ui.elements.OutlinedAvatar +import com.morpho.app.ui.elements.RichTextElement +import com.morpho.app.util.openBrowser +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun UserListEntryFragment( + list: BskyList, + modifier: Modifier = Modifier, + hasListPinned: Boolean = false, + muteListClicked: (StrongRef, Boolean) -> Unit = {_,_->}, + blockListClicked: (StrongRef, Boolean) -> Unit = {_,_->}, + pinListClicked: (StrongRef, Boolean) -> Unit = {_,_->}, + onListClicked: (BskyList) -> Unit = {}, +) { + var pinned by remember { mutableStateOf(hasListPinned) } + var muted by remember { mutableStateOf(list.viewerMuted) } + var blocked by remember { mutableStateOf(list.viewerBlocked != null)} + val uriHandler = LocalUriHandler.current + Surface ( + shadowElevation = 1.dp, + tonalElevation = 4.dp, + shape = MaterialTheme.shapes.small, + modifier = modifier + .fillMaxWidth() + + ) { + Column( + Modifier + .fillMaxWidth() + .clickable { onListClicked(list) } + .padding(bottom = 4.dp) + .padding(start = 0.dp, end = 6.dp) + ) { + Row( + modifier = Modifier + .padding(end = 4.dp), + horizontalArrangement = Arrangement.End + + ) { + if(list.avatar != null) { + OutlinedAvatar( + url = list.avatar.orEmpty(), + contentDescription = "Avatar for ${list.name}", + modifier = Modifier + .size(55.dp) + .align(Alignment.CenterVertically), + outlineColor = MaterialTheme.colorScheme.tertiary, + onClicked = { onListClicked(list) } + ) + } else { + Icon( + imageVector = Icons.Default.RssFeed, + contentDescription = "Avatar for ${list.name}", + modifier = Modifier + .size(55.dp) + .align(Alignment.CenterVertically), + tint = MaterialTheme.colorScheme.tertiary, + ) + } + SelectionContainer( + modifier = Modifier + //.padding(bottom = 12.dp + .align(Alignment.CenterVertically) + .padding(start = 16.dp, top = 4.dp) + .clickable { onListClicked(list) }, + ) { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize + .times(1.2f), + fontWeight = FontWeight.Medium + ) + ) { + append(when(list) { + is UserList -> list.creator.displayName.orEmpty() + is UserListBasic -> "" + }) + } + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = MaterialTheme.typography.labelLarge.fontSize + .times(1.0f) + ) + ) { + append(when(list) { + is UserList -> list.creator.handle.handle + is UserListBasic -> "" + }) + } + + }, + maxLines = 2, + style = MaterialTheme.typography.labelLarge, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .wrapContentWidth(Alignment.Start) + //.padding(bottom = 12.dp) + .alignByBaseline() + .align(Alignment.CenterVertically) + //.padding(start = 16.dp), + + ) + } + Spacer( + modifier = Modifier + .width(1.dp) + .weight(0.1F), + ) + if(list.purpose == ListType.CURATELIST) { + IconButton( + onClick = { + pinned = !pinned + pinListClicked(StrongRef(list.uri, list.cid), pinned) + }, + ) { + Icon( + imageVector = if (pinned) Icons.Default.DeleteOutline else Icons.Default.PushPin, + contentDescription = if(pinned) "Unpin from my feeds" else "Pin as a feed", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } else { + TextButton( + onClick = { + muted = !muted + muteListClicked(StrongRef(list.uri, list.cid), muted) + }, + ) { + Text( + text = if(muted) "Unmute" else "Mute", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + ) + } + TextButton( + onClick = { + blocked = !blocked + blockListClicked(StrongRef(list.uri, list.cid), blocked) + }, + ) { + Text( + text = if(blocked) "Unblock" else "Block", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + RichTextElement( + text = when(list) { + is UserList -> list.description.orEmpty() + is UserListBasic -> "" + }, + facets = when(list) { + is UserList -> list.descriptionFacets + is UserListBasic -> persistentListOf() + }, + onClick = { facetTypes -> + if (facetTypes.isEmpty()) { + onListClicked(list) + return@RichTextElement + } + facetTypes.fastForEach { facetType -> + when (facetType) { + is FacetType.ExternalLink -> { + openBrowser(facetType.uri.uri, uriHandler) + } + is FacetType.Format -> { } + is FacetType.PollBlueOption -> {} + is FacetType.Tag -> { } + is FacetType.UserDidMention -> { } + is FacetType.UserHandleMention -> { } + else -> {} + } + } + }, + modifier = Modifier.padding(horizontal = 6.dp) + ) + } + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/NotificationAvatarList.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/NotificationAvatarList.kt index fa4de6e..41aa9f6 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/NotificationAvatarList.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/NotificationAvatarList.kt @@ -1,7 +1,12 @@ package com.morpho.app.ui.notifications import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -9,7 +14,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.SpanStyle @@ -19,6 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.morpho.app.model.bluesky.NotificationsListItem +import com.morpho.app.ui.elements.AvatarShape import com.morpho.app.ui.elements.OutlinedAvatar import com.morpho.butterfly.Did import kotlin.math.min @@ -43,6 +53,7 @@ fun NotificationAvatarList( OutlinedAvatar( url = it.author.avatar.orEmpty(), onClicked = { onClicked(it.author.did) }, + avatarShape = AvatarShape.Rounded, modifier = Modifier.padding(4.dp) ) } @@ -50,6 +61,7 @@ fun NotificationAvatarList( OutlinedAvatar( url = item.notifications.first().author.avatar.orEmpty(), onClicked = { onClicked(item.notifications.first().author.did) }, + avatarShape = AvatarShape.Rounded, modifier = Modifier.padding(4.dp) ) } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/NotificationsElement.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/NotificationsElement.kt index 551ca83..d1beb20 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/NotificationsElement.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/NotificationsElement.kt @@ -1,73 +1,102 @@ package com.morpho.app.ui.notifications -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconToggleButton -import androidx.compose.runtime.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import com.atproto.repo.StrongRef import com.morpho.app.model.bluesky.BskyNotification import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.NotificationsListItem -import com.morpho.app.ui.common.OnPostClicked import com.morpho.app.ui.elements.MenuOptions import com.morpho.app.ui.post.PostFragment +import com.morpho.app.ui.utils.ItemClicked import com.morpho.app.util.getFormattedDateTimeSince import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri +import com.morpho.butterfly.Did import com.morpho.butterfly.model.RecordType -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch @Composable fun NotificationsElement( item: NotificationsListItem, showPost: Boolean = true, - getPost: suspend (AtUri) -> Flow, - onPostClicked: OnPostClicked, - onAvatarClicked: (AtIdentifier) -> Unit = {}, + getPost: suspend (AtUri) -> BskyPost?, + onItemClicked: ItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), + onAvatarClicked: (Did) -> Unit = {}, onReplyClicked: (BskyPost) -> Unit = { }, onRepostClicked: (BskyPost) -> Unit = { }, onLikeClicked: (StrongRef) -> Unit = { }, onMenuClicked: (MenuOptions, BskyPost) -> Unit = { _, _ -> }, onUnClicked: (type: RecordType, uri: AtUri) -> Unit = { _, _ -> }, readOnLoad: Boolean = false, - markRead: (AtUri) -> Unit = { } + markRead: (AtUri) -> Unit = { }, + resolveHandle: suspend (AtIdentifier) -> Did?, ) { var expand by remember { mutableStateOf(showPost) } var post: BskyPost? by remember { mutableStateOf(null) } val delta = remember { getFormattedDateTimeSince(item.notifications.first().indexedAt) } + val scope = rememberCoroutineScope() LaunchedEffect(expand) { @Suppress("REDUNDANT_ELSE_IN_WHEN") when (val notif = item.notifications.first()) { is BskyNotification.Like -> { - if(showPost) post = getPost(notif.subject.uri).firstOrNull() + if(showPost) post = getPost(notif.subject.uri) } is BskyNotification.Follow -> {} is BskyNotification.Post -> { post = notif.post - if(showPost) post = getPost(notif.uri).firstOrNull() + if(showPost) post = getPost(notif.uri) } is BskyNotification.Repost -> { - if(showPost) post = getPost(notif.subject.uri).firstOrNull() + if(showPost) post = getPost(notif.subject.uri) } is BskyNotification.Unknown -> { if (notif.reasonSubject != null && showPost) { - post = getPost(notif.reasonSubject!!).firstOrNull() + post = getPost(notif.reasonSubject!!) } } else -> {} } } + var unread by remember { mutableStateOf(item.notifications.any { !it.isRead }) } + val markAsRead: (AtUri) -> Unit = remember { { uri -> + markRead(uri) + unread = false + } } + remember { if (!readOnLoad) return@remember // We just mark the first notification as read, @@ -82,7 +111,12 @@ fun NotificationsElement( } } val number = remember { item.notifications.size } - Column { + Column( + modifier = if(unread) Modifier + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) + .clickable { markAsRead(item.notifications.first().uri) } + else Modifier.clickable { markAsRead(item.notifications.first().uri) } + ) { Row( ) { Column( @@ -102,7 +136,7 @@ fun NotificationsElement( checked = expand, onCheckedChange = { expand = it - markRead(item.notifications.first().uri) + markAsRead(item.notifications.first().uri) }, ) { if (expand) { @@ -132,36 +166,40 @@ fun NotificationsElement( NotificationText(reason = item.reason, number = number, name = firstName, delta = delta) if (expand && post != null) { // TODO: maybe do a more compact variant + PostFragment( post = post!!, elevate = true, - onItemClicked = { - if(!readOnLoad) markRead(item.notifications.first().uri) - onPostClicked(it) - }, + onItemClicked = onItemClicked.copy( + callbackAlways = { + if(!readOnLoad) markAsRead(item.notifications.first().uri) + } + ), onProfileClicked = { - if(!readOnLoad) markRead(item.notifications.first().uri) - onAvatarClicked(it) - }, + if(!readOnLoad) markAsRead(item.notifications.first().uri) + scope.launch { + resolveHandle(it)?.let { did -> onAvatarClicked(did) } + } + }, onUnClicked = { type, uri -> - if(!readOnLoad) markRead(item.notifications.first().uri) + if(!readOnLoad) markAsRead(item.notifications.first().uri) onUnClicked(type, uri) }, onRepostClicked = { onRepostClicked(it) - if(!readOnLoad) markRead(item.notifications.first().uri) + if(!readOnLoad) markAsRead(item.notifications.first().uri) }, onReplyClicked = { onReplyClicked(it) - if(!readOnLoad) markRead(item.notifications.first().uri) + if(!readOnLoad) markAsRead(item.notifications.first().uri) }, onMenuClicked = { option, p -> onMenuClicked(option, p) - if(!readOnLoad) markRead(item.notifications.first().uri) + if(!readOnLoad) markAsRead(item.notifications.first().uri) }, onLikeClicked = { onLikeClicked(it) - if(!readOnLoad) markRead(item.notifications.first().uri) - }, + if(!readOnLoad) markAsRead(item.notifications.first().uri) + }, ) } } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/ReasonIcon.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/ReasonIcon.kt index 1f2de5c..4d8517f 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/ReasonIcon.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/notifications/ReasonIcon.kt @@ -2,10 +2,7 @@ package com.morpho.app.ui.notifications import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.* import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -47,5 +44,10 @@ fun ReasonIcon( contentDescription = "Quote", modifier = modifier ) + ListNotificationsReason.PLACEHOLDER -> Icon( + imageVector = Icons.Default.Download, + contentDescription = "Placeholder", + modifier = modifier + ) } } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/BlockedPostFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/BlockedPostFragment.kt index ee4a302..2b428f9 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/BlockedPostFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/BlockedPostFragment.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import com.morpho.butterfly.AtUri @@ -39,7 +40,7 @@ fun BlockedPostFragment( SelectionContainer { Text( text = "Post by blocked or blocking user", - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.ExtraLight), modifier = Modifier .padding(12.dp) .align(Alignment.CenterHorizontally) diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/EmbedPostFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/EmbedPostFragment.kt index 16ecf62..7453c97 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/EmbedPostFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/EmbedPostFragment.kt @@ -2,7 +2,9 @@ package com.morpho.app.ui.post import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme @@ -12,6 +14,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -24,6 +27,7 @@ import com.morpho.app.ui.elements.OutlinedAvatar import com.morpho.app.ui.elements.RichTextElement import com.morpho.app.ui.elements.WrappedColumn import com.morpho.app.ui.lists.FeedListEntryFragment +import com.morpho.app.ui.lists.UserListEntryFragment import com.morpho.app.util.getFormattedDateTimeSince import com.morpho.app.util.openBrowser import com.morpho.app.util.parseImageFullRef @@ -42,10 +46,13 @@ fun EmbedPostFragment( val delta = remember { getFormattedDateTimeSince(post.litePost.createdAt) } var hidePost by rememberSaveable { mutableStateOf(post.author.mutedByMe) } val muted = rememberSaveable { post.author.mutedByMe } + val interactionSource = remember { MutableInteractionSource() } + val indication = LocalIndication.current + val uriHandler = LocalUriHandler.current WrappedColumn( modifier .fillMaxWidth() - .padding(2.dp) + .padding(top = 6.dp) ) { Surface ( tonalElevation = 4.dp, @@ -55,23 +62,27 @@ fun EmbedPostFragment( modifier = Modifier .fillMaxWidth() .align(Alignment.End) + .clickable( + interactionSource = interactionSource, + indication = indication, + enabled = true, + onClick = { onItemClicked(post.uri) } + ) ) { Column( - Modifier.clickable { onItemClicked(post.uri) } - .padding(bottom = 6.dp, end = 2.dp) - .fillMaxWidth(), + Modifier.fillMaxWidth(), ) { Row( modifier = Modifier - .padding(end = 4.dp), + .padding(end = 6.dp), horizontalArrangement = Arrangement.End ) { OutlinedAvatar( url = post.author.avatar.orEmpty(), contentDescription = "Avatar for ${post.author.handle}", - size = 20.dp, + size = 25.dp, //outlineColor = MaterialTheme.colorScheme.background, onClicked = { onProfileClicked(post.author.did) @@ -107,7 +118,12 @@ fun EmbedPostFragment( .padding(top = 4.dp, start = 4.dp) .weight(10.0F) .alignByBaseline() - .clickable { onProfileClicked(post.author.did) }, + .clickable( + interactionSource = interactionSource, + indication = indication, + enabled = true, + onClick = { onProfileClicked(post.author.did) } + ), ) Spacer( modifier = Modifier @@ -142,7 +158,7 @@ fun EmbedPostFragment( facetTypes.fastForEach { when(it) { is FacetType.ExternalLink -> { - openBrowser(it.uri.uri) + openBrowser(it.uri.uri, uriHandler) } is FacetType.Format -> {} is FacetType.PollBlueOption -> { @@ -160,10 +176,10 @@ fun EmbedPostFragment( } } }, - modifier = Modifier.padding(horizontal = 4.dp) + modifier = Modifier.padding(horizontal = 6.dp) ) EmbedPostFeature(embed = post, onItemClicked, onLinkClicked = { - openBrowser(it) + openBrowser(it, uriHandler) }) } @@ -195,10 +211,15 @@ fun ColumnScope.EmbedPostFeature( ) } is EmbedRecord.EmbedLabelService -> { - + Text(text = "Label Service") } is EmbedRecord.EmbedList -> { + UserListEntryFragment( + list = embed.list, + onListClicked = { + } + ) } is EmbedRecord.InvisibleEmbedPost -> { EmbedNotFoundPostFragment(uri = embed.uri) @@ -320,7 +341,12 @@ fun ColumnScope.EmbedPostFeature( onItemClicked = onItemClicked, modifier = Modifier.align(Alignment.CenterHorizontally) ) - is EmbedRecord.EmbedList -> {} + is EmbedRecord.EmbedList -> { + UserListEntryFragment( + list = embed.litePost.feature.record.list, + onListClicked = { } + ) + } is EmbedRecord.EmbedFeed -> { FeedListEntryFragment( embed.litePost.feature.record.feed, @@ -342,8 +368,8 @@ fun ColumnScope.EmbedPostFeature( alt = embed.alt, aspectRatio = embed.aspectRatio, modifier = Modifier - .padding(vertical = 6.dp) - .heightIn(10.dp, 700.dp) + .padding(top = 6.dp) + .heightIn(100.dp, 600.dp) .fillMaxWidth(), ) } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/FullPostFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/FullPostFragment.kt index cf8c688..959f5b5 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/FullPostFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/FullPostFragment.kt @@ -3,7 +3,15 @@ package com.morpho.app.ui.post import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons @@ -12,9 +20,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -23,20 +36,30 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEach +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.atproto.label.Blurs import com.atproto.repo.StrongRef import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.FacetType -import com.morpho.app.model.bluesky.LabelAction -import com.morpho.app.model.bluesky.LabelScope -import com.morpho.app.model.uidata.ContentHandling -import com.morpho.app.model.uidata.LabelDescription -import com.morpho.app.ui.elements.* -import com.morpho.app.util.openBrowser +import com.morpho.app.ui.elements.ContentHider +import com.morpho.app.ui.elements.MenuOptions +import com.morpho.app.ui.elements.OutlinedAvatar +import com.morpho.app.ui.elements.PostMenu +import com.morpho.app.ui.elements.RichTextElement +import com.morpho.app.ui.elements.WrappedColumn +import com.morpho.app.ui.utils.ItemClicked +import com.morpho.app.ui.utils.OnFacetClicked +import com.morpho.app.ui.utils.OnItemClicked +import com.morpho.app.ui.utils.OnPostClicked import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri +import com.morpho.butterfly.ContentHandling +import com.morpho.butterfly.LabelAction +import com.morpho.butterfly.LabelDescription +import com.morpho.butterfly.LabelIcon import com.morpho.butterfly.model.RecordType import kotlinx.collections.immutable.toImmutableList -import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.ExperimentalResourceApi @@ -46,7 +69,10 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi fun FullPostFragment( post: BskyPost, modifier: Modifier = Modifier, - onItemClicked: (AtUri) -> Unit = {}, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), onProfileClicked: (AtIdentifier) -> Unit = {}, onReplyClicked: (BskyPost) -> Unit = { }, onRepostClicked: (BskyPost) -> Unit = { }, @@ -56,13 +82,14 @@ fun FullPostFragment( getContentHandling: (BskyPost) -> List = { listOf() } ) { val postDate = remember { post.createdAt.instant.toLocalDateTime(TimeZone.currentSystemDefault()).date } + val postTime = remember { post.createdAt.instant.toLocalDateTime(TimeZone.currentSystemDefault()).time } var menuExpanded by remember { mutableStateOf(false) } val contentHandling = remember { if (post.author.mutedByMe) { getContentHandling(post) + ContentHandling( - scope = LabelScope.Content, + scope = Blurs.CONTENT, id = "muted", - icon = Icons.Default.MoreHoriz, + icon = LabelIcon.EyeSlash(labelerAvatar = null), action = LabelAction.Blur, source = LabelDescription.YouMuted, ) @@ -71,6 +98,12 @@ fun FullPostFragment( }.toImmutableList() } + val onPostClicked: OnPostClicked = remember { { uri -> + onItemClicked.onRichTextFacetClicked(uri = uri) + } } + val onFacetClicked: OnFacetClicked = remember { { facet -> + onItemClicked.onRichTextFacetClicked(facet = facet) + } } WrappedColumn( modifier @@ -81,7 +114,7 @@ fun FullPostFragment( ) { ContentHider( reasons = contentHandling, - scope = LabelScope.Content, + scope = Blurs.CONTENT, ) { Row( modifier = Modifier @@ -145,7 +178,7 @@ fun FullPostFragment( Icon( imageVector = Icons.Default.MoreHoriz, contentDescription = "More", - tint = MaterialTheme.colorScheme.onSurface + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } DisableSelection { PostMenu(menuExpanded, { @@ -161,7 +194,7 @@ fun FullPostFragment( text = post.text, facets = post.facets, //modifier = Modifier.padding(bottom = 2.dp).padding(start = 0.dp, end = 6.dp), - onItemClicked = { onItemClicked(post.uri) }, + onItemClicked = { onPostClicked(post.uri) }, onProfileClicked = onProfileClicked, getContentHandling = getContentHandling ) @@ -171,34 +204,27 @@ fun FullPostFragment( facets = post.facets, onClick = { facetTypes -> if (facetTypes.isEmpty()) { - onItemClicked(post.uri) + onPostClicked(post.uri) return@RichTextElement } facetTypes.fastForEach { - when(it) { - is FacetType.ExternalLink -> { - openBrowser(it.uri.uri) - } - is FacetType.Tag -> {onItemClicked(post.uri)} - is FacetType.UserDidMention -> { - onProfileClicked(it.did) - } - is FacetType.UserHandleMention -> { - onProfileClicked(it.handle) - } - - else -> {} - } + onFacetClicked(it) } }, ) } + val postTimestamp = remember { - val seconds = post.createdAt.instant.epochSeconds % 60 - Instant.fromEpochSeconds(post.createdAt.instant.epochSeconds - seconds) - .toLocalDateTime(TimeZone.currentSystemDefault()).time + // attmepts to cleanly handle 12-hour time while stripping seconds and sub-seconds + val string = postTime.toString() + if(string.contains("AM") || string.contains("PM")) { + val ampm = if(string.contains("AM")) "AM" else "PM" + val components = string.split(":") + "${components[0]}:${components[1]} $ampm" + } else { + string.substringBeforeLast(":") + } } - PostFeatureElement( post.feature, onItemClicked, contentHandling = contentHandling ) diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/NotFoundPostFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/NotFoundPostFragment.kt index 6770b98..fd80b3a 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/NotFoundPostFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/NotFoundPostFragment.kt @@ -10,12 +10,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import com.morpho.app.ui.elements.WrappedColumn import com.morpho.butterfly.AtUri import morpho.app.ui.utils.indentLevel + @Composable fun NotFoundPostFragment( modifier: Modifier = Modifier, @@ -40,7 +42,7 @@ fun NotFoundPostFragment( SelectionContainer { Text( text = "Post deleted or not found", - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.ExtraLight), modifier = Modifier .padding(12.dp) .align(Alignment.CenterHorizontally) @@ -49,4 +51,43 @@ fun NotFoundPostFragment( } } } -} \ No newline at end of file +} + + + +@Composable +fun PlaceholderSkylineItem( + modifier: Modifier = Modifier, + elevate: Boolean = false, + indentLevel: Int = 0, + role: PostFragmentRole = PostFragmentRole.Solo, +) { + + WrappedColumn( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp) + ) { + Surface( + shadowElevation = max((indentLevel - 1).dp, 0.dp), + tonalElevation = indentLevel.dp, + shape = MaterialTheme.shapes.small, + modifier = Modifier + .fillMaxWidth(indentLevel(indentLevel.toFloat())) + + ) { + Column { + SelectionContainer { + Text( + text = "Post Loading", + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Light), + color = MaterialTheme.colorScheme.outline, + modifier = Modifier + .padding(50.dp) + .align(Alignment.CenterHorizontally) + ) + } + } + } + } +} diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PollBlueEmbed.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PollBlueEmbed.kt index 7cee1bd..62c8cd1 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PollBlueEmbed.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PollBlueEmbed.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastFold @@ -24,11 +25,11 @@ import com.morpho.app.data.stripPollOptionCharacters import com.morpho.app.model.bluesky.BskyFacet import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.FacetType -import com.morpho.app.model.uidata.ContentHandling import com.morpho.app.ui.elements.RichTextElement import com.morpho.app.util.openBrowser import com.morpho.app.util.utf8Slice import com.morpho.butterfly.AtIdentifier +import com.morpho.butterfly.ContentHandling import com.morpho.butterfly.Uri import kotlinx.coroutines.launch import org.koin.compose.getKoin @@ -51,6 +52,7 @@ fun ColumnScope.PollBlueOption( is FacetType.PollBlueOption -> type.number else -> throw IllegalArgumentException("Expected PollBlueOption, got $type") } } + Row( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, @@ -133,6 +135,7 @@ fun PollBluePost( ) } var optionChosen by remember { mutableStateOf(pollBlueService.lookupPollBlueVote(pollId)) } val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current DisableSelection { Column( horizontalAlignment = Alignment.Start, @@ -149,7 +152,7 @@ fun PollBluePost( facetTypes.fastForEach { when(it) { is FacetType.ExternalLink -> { - openBrowser(it.uri.uri) + openBrowser(it.uri.uri, uriHandler) } is FacetType.Tag -> {} is FacetType.UserDidMention -> { @@ -233,7 +236,7 @@ fun PollBluePost( color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 4.dp) - .clickable { openBrowser("https://poll.blue/post") } + .clickable { openBrowser("https://poll.blue/post", uriHandler) } ) } } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostActions.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostActions.kt index 14c093e..92591a8 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostActions.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostActions.kt @@ -87,6 +87,55 @@ fun PostActions( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DummyPostActions( + modifier: Modifier = Modifier, + showMenu: Boolean = true, +) { + var menuExpanded by remember { mutableStateOf(false) } + Row( + horizontalArrangement = Arrangement.SpaceEvenly + ) { + PostAction( + parameter = 0, + iconNormal = Icons.Outlined.ChatBubbleOutline, + contentDescription = "Reply ", + + onUnClicked = { }, + ) + PostAction( + parameter = 0, + iconNormal = Icons.Outlined.Repeat, + contentDescription = "Repost ", + active = false + ) + PostAction( + parameter = 0, + iconNormal = Icons.Outlined.FavoriteBorder, + iconActive = Icons.Default.Favorite, + contentDescription = "Like ", + activeColor = Color(0xFFEC7B9E), + active = false + ) + if (showMenu) { + + PostAction( + parameter = -1, + iconNormal = Icons.Default.MoreHoriz, + contentDescription = "More ", + onClicked = { + menuExpanded = true + }, + onUnClicked = { + menuExpanded = true + }, + ) + } + + } +} + @OptIn(ExperimentalLayoutApi::class) @@ -103,7 +152,7 @@ inline fun PostAction( active: Boolean = false, ) { var clicked by rememberSaveable { mutableStateOf(active) } - val inactiveColor = MaterialTheme.colorScheme.onSurface + val inactiveColor = MaterialTheme.colorScheme.onSurfaceVariant var num by rememberSaveable { mutableLongStateOf(parameter) } val color = remember { mutableStateOf(if (clicked) activeColor else inactiveColor) } val icon = remember { mutableStateOf(if (clicked) iconActive else iconNormal) } @@ -123,8 +172,8 @@ inline fun PostAction( onUnClicked() } }, - modifier = Modifier - .padding(0.dp), + shape = MaterialTheme.shapes.small, + modifier = modifier, contentPadding = PaddingValues(0.dp) ) { Icon( @@ -135,10 +184,11 @@ inline fun PostAction( .size(20.dp) .padding(0.dp) ) + Text( - text = if (num > 0) num.toString() else "", + text = if (num > 0) "$num" else "", color = color.value, - modifier = Modifier.padding(start = 6.dp)//.offset(y=(-1).dp) + modifier = Modifier.padding(start = 6.dp) ) } } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostFragment.kt index 7e2253f..2e649a3 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostFragment.kt @@ -2,20 +2,36 @@ package com.morpho.app.ui.post import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Repeat -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -23,18 +39,36 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAny -import androidx.compose.ui.util.fastForEach +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.atproto.label.Blurs import com.atproto.repo.StrongRef -import com.morpho.app.model.bluesky.* -import com.morpho.app.model.uidata.ContentHandling -import com.morpho.app.model.uidata.LabelDescription +import com.morpho.app.model.bluesky.BskyPost +import com.morpho.app.model.bluesky.BskyPostFeature +import com.morpho.app.model.bluesky.BskyPostReason +import com.morpho.app.model.bluesky.EmbedRecord +import com.morpho.app.model.bluesky.FacetType +import com.morpho.app.model.bluesky.TimelinePostMedia import com.morpho.app.ui.common.OnPostClicked -import com.morpho.app.ui.elements.* +import com.morpho.app.ui.elements.AvatarShape +import com.morpho.app.ui.elements.ContentHider +import com.morpho.app.ui.elements.MenuOptions +import com.morpho.app.ui.elements.OutlinedAvatar +import com.morpho.app.ui.elements.RichTextElement +import com.morpho.app.ui.elements.WrappedColumn import com.morpho.app.ui.lists.FeedListEntryFragment +import com.morpho.app.ui.lists.UserListEntryFragment +import com.morpho.app.ui.utils.ItemClicked +import com.morpho.app.ui.utils.OnFacetClicked +import com.morpho.app.ui.utils.OnItemClicked import com.morpho.app.util.getFormattedDateTimeSince import com.morpho.app.util.openBrowser import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri +import com.morpho.butterfly.ContentHandling +import com.morpho.butterfly.LabelAction +import com.morpho.butterfly.LabelDescription +import com.morpho.butterfly.LabelIcon import com.morpho.butterfly.model.RecordType import kotlinx.collections.immutable.toImmutableList import morpho.app.ui.utils.indentLevel @@ -43,7 +77,6 @@ import morpho.composeapp.generated.resources.replyIndicator import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.stringResource - @OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) @Composable fun PostFragment( @@ -52,7 +85,10 @@ fun PostFragment( role: PostFragmentRole = PostFragmentRole.Solo, indentLevel: Int = 0, elevate: Boolean = false, - onItemClicked: OnPostClicked = {}, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), onProfileClicked: (AtIdentifier) -> Unit = {}, onReplyClicked: (BskyPost) -> Unit = { }, onRepostClicked: (BskyPost) -> Unit = { }, @@ -62,15 +98,15 @@ fun PostFragment( getContentHandling: (BskyPost) -> List = { listOf() } ) { val padding = remember { when(role) { - PostFragmentRole.Solo -> Modifier.padding(2.dp) + PostFragmentRole.Solo -> if(indentLevel == 0) Modifier.padding(2.dp) else Modifier PostFragmentRole.PrimaryThreadRoot -> Modifier.padding(2.dp) - PostFragmentRole.ThreadBranchStart -> Modifier.padding(start = 2.dp, top = 0.dp, end = 2.dp, bottom = 0.dp) - PostFragmentRole.ThreadBranchMiddle -> Modifier.padding(start = 2.dp, top = 0.dp, end = 2.dp, bottom = 0.dp) - PostFragmentRole.ThreadBranchEnd -> Modifier.padding(start = 2.dp, top = 0.dp, end = 2.dp, bottom = 0.dp) + PostFragmentRole.ThreadBranchStart -> Modifier.padding(start = 0.dp, top = 0.dp, end = 2.dp, bottom = 2.dp) + PostFragmentRole.ThreadBranchMiddle -> Modifier.padding(start = 0.dp, top = 0.dp, end = 2.dp, bottom = 2.dp) + PostFragmentRole.ThreadBranchEnd -> Modifier.padding(start = 0.dp, top = 0.dp, end = 2.dp, bottom = 2.dp) PostFragmentRole.ThreadRootUnfocused -> Modifier.padding(2.dp) - PostFragmentRole.ThreadEnd -> Modifier.padding(start = 2.dp, top = 0.dp, end = 2.dp, bottom = 0.dp) + PostFragmentRole.ThreadEnd -> Modifier.padding(start = 2.dp, top = 0.dp, end = 2.dp, bottom = 2.dp) }} - WrappedColumn(modifier = padding.fillMaxWidth()) { + WrappedColumn(modifier = modifier.then(padding.fillMaxWidth())) { val delta = remember { getFormattedDateTimeSince(post.createdAt) } val indent = remember { when(role) { PostFragmentRole.Solo -> indentLevel.toFloat() @@ -81,46 +117,22 @@ fun PostFragment( PostFragmentRole.ThreadRootUnfocused -> indentLevel.toFloat() PostFragmentRole.ThreadEnd -> 0.0f }} - val baseShape = MaterialTheme.shapes.small - val shape = when(role) { - PostFragmentRole.Solo -> baseShape - PostFragmentRole.PrimaryThreadRoot -> baseShape - PostFragmentRole.ThreadBranchStart -> { - baseShape.copy( - bottomEnd = CornerSize(0.dp), - bottomStart = CornerSize(0.dp), - ) } - PostFragmentRole.ThreadBranchMiddle -> { - baseShape.copy( - topEnd = CornerSize(0.dp), - topStart = CornerSize(0.dp), - bottomEnd = CornerSize(0.dp), - bottomStart = CornerSize(0.dp), - ) } - PostFragmentRole.ThreadBranchEnd -> { - baseShape.copy( - topEnd = CornerSize(0.dp), - topStart = CornerSize(0.dp), - ) } - PostFragmentRole.ThreadRootUnfocused -> baseShape - PostFragmentRole.ThreadEnd -> { - baseShape.copy( - topEnd = CornerSize(0.dp), - topStart = CornerSize(0.dp), - ) } - } - val bgColor = if (role == PostFragmentRole.ThreadEnd) { + + val interactionSource = remember { MutableInteractionSource() } + val indication = LocalIndication.current + val bgColor = if (role == PostFragmentRole.PrimaryThreadRoot) { MaterialTheme.colorScheme.background } else { - MaterialTheme.colorScheme.surfaceColorAtElevation(if (elevate || indentLevel > 0) 2.dp else 0.dp) + MaterialTheme.colorScheme.surfaceColorAtElevation(if (elevate ) 2.dp else + if (indentLevel > 0) (indentLevel*2).dp else 0.dp) } val contentHandling = remember { if (post.author.mutedByMe) { getContentHandling(post) + ContentHandling( - scope = LabelScope.Content, + scope = Blurs.CONTENT, id = "muted", - icon = Icons.Default.MoreHoriz, + icon = LabelIcon.EyeSlash(labelerAvatar = null), action = LabelAction.Blur, source = LabelDescription.YouMuted, ) @@ -128,194 +140,194 @@ fun PostFragment( getContentHandling(post) }.toImmutableList() } + val onPostClicked: OnPostClicked = remember { { uri -> + onItemClicked.onRichTextFacetClicked(uri = uri) + } } + val onFacetClicked: OnFacetClicked = remember { { facet -> + onItemClicked.onRichTextFacetClicked(facet = facet) + } } Surface ( - shadowElevation = if (elevate || indentLevel > 0) 1.dp else 0.dp, - tonalElevation = if ((elevate || indentLevel > 0) && role != PostFragmentRole.ThreadEnd) 2.dp else 0.dp, - shape = shape, + shadowElevation = if (elevate || indentLevel > 0) 2.dp else 0.dp, + tonalElevation = if (elevate && role != PostFragmentRole.ThreadEnd) 2.dp + else if (indentLevel > 0) (indentLevel*2).dp else 0.dp, + shape = MaterialTheme.shapes.small, + //color = bgColor, modifier = modifier .fillMaxWidth(indentLevel(indent)) .align(Alignment.End) - .background(bgColor, shape) + .clickable( + interactionSource = interactionSource, + indication = indication, + enabled = true, + onClick = { onPostClicked(post.uri) } + ) ) { ContentHider( reasons = contentHandling, - scope = LabelScope.Content, + scope = Blurs.CONTENT, ) { - SelectionContainer( - Modifier.clickable { onItemClicked(post.uri) } + Row( + modifier = Modifier.padding(end = 6.dp) + .fillMaxWidth()//.fillMaxWidth(indentLevel(indent)) ) { - Row( - modifier = Modifier.padding(bottom = 2.dp).padding(start = 0.dp, end = 6.dp) - .fillMaxWidth(indentLevel(indent)) - ) { - - if (indent < 2) { - OutlinedAvatar( - url = post.author.avatar.orEmpty(), - contentDescription = "Avatar for ${post.author.handle}", - size = 45.dp, - outlineColor = MaterialTheme.colorScheme.background, - onClicked = { onProfileClicked(post.author.did) }, - avatarShape = AvatarShape.Corner - ) - } - Column( - Modifier - .padding(vertical = 2.dp, horizontal = 6.dp) - .fillMaxWidth(indentLevel(indent)), - ) { - if (post.reason is BskyPostReason.BskyPostRepost) { - Row( - modifier = Modifier, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Repeat, - contentDescription = "", - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.height(15.dp) - ) - Text( - text = "Reposted by ${post.reason.repostAuthor.displayName}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(start = 5.dp) - ) - } - } + if (indent < 2) { + OutlinedAvatar( + url = post.author.avatar.orEmpty(), + contentDescription = "Avatar for ${post.author.handle}", + size = 45.dp, + outlineColor = MaterialTheme.colorScheme.background, + onClicked = { onProfileClicked(post.author.did) }, + avatarShape = AvatarShape.Corner, + modifier = Modifier.padding(end = 2.dp) + ) + } + Column( + Modifier + .padding(top = 4.dp, start = 2.dp, end = 6.dp) + .fillMaxWidth()//.fillMaxWidth(indentLevel(indent)) + ) { + if (post.reason is BskyPostReason.BskyPostRepost) { Row( - modifier = Modifier.padding(top = 4.dp).padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.End + modifier = Modifier.padding(start = 2.dp), + verticalAlignment = Alignment.CenterVertically ) { - if (indent >= 2) { - OutlinedAvatar( - url = post.author.avatar.orEmpty(), - contentDescription = "Avatar for ${post.author.handle}", - size = 30.dp, - avatarShape = AvatarShape.Rounded, - outlineColor = MaterialTheme.colorScheme.background, - onClicked = { onProfileClicked(post.author.did) } - ) - } - Text( - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize.times( - 1.2f - ), - fontWeight = FontWeight.Medium - ) - ) { - if (post.author.displayName != null) append("${post.author.displayName} ") - } - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = MaterialTheme.typography.labelLarge.fontSize.times( - 1.0f - ) - ) - ) { - append("@${post.author.handle}") - } - - }, - maxLines = 1, - style = MaterialTheme.typography.labelLarge, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .wrapContentWidth(Alignment.Start) - .weight(10.0F) - .alignByBaseline() - .clickable { onProfileClicked(post.author.did) }, + Icon( + imageVector = Icons.Default.Repeat, + contentDescription = "", + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.height(15.dp) ) - - Spacer(modifier = Modifier.width(1.dp).weight(0.1F)) Text( - text = delta, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.labelLarge, - fontSize = MaterialTheme.typography.labelLarge.fontSize.div(1.2F), - modifier = Modifier - .wrapContentWidth(Alignment.End) - //.weight(3.0F) - .alignByBaseline(), - maxLines = 1, - overflow = TextOverflow.Visible, - softWrap = false, + text = "Reposted by ${post.reason.repostAuthor.displayName}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(start = 5.dp) ) } + } - if (post.reply?.parent != null) { - ReplyIndicator(post.reply.parent) + Row( + modifier = Modifier.padding(top = 2.dp, start = 2.dp, end = 4.dp), + horizontalArrangement = Arrangement.End + ) { + if (indent >= 2) { + OutlinedAvatar( + url = post.author.avatar.orEmpty(), + contentDescription = "Avatar for ${post.author.handle}", + size = 30.dp, + avatarShape = AvatarShape.Rounded, + outlineColor = MaterialTheme.colorScheme.background, + onClicked = { onProfileClicked(post.author.did) } + ) } + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + fontWeight = FontWeight.Medium + ) + ) { + if (post.author.displayName != null) append("${post.author.displayName} ") + } + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = MaterialTheme.typography.labelLarge.fontSize.times( + 0.8f + ) + ) + ) { + append("@${post.author.handle}") + } - if (post.facets.fastAny { - it.facetType.first() is FacetType.PollBlueOption - }) { - PollBluePost( - text = post.text, - facets = post.facets, - //modifier = Modifier.padding(bottom = 2.dp).padding(start = 0.dp, end = 6.dp), - onItemClicked = { onItemClicked(post.uri) }, - onProfileClicked = onProfileClicked, - getContentHandling = getContentHandling - ) - } else { - RichTextElement( - text = post.text, - facets = post.facets, - onClick = { facetTypes -> - if (facetTypes.isEmpty()) { - onItemClicked(post.uri) - return@RichTextElement - } - facetTypes.fastForEach { - when(it) { - is FacetType.ExternalLink -> { - openBrowser(it.uri.uri) - } - is FacetType.Tag -> {onItemClicked(post.uri)} - is FacetType.UserDidMention -> { - onProfileClicked(it.did) - } - is FacetType.UserHandleMention -> { - onProfileClicked(it.handle) - } + }, + maxLines = 1, + style = MaterialTheme.typography.labelLarge, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .wrapContentWidth(Alignment.Start) + .weight(10.0F) + .alignByBaseline() + .pointerHoverIcon(PointerIcon.Hand) + .clickable( + interactionSource = interactionSource, + indication = indication, + enabled = true, + onClick = { onProfileClicked(post.author.did) } + ) + ) - else -> {} - } - } - }, - ) - } - PostFeatureElement( - post.feature, onItemClicked, contentHandling = contentHandling + Spacer(modifier = Modifier.width(1.dp).weight(0.1F)) + Text( + text = delta, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, + fontSize = MaterialTheme.typography.labelLarge.fontSize.div(1.2F), + modifier = Modifier + .wrapContentWidth(Alignment.End) + //.weight(3.0F) + .alignByBaseline(), + maxLines = 1, + overflow = TextOverflow.Visible, + softWrap = false, ) + } + + if (post.reply?.parentPost != null) { + ReplyIndicator(post.reply.parentPost) + } - PostActions( - post = post, - onLikeClicked = { onLikeClicked(StrongRef(post.uri, post.cid)) }, - onMenuClicked = { onMenuClicked(it, post) }, - onReplyClicked = { onReplyClicked(post) }, - onRepostClicked = { onRepostClicked(post) }, - onUnClicked = onUnClicked, + if (post.facets.fastAny { + it.facetType.first() is FacetType.PollBlueOption + }) { + PollBluePost( + text = post.text, + facets = post.facets, + //modifier = Modifier.padding(bottom = 2.dp).padding(start = 0.dp, end = 6.dp), + onItemClicked = { onPostClicked(post.uri) }, + onProfileClicked = onProfileClicked, + getContentHandling = getContentHandling + ) + } else { + RichTextElement( + text = post.text, + facets = post.facets, + modifier = Modifier.padding(end = 2.dp), + onClick = { facetTypes -> + if (facetTypes.isEmpty()) { + onPostClicked(post.uri) + return@RichTextElement + } + facetTypes.forEach { + onFacetClicked(it) + } + }, ) } + PostFeatureElement( + post.feature, onItemClicked, contentHandling = contentHandling + ) + + PostActions( + post = post, + onLikeClicked = { onLikeClicked(StrongRef(post.uri, post.cid)) }, + onMenuClicked = { onMenuClicked(it, post) }, + onReplyClicked = { onReplyClicked(post) }, + onRepostClicked = { onRepostClicked(post) }, + onUnClicked = onUnClicked, + ) } } - } - + } } } - } @OptIn(ExperimentalResourceApi::class) @@ -337,7 +349,7 @@ internal inline fun ReplyIndicator( ) Text( text = stringResource(Res.string.replyIndicator, parent.author.handle), - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(start = 5.dp) ) @@ -347,29 +359,40 @@ internal inline fun ReplyIndicator( @Composable inline fun ColumnScope.PostFeatureElement( feature: BskyPostFeature? = null, - crossinline onItemClicked: OnPostClicked = {}, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), crossinline onLikeClicked: (StrongRef) -> Unit = { }, crossinline onUnClicked: (type: RecordType, uri: AtUri) -> Unit = { _, _ -> }, contentHandling: List = listOf() ) { + @Suppress("REDUNDANT_ELSE_IN_WHEN") when (feature) { - is BskyPostFeature.ExternalFeature -> PostLinkEmbed(linkData = feature, - linkPress = { openBrowser(it) }, - modifier = Modifier.align(Alignment.CenterHorizontally)) + is BskyPostFeature.ExternalFeature -> { + val uriHandler = LocalUriHandler.current + PostLinkEmbed( + linkData = feature, + linkPress = { openBrowser(it, uriHandler) }, + modifier = Modifier.padding(end = 2.dp) + .align(Alignment.CenterHorizontally) + ) } is BskyPostFeature.ImagesFeature -> { ContentHider( reasons = contentHandling, - scope = LabelScope.Media, + scope = Blurs.MEDIA, + modifier = Modifier.padding(horizontal = 2.dp) ) { PostImages(imagesFeature = feature, - modifier = Modifier.align(Alignment.CenterHorizontally)) + modifier = Modifier.padding(end = 2.dp) + .align(Alignment.CenterHorizontally)) } } is BskyPostFeature.MediaRecordFeature -> { ContentHider( reasons = contentHandling, - scope = LabelScope.Media, + scope = Blurs.MEDIA, ) { RecordFeature( record = feature.record, @@ -384,7 +407,7 @@ inline fun ColumnScope.PostFeatureElement( is BskyPostFeature.RecordFeature -> { ContentHider( reasons = contentHandling, - scope = LabelScope.Content, + scope = Blurs.MEDIA, ) { RecordFeature( record = feature.record, @@ -398,7 +421,7 @@ inline fun ColumnScope.PostFeatureElement( is BskyPostFeature.VideoFeature -> { ContentHider( reasons = contentHandling, - scope = LabelScope.Media, + scope = Blurs.MEDIA, ) { VideoEmbedThumb( video = feature.video, @@ -409,6 +432,9 @@ inline fun ColumnScope.PostFeatureElement( } } + is BskyPostFeature.UnknownEmbed -> { + Text(text = "Unknown Embed ${feature.value}") + } null -> {} else -> {Text(text = "Feature type not supported")} @@ -419,30 +445,40 @@ inline fun ColumnScope.PostFeatureElement( inline fun ColumnScope.RecordFeature( record: EmbedRecord? = null, media: TimelinePostMedia? = null, - crossinline onItemClicked: OnPostClicked = {}, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), crossinline onLikeClicked: (StrongRef) -> Unit = { }, crossinline onUnClicked: (type: RecordType, uri: AtUri) -> Unit = { _, _ -> }, contentHandling: List = listOf(), getContentHandling: (EmbedRecord) -> List = { listOf() } ) { + if(media != null) { + ContentHider( reasons = contentHandling, - scope = LabelScope.Media, - modifier = Modifier.align(Alignment.CenterHorizontally) + scope = Blurs.MEDIA, + modifier = Modifier + .padding(horizontal = 2.dp) + .align(Alignment.CenterHorizontally) ) { when(media) { is BskyPostFeature.ExternalFeature -> { + val uriHandler = LocalUriHandler.current PostLinkEmbed( linkData = media, - linkPress = { openBrowser(it) }, - modifier = Modifier.align(Alignment.CenterHorizontally) + linkPress = { openBrowser(it, uriHandler) }, + modifier = Modifier.padding(end = 2.dp) + .align(Alignment.CenterHorizontally) ) } is BskyPostFeature.ImagesFeature -> { PostImages( imagesFeature = media, - modifier = Modifier.align(Alignment.CenterHorizontally) + modifier = Modifier.padding(end = 2.dp) + .align(Alignment.CenterHorizontally) ) } is BskyPostFeature.VideoFeature -> { @@ -450,7 +486,8 @@ inline fun ColumnScope.RecordFeature( video = media.video, alt = media.alt, aspectRatio = media.aspectRatio, - modifier = Modifier.align(Alignment.CenterHorizontally) + modifier = Modifier.padding(end = 2.dp) + .align(Alignment.CenterHorizontally) ) } else -> {Text(text = "Record Feature not supported")} @@ -460,7 +497,7 @@ inline fun ColumnScope.RecordFeature( if(record != null) { ContentHider( reasons = contentHandling, - scope = LabelScope.Content, + scope = Blurs.CONTENT, modifier = Modifier.align(Alignment.CenterHorizontally) ) { when (record) { @@ -468,7 +505,7 @@ inline fun ColumnScope.RecordFeature( is EmbedRecord.InvisibleEmbedPost -> EmbedNotFoundPostFragment(uri = record.uri) is EmbedRecord.VisibleEmbedPost -> EmbedPostFragment( post = record, - onItemClicked = { onItemClicked(record.uri) }, + onItemClicked = { onItemClicked.onRichTextFacetClicked(uri = record.uri) }, modifier = Modifier.align(Alignment.CenterHorizontally) ) @@ -482,6 +519,12 @@ inline fun ColumnScope.RecordFeature( onFeedClicked = { } ) } + is EmbedRecord.EmbedList -> { + UserListEntryFragment( + list = record.list, + onListClicked = { } + ) + } else -> { Text(text = "Record Media Feature not supported") diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostImage.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostImage.kt index 2178a76..7d827de 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostImage.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostImage.kt @@ -2,14 +2,26 @@ package com.morpho.app.ui.post import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.requiredWidthIn import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -18,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign @@ -28,6 +41,7 @@ import coil3.request.ImageRequest import coil3.size.Size import com.morpho.app.model.bluesky.BskyPostFeature import com.morpho.app.model.bluesky.EmbedImage +import kotlin.math.roundToInt @OptIn(ExperimentalLayoutApi::class) @Composable @@ -38,22 +52,22 @@ fun PostImages( val numImages = rememberSaveable { imagesFeature.images.size} if(numImages > 1) { LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Adaptive(120.dp), - contentPadding = PaddingValues(0.dp), + columns = StaggeredGridCells.Adaptive(150.dp), + contentPadding = PaddingValues(2.dp), modifier = modifier - .padding(vertical = 6.dp) + .padding(top = 6.dp) .heightIn(10.dp, 700.dp) ) { items(imagesFeature.images) {image -> PostImageThumb( image = image, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(2.dp) ) } } } else if (numImages == 1 && imagesFeature.images.isNotEmpty()) { PostImageThumb(image = imagesFeature.images.first(), modifier = Modifier - .padding(vertical = 6.dp) + .padding(top = 6.dp) .heightIn(10.dp, 700.dp) .fillMaxWidth() ) @@ -74,7 +88,7 @@ fun PostImageThumb( } val showAltText = remember { mutableStateOf(false) } BoxWithConstraints( - modifier = modifier.padding(2.dp) + modifier = modifier ) { if (image.aspectRatio == null) { AsyncImage( @@ -83,6 +97,7 @@ fun PostImageThumb( .build(), contentDescription = image.alt, contentScale = ContentScale.Inside, + filterQuality = FilterQuality.High, modifier = Modifier .clip(MaterialTheme.shapes.small) .clickable { @@ -96,15 +111,18 @@ fun PostImageThumb( val ratio = image.aspectRatio.width.toFloat() / image.aspectRatio.height.toFloat() if (ratio > 1) { height /= ratio + height = height.roundToInt().toFloat() } else { width /= ratio + width = width.roundToInt().toFloat() } AsyncImage( model = ImageRequest.Builder(LocalPlatformContext.current) - .data(image.thumb) .size(Size(width.toInt(), height.toInt())) + .data(image.thumb) .build(), contentDescription = image.alt, + filterQuality = FilterQuality.High, contentScale = ContentScale.Inside, modifier = Modifier .clip(MaterialTheme.shapes.small) diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostLinkEmbed.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostLinkEmbed.kt index 96883df..8b2b41a 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostLinkEmbed.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/post/PostLinkEmbed.kt @@ -4,11 +4,11 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -22,7 +22,6 @@ import coil3.size.Size import com.morpho.app.model.bluesky.BskyPostFeature import com.morpho.app.ui.elements.RichTextElement import com.morpho.app.ui.elements.WrappedColumn -import com.morpho.app.util.makeBlueskyText import org.jetbrains.compose.resources.ExperimentalResourceApi @OptIn(ExperimentalLayoutApi::class, ExperimentalResourceApi::class) @@ -37,7 +36,7 @@ fun PostLinkEmbed( Surface( shape = MaterialTheme.shapes.extraSmall, tonalElevation = 3.dp, - shadowElevation = 1.dp, + shadowElevation = 2.dp, modifier = modifier //border = BorderStroke(1.dp,MaterialTheme.colorScheme.secondary) ) { @@ -55,7 +54,8 @@ fun PostLinkEmbed( modifier = Modifier .fillMaxWidth() .align(Alignment.CenterHorizontally) - .clip(MaterialTheme.shapes.extraSmall) + .clip(MaterialTheme.shapes.extraSmall + .copy(bottomEnd = CornerSize(0.dp), bottomStart = CornerSize(0.dp))) .clickable { linkPress(linkData.uri.uri) } ) WrappedColumn( @@ -63,15 +63,15 @@ fun PostLinkEmbed( ) { Text( text = linkData.title, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(8.dp) ) - val bskyTxt = remember { makeBlueskyText(linkData.description) } - RichTextElement( - text = bskyTxt.text, - facets = bskyTxt.facets, - modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 8.dp) - ) + if(linkData.description.isNotEmpty()) { + RichTextElement( + text = linkData.description, + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 8.dp) + ) + } } } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/CompactProfileFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/CompactProfileFragment.kt new file mode 100644 index 0000000..e929401 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/CompactProfileFragment.kt @@ -0,0 +1,142 @@ +package com.morpho.app.ui.profile + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.ui.elements.AvatarShape +import com.morpho.app.ui.elements.OutlinedAvatar +import com.morpho.app.ui.elements.RichTextElement +import com.morpho.app.ui.elements.WrappedColumn +import com.morpho.app.ui.utils.ItemClicked +import com.morpho.app.ui.utils.OnItemClicked +import com.morpho.butterfly.Did + +@Composable +fun CompactProfileFragment( + profile: DetailedProfile, + elevate: Boolean = false, + modifier: Modifier = Modifier, + onProfileClicked: (Did) -> Unit = { }, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), +) { + val interactionSource = remember { MutableInteractionSource() } + val indication = LocalIndication.current + WrappedColumn(modifier = modifier.fillMaxWidth()) { + Surface ( + shadowElevation = if (elevate ) 2.dp else 0.dp, + tonalElevation = if (elevate) 2.dp else 0.dp, + shape = MaterialTheme.shapes.small, + //color = bgColor, + modifier = modifier + .fillMaxWidth() + .align(Alignment.End) + .clickable( + interactionSource = interactionSource, + indication = indication, + enabled = true, + onClick = { onProfileClicked(profile.did) } + ) + + ) { + Row( + modifier = Modifier.padding(end = 6.dp) + .fillMaxWidth()//.fillMaxWidth(indentLevel(indent)) + ) { + OutlinedAvatar( + url = profile.avatar.orEmpty(), + contentDescription = "Avatar for ${profile.displayName} ${profile.handle}", + size = 45.dp, + outlineColor = MaterialTheme.colorScheme.background, + modifier = Modifier.padding(end = 2.dp), + avatarShape = AvatarShape.Corner + ) + Column( + Modifier + .padding(top = 4.dp, start = 2.dp, end = 6.dp) + .fillMaxWidth()//.fillMaxWidth(indentLevel(indent)) + ) { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + fontWeight = FontWeight.Medium + ) + ) { + if (profile.displayName != null) append("${profile.displayName} ") + } + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = MaterialTheme.typography.labelLarge.fontSize.times( + 0.8f + ) + ) + ) { + append("@${profile.handle}") + } + + }, + maxLines = 1, + style = MaterialTheme.typography.labelLarge, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .wrapContentWidth(Alignment.Start) + .pointerHoverIcon(PointerIcon.Hand) + .clickable( + interactionSource = interactionSource, + indication = indication, + enabled = true, + onClick = { onProfileClicked(profile.did) } + ) + ) + ProfileLabels( + labels = profile.labels, + modifier = Modifier.padding(vertical = 4.dp) + ) { label -> + + } + RichTextElement( + profile.description.orEmpty(), + maxLines = 4, + modifier = Modifier.padding(vertical = 4.dp) + ) { facetTypes -> + facetTypes.fastForEach { + onItemClicked.onRichTextFacetClicked(facet = it) + } + } + + } + } + } + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.kt index 5f01233..af8d223 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.kt @@ -2,13 +2,28 @@ package com.morpho.app.ui.profile import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.morpho.app.model.bluesky.BskyLabelService import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.uidata.Event import org.jetbrains.compose.resources.ExperimentalResourceApi @@ -26,4 +41,59 @@ expect fun DetailedProfileFragment( scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState()), onBackClicked: () -> Unit = {}, -) \ No newline at end of file + eventCallback: (Event) -> Unit = {}, +) + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalLayoutApi::class, + ExperimentalResourceApi::class +) +@Composable +expect fun LabelerProfileFragment( + labeler: BskyLabelService, + modifier: Modifier = Modifier, + isSubscribed: Boolean, + isTopLevel:Boolean = false, + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState()), + onBackClicked: () -> Unit, + eventCallback: (Event) -> Unit, +) + +@Composable +fun LabelerButtons( + modifier: Modifier = Modifier, + subscribed: Boolean = false, + onSubscribeClicked: () -> Unit = {}, + onUnsubscribeClicked: () -> Unit = {}, + onMenuClicked: () -> Unit = {}, +) { + var isSubscribed by remember { mutableStateOf(subscribed) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .padding(horizontal = 2.dp) + ) { + ExtendedFloatingActionButton( + text = { + Text( + text = if(isSubscribed) "Unsubscribe" else "Subscribe", + style = MaterialTheme.typography.labelLarge, + fontSize = MaterialTheme.typography.labelLarge + .fontSize.times(0.9) + ) + }, + icon = { + }, + onClick = { + if(isSubscribed) onUnsubscribeClicked() else onSubscribeClicked() + isSubscribed = !isSubscribed + }, + shape = ButtonDefaults.filledTonalShape, + modifier = modifier + .heightIn(min = 30.dp, max = 48.dp) + ) + ProfileMenuButton(onClick = onMenuClicked) + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/ProfileLabel.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/ProfileLabel.kt index ba8a1af..0f93706 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/ProfileLabel.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/ProfileLabel.kt @@ -15,11 +15,12 @@ import com.morpho.app.model.bluesky.BskyLabel @Composable fun ProfileLabel( modifier: Modifier = Modifier, - label: BskyLabel + label: BskyLabel, + onClick: (BskyLabel) -> Unit = {}, ) { InputChip( selected = true, - onClick = { /*TODO*/ }, + onClick = { onClick(label) }, label = { Text( text = label.value, diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/ProfileLabels.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/ProfileLabels.kt index ac6e9f3..d3c8eb1 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/ProfileLabels.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/ProfileLabels.kt @@ -10,7 +10,8 @@ import com.morpho.app.model.bluesky.BskyLabel @Composable fun ProfileLabels( modifier: Modifier = Modifier, - labels: List + labels: List, + onLabelClicked: (BskyLabel) -> Unit = {}, ) { FlowRow( modifier = modifier @@ -19,8 +20,9 @@ fun ProfileLabels( ProfileLabel( label = it, modifier = modifier - - ) + ) { label -> + onLabelClicked(label) + } } } } \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/UserStatsFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/UserStatsFragment.kt index 53508ff..df6df9e 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/UserStatsFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/profile/UserStatsFragment.kt @@ -43,7 +43,7 @@ public fun UserStatsFragment( color = MaterialTheme.colorScheme.onSurface, ) Text( - text = " Followers", + text = " followers", fontSize = MaterialTheme.typography.labelMedium.fontSize.times(0.9), fontWeight = FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface, @@ -64,7 +64,7 @@ public fun UserStatsFragment( color = MaterialTheme.colorScheme.onSurface, ) Text( - text = " Following", + text = " following", fontSize = MaterialTheme.typography.labelMedium.fontSize.times(0.9), fontWeight = FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface, @@ -86,7 +86,7 @@ public fun UserStatsFragment( textAlign = TextAlign.Start ) Text( - text = " Posts", + text = " posts", fontSize = MaterialTheme.typography.labelMedium.fontSize.times(0.9), fontWeight = FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface, diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/AccessibilitySettings.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/AccessibilitySettings.kt new file mode 100644 index 0000000..8338cd7 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/AccessibilitySettings.kt @@ -0,0 +1,145 @@ +package com.morpho.app.ui.settings + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.morpho.app.data.AccessibilityPreferences +import com.morpho.app.data.MorphoAgent +import com.morpho.app.ui.elements.SettingsGroup +import com.morpho.app.ui.elements.SettingsItem +import org.koin.compose.getKoin + +@Composable +fun AccessibilitySettings( + agent: MorphoAgent = getKoin().get(), + distinguish: Boolean = true, + modifier: Modifier = Modifier, + topLevel: Boolean = true, +) { + val morphoPrefs = agent.morphoPrefs.value + SettingsGroup( + title = if(!topLevel) "Accessibility" else "", + modifier = modifier, + distinguish = distinguish, + ) { + SettingsGroup( + title = "Alt Text", + distinguish = true, + modifier = Modifier.padding(8.dp), + ) { + SettingsItem( description = AnnotatedString("Require Alt Text")) { mod -> + var requireAltText by remember { + mutableStateOf(morphoPrefs.accessibility?.requireAltText ?: false) + } + + Switch( + checked = requireAltText, + onCheckedChange = { + requireAltText = it + agent.setAccessibilityPrefs( + AccessibilityPreferences.toUpdate(requireAltText = requireAltText) + ) + }, + modifier = mod + ) + } + + SettingsItem( description = AnnotatedString("Display larger alt text")) { mod -> + var showLargerAltText by remember { + mutableStateOf(morphoPrefs.accessibility?.displayLargerAltBadge ?: false) + } + + Switch( + checked = showLargerAltText, + onCheckedChange = { + showLargerAltText = it + agent.setAccessibilityPrefs( + AccessibilityPreferences.toUpdate(displayLargerAltBadge = showLargerAltText) + ) + }, + modifier = mod + ) + } + } + + SettingsGroup( + title = "Sensory", + distinguish = true, + modifier = Modifier.padding(8.dp), + ) { + SettingsItem( description = AnnotatedString("Disable autoplay for media")) { mod -> + var disableAutoplay by remember { + mutableStateOf(morphoPrefs.accessibility?.disableAutoplay ?: false) + } + + Switch( + checked = disableAutoplay, + onCheckedChange = { + disableAutoplay = it + agent.setAccessibilityPrefs( + AccessibilityPreferences.toUpdate(disableAutoplay = disableAutoplay) + ) + }, + modifier = mod + ) + } + + SettingsItem( description = AnnotatedString("Reduce/remove animations")) { mod -> + var reduceMotion by remember { + mutableStateOf(morphoPrefs.accessibility?.reduceMotion ?: false) + } + + Switch( + checked = reduceMotion, + onCheckedChange = { + reduceMotion = it + agent.setAccessibilityPrefs( + AccessibilityPreferences.toUpdate(reduceMotion = reduceMotion) + ) + }, + modifier = mod + ) + } + SettingsItem( description = AnnotatedString("Disable haptic feedback")) { mod -> + var disableHaptics by remember { + mutableStateOf(morphoPrefs.accessibility?.disableHaptics ?: false) + } + + Switch( + checked = disableHaptics, + onCheckedChange = { + disableHaptics = it + agent.setAccessibilityPrefs( + AccessibilityPreferences.toUpdate(disableHaptics = disableHaptics) + ) + }, + modifier = mod + ) + } + SettingsItem( description = AnnotatedString("Simplify UI")) { mod -> + var simpleUI by remember { + mutableStateOf(morphoPrefs.accessibility?.simpleUI ?: false) + } + + Switch( + enabled = false, + checked = simpleUI, + onCheckedChange = { + simpleUI = it + agent.setAccessibilityPrefs( + AccessibilityPreferences.toUpdate(simpleUI = simpleUI) + ) + }, + modifier = mod + ) + } + } + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/AdditionalLabelerSettings.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/AdditionalLabelerSettings.kt new file mode 100644 index 0000000..3e5d84e --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/AdditionalLabelerSettings.kt @@ -0,0 +1,107 @@ +package com.morpho.app.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.bsky.labeler.LabelerViewDetailed +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.morpho.app.data.MorphoAgent +import com.morpho.app.model.bluesky.toLabelService +import com.morpho.app.ui.elements.AvatarShape +import com.morpho.app.ui.elements.OutlinedAvatar +import com.morpho.app.ui.elements.SettingsGroup +import kotlinx.coroutines.launch +import org.koin.compose.getKoin + +@Composable +fun AdditionalLabelerSettings( + agent: MorphoAgent = getKoin().get(), + modifier: Modifier = Modifier, + distinguish: Boolean = true, + navigator: Navigator = LocalNavigator.currentOrThrow, +) { + val labelers by agent.labelersDetailed.collectAsState(initial = listOf()) + val scope = rememberCoroutineScope() + val onLabelerClicked: (LabelerViewDetailed) -> Unit = { labeler -> + //TODO: open labeler + scope.launch { + val labelerProfile = labeler.toLabelService(agent) + } + } + SettingsGroup( + title = "Advanced labeler settings", + modifier = modifier, + distinguish = distinguish, + ) { + labelers.forEach { labeler -> + LabelerLink( + labeler = labeler, + onClick = { onLabelerClicked(labeler) }, + modifier = modifier + ) + } + + } +} + +@Composable +fun LabelerLink( + labeler: LabelerViewDetailed, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.medium, + tonalElevation = 2.dp, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + .clickable(onClick = onClick).padding(12.dp), + ) { + OutlinedAvatar( + url = labeler.creator.avatar.orEmpty(), + contentDescription = "Avatar for ${labeler.creator.displayName.orEmpty()}", + size = 50.dp, + avatarShape = AvatarShape.Rounded, + ) + Column( + Modifier.padding(horizontal = 12.dp) + ) { + Text( + text = labeler.creator.displayName.orEmpty(), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "@${labeler.creator.handle.handle}", + style = MaterialTheme.typography.bodyMedium, + ) + } + Spacer(modifier = Modifier.width(6.dp).weight(1F)) + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "Open Labeler", + ) + } + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/AppearanceSettings.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/AppearanceSettings.kt new file mode 100644 index 0000000..7e906b2 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/AppearanceSettings.kt @@ -0,0 +1,108 @@ +package com.morpho.app.ui.settings + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import com.morpho.app.data.DarkModeSetting +import com.morpho.app.data.MorphoAgent +import com.morpho.app.ui.elements.SettingsGroup +import com.morpho.app.ui.elements.SettingsItem +import com.morpho.app.ui.theme.segmentedButtonEnd +import com.morpho.app.ui.theme.segmentedButtonMiddle +import com.morpho.app.ui.theme.segmentedButtonStart +import org.koin.compose.getKoin + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppearanceSettings( + agent: MorphoAgent = getKoin().get(), + modifier: Modifier = Modifier, + distinguish: Boolean = false, + topLevel: Boolean = true, +) { + val morphoPrefs by agent.morphoPrefs.collectAsState(initial = agent.morphoPrefs.value) + SettingsGroup( + title = if(!topLevel) "Appearance" else "", + modifier = modifier, + distinguish = distinguish, + ) { + SettingsItem( text = AnnotatedString("Mode")) { + var darkMode by remember { + mutableStateOf(morphoPrefs.darkMode ?: DarkModeSetting.SYSTEM) + } + SingleChoiceSegmentedButtonRow( + modifier = it + ) { + SegmentedButton( + selected = darkMode == DarkModeSetting.SYSTEM, + onClick = { + darkMode = DarkModeSetting.SYSTEM + agent.setDarkMode(DarkModeSetting.SYSTEM) + }, + shape = segmentedButtonStart.small, + label = { Text("System") }, + ) + SegmentedButton( + selected = darkMode == DarkModeSetting.LIGHT, + onClick = { + darkMode = DarkModeSetting.LIGHT + agent.setDarkMode(DarkModeSetting.LIGHT) + }, + shape = segmentedButtonMiddle, + label = { Text("Light") }, + ) + SegmentedButton( + selected = darkMode == DarkModeSetting.DARK, + onClick = { + darkMode = DarkModeSetting.DARK + agent.setDarkMode(DarkModeSetting.DARK) + }, + shape = segmentedButtonEnd.small, + label = { Text("Dark") }, + ) + + } + } + + SettingsItem(text = AnnotatedString("Interface Style")) { + var tabbed by remember { + mutableStateOf(morphoPrefs.tabbed ?: true) + } + SingleChoiceSegmentedButtonRow( + modifier = it + ) { + SegmentedButton( + selected = tabbed, + enabled = false, + onClick = { + tabbed = true + // TODO: come back when the non-tabbed view is ready + agent.setDarkMode(DarkModeSetting.DARK) + }, + shape = segmentedButtonStart.small, + label = { Text("System") }, + ) + SegmentedButton( + selected = !tabbed, + enabled = false, + onClick = { + tabbed = false + // TODO: come back when the non-tabbed view is ready + agent.setDarkMode(DarkModeSetting.LIGHT) + }, + shape = segmentedButtonEnd.small, + label = { Text("Light") }, + ) + } + } + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/BuiltinContentFilters.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/BuiltinContentFilters.kt new file mode 100644 index 0000000..50383e9 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/BuiltinContentFilters.kt @@ -0,0 +1,267 @@ +package com.morpho.app.ui.settings + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.unit.dp +import app.bsky.actor.Visibility +import com.atproto.label.Severity +import com.morpho.app.data.MorphoAgent +import com.morpho.app.model.bluesky.MorphoDataItem +import com.morpho.app.ui.elements.SettingsGroup +import com.morpho.app.ui.elements.SettingsItem +import com.morpho.app.ui.theme.segmentedButtonEnd +import com.morpho.app.ui.theme.segmentedButtonMiddle +import com.morpho.app.ui.theme.segmentedButtonStart +import com.morpho.butterfly.InterpretedLabelDefinition +import com.morpho.butterfly.localize +import org.koin.compose.getKoin + +@Composable +fun BuiltinContentFilters( + agent: MorphoAgent = getKoin().get(), + modifier: Modifier = Modifier, + distinguish: Boolean = true, +) { + var adultContentEnabled by remember { + mutableStateOf(agent.prefs.modPrefs.adultContentEnabled) + } + + var modPrefs by remember { + mutableStateOf(agent.prefs.modPrefs) + } + + SettingsGroup( + title = "Content filters", + modifier = modifier, + distinguish = distinguish, + ) { + + SettingsItem( + description = AnnotatedString("Enable adult content") + ) { + + Switch( + checked = adultContentEnabled, + onCheckedChange = { + adultContentEnabled = it + agent.toggleAdultContent(it) + } + ) + } + + if(adultContentEnabled) { + BuiltinContentFilterSelector( + labelDefinition = com.morpho.butterfly.Porn.localize(agent.myLanguage.value), + initialFilter = modPrefs.labels[com.morpho.butterfly.Porn.identifier] ?: + com.morpho.butterfly.Porn.defaultSetting, + onSelected = { visibility -> + modPrefs = modPrefs.copy( + labels = modPrefs.labels.toMutableMap().apply { + this[com.morpho.butterfly.Porn.identifier] = visibility + } + ) + agent.setContentLabelPref(com.morpho.butterfly.Porn.identifier, visibility) + } + ) + BuiltinContentFilterSelector( + labelDefinition = com.morpho.butterfly.NSFW.localize(agent.myLanguage.value), + initialFilter = modPrefs.labels[com.morpho.butterfly.NSFW.identifier] ?: + com.morpho.butterfly.NSFW.defaultSetting, + onSelected = { visibility -> + modPrefs = modPrefs.copy( + labels = modPrefs.labels.toMutableMap().apply { + this[com.morpho.butterfly.NSFW.identifier] = visibility + } + ) + agent.setContentLabelPref(com.morpho.butterfly.NSFW.identifier, visibility) + } + ) + BuiltinContentFilterSelector( + labelDefinition = com.morpho.butterfly.GraphicMedia.localize(agent.myLanguage.value), + initialFilter = modPrefs.labels[com.morpho.butterfly.GraphicMedia.identifier] ?: + com.morpho.butterfly.GraphicMedia.defaultSetting, + onSelected = { visibility -> + modPrefs = modPrefs.copy( + labels = modPrefs.labels.toMutableMap().apply { + this[com.morpho.butterfly.GraphicMedia.identifier] = visibility + } + ) + agent.setContentLabelPref(com.morpho.butterfly.GraphicMedia.identifier, visibility) + } + ) + } + BuiltinContentFilterSelector( + labelDefinition = com.morpho.butterfly.Nudity.localize(agent.myLanguage.value), + initialFilter = modPrefs.labels[com.morpho.butterfly.Nudity.identifier] ?: + com.morpho.butterfly.Nudity.defaultSetting, + onSelected = { visibility -> + modPrefs = modPrefs.copy( + labels = modPrefs.labels.toMutableMap().apply { + this[com.morpho.butterfly.Nudity.identifier] = visibility + } + ) + agent.setContentLabelPref(com.morpho.butterfly.Nudity.identifier, visibility) + } + ) + Spacer(modifier = Modifier.height(6.dp)) + } +} + + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ColumnScope.BuiltinContentFilterSelector( + + labelDefinition: InterpretedLabelDefinition, + initialFilter: Visibility, + onSelected: (Visibility) -> Unit, + modifier: Modifier = Modifier, +) { + var setting by remember { mutableStateOf(initialFilter) } + val text = buildAnnotatedString { + pushStyle(MaterialTheme.typography.titleSmall.toSpanStyle().copy( + color = MaterialTheme.colorScheme.onSurface + )) + append("${labelDefinition.localizedName}\n") + pop() + pushStyle(MaterialTheme.typography.bodyMedium.toSpanStyle().copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + )) + append(labelDefinition.localizedDescription) + pop() + + toAnnotatedString() + } + + SettingsItem( + text = text, + modifier = modifier + ) { + SingleChoiceSegmentedButtonRow( + modifier = it + ) { + SegmentedButton( + selected = setting == Visibility.SHOW || setting == Visibility.IGNORE, + onClick = { + setting = Visibility.SHOW + onSelected(Visibility.SHOW) + }, + shape = segmentedButtonStart.small, + label = { Text(text = "Show") } + ) + SegmentedButton( + selected = setting == Visibility.WARN, + onClick = { + setting = Visibility.WARN + onSelected(Visibility.WARN) + }, + shape = segmentedButtonMiddle, + label = { Text(text = "Warn") } + ) + SegmentedButton( + selected = setting == Visibility.HIDE, + onClick = { + setting = Visibility.HIDE + onSelected(Visibility.HIDE) + }, + shape = segmentedButtonEnd.small, + label = { Text(text = "Hide") } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContentLabelSelector( + labelItem: MorphoDataItem.ModLabel, + onSelected: (Visibility) -> Unit, + modifier: Modifier = Modifier, +) { + + var setting by remember { mutableStateOf(labelItem.setting) } + val label = labelItem.label + val onItemClicked: (Visibility) -> Unit = remember { onSelected } + val text = buildAnnotatedString { + pushStyle(MaterialTheme.typography.titleSmall.toSpanStyle().copy( + color = MaterialTheme.colorScheme.onSurface + )) + append("${label.localizedName}\n") + pop() + pushStyle(MaterialTheme.typography.bodyMedium.toSpanStyle().copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + )) + append(label.localizedDescription) + pop() + + toAnnotatedString() + } + val middleButtonText = remember { + if(label.severity == Severity.INFORM) { + "Show badge" + } else { + "Warn" + } + } + + SettingsItem( + text = text, + modifier = modifier.padding(horizontal = 8.dp), + spacing = 8.dp, + ) { + SingleChoiceSegmentedButtonRow( + modifier = it + ) { + SegmentedButton( + selected = setting == Visibility.SHOW || setting == Visibility.IGNORE, + onClick = { + setting = Visibility.SHOW + onItemClicked(Visibility.SHOW) + }, + shape = segmentedButtonStart.small, + label = { Text(text = "Show") } + ) + SegmentedButton( + selected = setting == Visibility.WARN || setting == Visibility.INFORM, + onClick = { + if(label.severity == Severity.INFORM) { + setting = Visibility.INFORM + onItemClicked(Visibility.INFORM) + } else { + setting = Visibility.WARN + onItemClicked(Visibility.WARN) + } + }, + shape = segmentedButtonMiddle, + label = { Text(text = middleButtonText) } + ) + SegmentedButton( + selected = setting == Visibility.HIDE, + onClick = { + setting = Visibility.HIDE + onItemClicked(Visibility.HIDE) + }, + shape = segmentedButtonEnd.small, + label = { Text(text = "Hide") } + ) + } + } + +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/FeedPreferences.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/FeedPreferences.kt new file mode 100644 index 0000000..2318062 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/FeedPreferences.kt @@ -0,0 +1,238 @@ +package com.morpho.app.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import app.bsky.actor.FeedViewPref +import com.morpho.app.data.MorphoAgent +import com.morpho.app.ui.elements.SettingsGroup +import com.morpho.app.ui.elements.SettingsItem +import com.morpho.app.ui.elements.WrappedColumn +import org.koin.compose.getKoin + +@Composable +fun FeedPreferences( + agent: MorphoAgent = getKoin().get(), + modifier: Modifier = Modifier, + distinguish: Boolean = true, + topLevel: Boolean = true, +) { + val feedPrefs = agent.prefs.feedView ?: FeedViewPref( + feed = "following", + hideReplies = false, + hideRepliesByUnfollowed = true, + hideRepliesByLikeCount = 0, + hideReposts = false, + hideQuotePosts = false, + lab_mergeFeedEnabled = true, + ) + SettingsGroup( + title = if(!topLevel) "Following Feed Preferences" else "", + modifier = modifier, + distinguish = distinguish, + ) { + WrappedColumn { + SettingsItem( + text = AnnotatedString("Show replies"), + description = AnnotatedString("Show any replies in the following feed at all?"), + ) { + var showReplies by mutableStateOf(feedPrefs.hideReplies != true) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = it + ) { + Switch( + checked = showReplies, + thumbContent = { + if (showReplies) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + }, + onCheckedChange = { + showReplies = it + agent.setFeedViewPrefs( + feed = "following", + feedViewPref = feedPrefs.copy(hideReplies = !showReplies) + ) + } + ) + Text( + text = if (showReplies) "Show" else "Hide", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + if (feedPrefs.hideReplies != true) { + SettingsItem( + text = AnnotatedString("Show replies by unfollowed"), + description = AnnotatedString("Show replies by people you don't follow, but who are replying to people you do follow?"), + ) { + var showRepliesByUnfollowed by mutableStateOf(feedPrefs.hideRepliesByUnfollowed != true) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = it + ) { + Switch( + checked = showRepliesByUnfollowed, + thumbContent = { + if (showRepliesByUnfollowed) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + }, + onCheckedChange = { + showRepliesByUnfollowed = it + agent.setFeedViewPrefs( + feed = "following", + feedViewPref = feedPrefs.copy(hideRepliesByUnfollowed = !showRepliesByUnfollowed) + ) + } + ) + Text( + text = if (showRepliesByUnfollowed) "Show" else "Hide", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + SettingsItem( + text = AnnotatedString("Show reposts"), + description = AnnotatedString("Show reposts in the following feed?"), + ) { + var showReposts by mutableStateOf(feedPrefs.hideReposts != true) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = it + ) { + Switch( + checked = showReposts, + thumbContent = { + if (showReposts) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + }, + onCheckedChange = { + showReposts = it + agent.setFeedViewPrefs( + feed = "following", + feedViewPref = feedPrefs.copy(hideReposts = !showReposts) + ) + } + ) + Text( + text = if (showReposts) "Show" else "Hide", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + SettingsItem( + text = AnnotatedString("Show quote posts"), + description = AnnotatedString( + "Show quote posts in the following feed? (reposts will still be visible, if set to show)" + ), + ) { + var showQuotePosts by mutableStateOf(feedPrefs.hideQuotePosts != true) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = it + ) { + Switch( + checked = showQuotePosts, + thumbContent = { + if (showQuotePosts) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + }, + onCheckedChange = { + showQuotePosts = it + agent.setFeedViewPrefs( + feed = "following", + feedViewPref = feedPrefs.copy(hideQuotePosts = !showQuotePosts) + ) + } + ) + Text( + text = if (showQuotePosts) "Show" else "Hide", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + SettingsItem( + text = AnnotatedString("Merge feeds into Following"), + description = AnnotatedString("Occasionally show posts from your saved feeds in your following feed?"), + ) { + var mergeFeeds by mutableStateOf(feedPrefs.lab_mergeFeedEnabled != null) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = it + ) { + + Switch( + checked = mergeFeeds, + thumbContent = { + if (mergeFeeds) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + }, + onCheckedChange = { + mergeFeeds = it + agent.setFeedViewPrefs( + feed = "following", + feedViewPref = feedPrefs.copy(lab_mergeFeedEnabled = mergeFeeds) + ) + } + ) + Text( + text = if (mergeFeeds) "Yes" else "No", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 8.dp) + ) + + } + } + } + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/LanguageSettings.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/LanguageSettings.kt new file mode 100644 index 0000000..42a0251 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/LanguageSettings.kt @@ -0,0 +1,156 @@ +package com.morpho.app.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.morpho.app.data.MorphoAgent +import com.morpho.app.ui.elements.SettingsGroup +import com.morpho.app.ui.elements.SettingsItem +import com.morpho.butterfly.Language +import org.koin.compose.getKoin + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LanguageSettings( + agent: MorphoAgent = getKoin().get(), + modifier: Modifier = Modifier, + distinguish: Boolean = true, + topLevel: Boolean = true, +) { + val morphoPrefs = agent.morphoPrefs.value + SettingsGroup( + title = if(!topLevel) "Language Settings" else "", + modifier = modifier, + distinguish = distinguish, + ) { + SettingsItem(text = AnnotatedString("App Language")) { + LanguageDropDownMenu( + onSelected = { lang -> + agent.setUILanguage(lang) + }, + initialLanguage = morphoPrefs.uiLanguage ?: agent.myLanguage.value + ) + } + } +} + +@Composable +fun LanguageDropDownMenu( + onSelected: (Language) -> Unit, + initialLanguage: Language, + expandedInitially: Boolean = false, +) { + Box(Modifier.height(100.dp).fillMaxWidth()) { + val shape = MaterialTheme.shapes.medium + var expanded by remember { mutableStateOf(expandedInitially) } + var language by remember { mutableStateOf(initialLanguage) } + val onItemClicked: (Language) -> Unit = { lang -> + language = lang + onSelected(lang) + expanded = false + } + Button( + onClick = { expanded = !expanded }, + shape = shape, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(12.dp) + ), + modifier = Modifier.width(240.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp) + ) { + Text( + initialLanguage.toLanguageName() + ) + Spacer(Modifier.width(4.dp).weight(1f)) + Icon( + Icons.Rounded.KeyboardArrowDown, + null, + ) + } + } + DropdownMenu(modifier = Modifier.align(Alignment.TopCenter).width(240.dp), expanded = expanded, + onDismissRequest = { expanded = false }) { + + DropdownMenuItem(text = { Text(Language("en").toLanguageName()) }, onClick = { + onItemClicked(Language("en")) + }) + DropdownMenuItem(text = { Text(Language("pt").toLanguageName()) }, onClick = { + onItemClicked(Language("pt")) + }) + DropdownMenuItem(text = { Text(Language("fr").toLanguageName()) }, onClick = { + onItemClicked(Language("fr")) + }) + DropdownMenuItem(text = { Text(Language("es").toLanguageName()) }, onClick = { + onItemClicked(Language("es")) + }) + DropdownMenuItem(text = { Text(Language("de").toLanguageName()) }, onClick = { + onItemClicked(Language("de")) + }) + DropdownMenuItem(text = { Text(Language("ar").toLanguageName()) }, onClick = { + onItemClicked(Language("ar")) + }) + DropdownMenuItem(text = { Text(Language("tr").toLanguageName()) }, onClick = { + onItemClicked(Language("tr")) + }) + DropdownMenuItem(text = { Text(Language("ru").toLanguageName()) }, onClick = { + onItemClicked(Language("ru")) + }) + DropdownMenuItem(text = { Text(Language("it").toLanguageName()) }, onClick = { + onItemClicked(Language("it")) + }) + DropdownMenuItem(text = { Text(Language("ja").toLanguageName()) }, onClick = { + onItemClicked(Language("ja")) + }) + DropdownMenuItem(text = { Text(Language("ko").toLanguageName()) }, onClick = { + onItemClicked(Language("ko")) + }) + + } + } +} + +fun Language.toLanguageName(): String { + return when(this) { + Language("en") -> "English" + Language("pt") -> "Português - Portuguese" + Language("fr") -> "Français - French" + Language("es") -> "Español - Spanish" + Language("de") -> "Deutsch - German" + Language("ar") -> "العربية - Arabic" + Language("tr") -> "Türkçe - Turkish" + Language("ru") -> "Русский - Russian" + Language("it") -> "Italiano - Italian" + Language("ja") -> "日本語 - Japanese" + Language("ko") -> "한국어 - Korean" + else -> "Not handled yet" + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/ModerationSettingsFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/ModerationSettingsFragment.kt new file mode 100644 index 0000000..a6715e6 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/ModerationSettingsFragment.kt @@ -0,0 +1,47 @@ +package com.morpho.app.ui.settings + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.morpho.app.data.MorphoAgent +import com.morpho.app.ui.elements.WrappedLazyColumn +import org.koin.compose.getKoin + +@Composable +fun ModerationSettingsFragment( + agent: MorphoAgent = getKoin().get(), + modifier: Modifier = Modifier, + navigator: Navigator = LocalNavigator.currentOrThrow, +) { + WrappedLazyColumn( + modifier = modifier + ) { + item { + PersonalModSettings( + agent = agent, + distinguish = true, + navigator = navigator, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + item { + BuiltinContentFilters( + agent = agent, + distinguish = true, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + item { + AdditionalLabelerSettings( + agent = agent, + distinguish = true, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/MutedWordsSettings.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/MutedWordsSettings.kt new file mode 100644 index 0000000..35ff48d --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/MutedWordsSettings.kt @@ -0,0 +1,413 @@ +package com.morpho.app.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedIconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import app.bsky.actor.MuteTargetGroup +import app.bsky.actor.MutedWord +import com.morpho.app.data.MorphoAgent +import com.morpho.app.model.uidata.Moment +import com.morpho.app.ui.elements.WrappedColumn +import com.morpho.app.ui.elements.WrappedLazyColumn +import com.morpho.app.util.getFormattedDateTimeSince +import com.morpho.butterfly.model.Timestamp +import com.morpho.butterfly.mutedWordContent +import com.morpho.butterfly.mutedWordTag +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import org.koin.compose.getKoin +import kotlin.time.Duration + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MutedWordsSettings( + agent: MorphoAgent = getKoin().get(), + scope: CoroutineScope = rememberCoroutineScope(), + modifier: Modifier = Modifier, +) { + var word: TextFieldValue by remember { mutableStateOf(TextFieldValue("")) } + val focusManager = LocalFocusManager.current + var duration by remember { mutableStateOf(MuteDuration.FOREVER) } + var target by remember { mutableStateOf(MuteTargetGroup.ALL) } + var targetType by remember { mutableStateOf(mutedWordContent) } + val mutedWords = agent.prefs.modPrefs.mutedWords.toMutableStateList() + WrappedLazyColumn ( + modifier = modifier.fillMaxWidth() + ) { + val verticalPadding = 8.dp + item { + WrappedColumn( + horizontalAlignment = Alignment.Start, + modifier = Modifier.fillMaxWidth() + ) { + Text( + "Edit muted words and tags", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = verticalPadding) + ) + Text( + "Posts can be muted based on their text, tags, or both. " + + "Avoid muting very common words, phrases, or tags, " + + "as this can prevent you from seeing essentially any posts.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = verticalPadding, horizontal = 8.dp) + ) + OutlinedTextField( + value = word, + placeholder = { Text(text = "Enter a word or tag to mute") }, + onValueChange = { text: TextFieldValue -> + word = text + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + modifier = Modifier.padding(vertical = verticalPadding, horizontal = 8.dp).fillMaxWidth() + ) + Text( + "Duration", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(vertical = verticalPadding) + ) + FlowRow( + modifier = Modifier.fillMaxWidth().padding(vertical = verticalPadding) + ) { + MutedWordDurationSelector( + initialDuration = duration, + text = "Forever", + value = MuteDuration.FOREVER, + onSelected = { duration = it } + ) + MutedWordDurationSelector( + initialDuration = duration, + text = "1 day", + value = MuteDuration.ONE_DAY, + onSelected = { duration = it } + ) + MutedWordDurationSelector( + initialDuration = duration, + text = "1 week", + value = MuteDuration.ONE_WEEK, + onSelected = { duration = it } + ) + MutedWordDurationSelector( + initialDuration = duration, + text = "1 month", + value = MuteDuration.ONE_MONTH, + onSelected = { duration = it } + ) + + } + Text( + "Mute in:", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(vertical = verticalPadding) + ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = verticalPadding) + ) { + Surface( + shape = MaterialTheme.shapes.small, + tonalElevation = 8.dp, + //color = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), + modifier = Modifier.padding(horizontal = 4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp) + ) { + RadioButton( + selected = targetType == mutedWordContent, + onClick = { + targetType = mutedWordContent + } + ) + Text( + text = "Text and Tags", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 8.dp) + ) + } + } + Surface( + shape = MaterialTheme.shapes.small, + tonalElevation = 8.dp, + //color = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), + modifier = Modifier.padding(horizontal = 4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp) + ) { + RadioButton( + selected = targetType == mutedWordTag, + onClick = { + targetType = mutedWordTag + } + ) + Text( + text = "Tags Only", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 8.dp) + ) + } + } + } + Text( + "Additional options:", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(vertical = verticalPadding) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = verticalPadding) + ){ + Switch( + checked = target == MuteTargetGroup.EXCLUDE_FOLLOWING, + onCheckedChange = { + target = if(it) MuteTargetGroup.EXCLUDE_FOLLOWING else MuteTargetGroup.ALL + } + ) + Text( + text = "Exclude users that you follow", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + FilledTonalButton( + enabled = word.text.isNotEmpty(), + onClick = { + val now = Clock.System.now() + val expiresAt: Timestamp? = if(duration == MuteDuration.FOREVER) null + else now.plus(duration.duration) + val newWord = MutedWord( + value = word.text, + targets = if(targetType == mutedWordContent) persistentListOf( + mutedWordContent, + mutedWordTag + ) else persistentListOf(mutedWordTag), + actorTarget = target, + expiresAt = expiresAt?.toString(), + ) + mutedWords.add(newWord) + scope.launch { + agent.updateMutedWord(newWord) + } + }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.padding(verticalPadding).fillMaxWidth() + ) { + Text( + text = "Add", + style = MaterialTheme.typography.labelMedium, + ) + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add", + ) + } + HorizontalDivider(Modifier.fillMaxWidth().padding(vertical = verticalPadding)) + Text( + "Words you have muted", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(vertical = verticalPadding) + ) + } + } + if(mutedWords.isEmpty()) { + item { + Surface( + shape = MaterialTheme.shapes.small, + tonalElevation = 8.dp, + //color = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), + modifier = Modifier.fillMaxWidth().padding( verticalPadding), + ) { + Text( + text = "No muted words", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp) + ) + } + } + } else { + items(mutedWords) { + MutedWordListItem( + word = it, + onRemoveClicked = { + mutedWords.remove(it) + scope.launch { + agent.removeMutedWord(it) + + } + } + ) + } + + } + + + } +} + +@Composable +fun MutedWordListItem( + word: MutedWord, + onRemoveClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + shape = MaterialTheme.shapes.small, + tonalElevation = 8.dp, + //color = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), + modifier = modifier.fillMaxWidth().padding(4.dp), + ) { + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.padding(4.dp) + ) { + Column { + val valueAndTargets = buildAnnotatedString { + pushStyle( + SpanStyle(fontWeight = FontWeight.Bold) + ) + append(word.value) + pop() + append(" in ") + pushStyle( + SpanStyle(fontWeight = FontWeight.SemiBold) + ) + val targetsString = if(word.targets.contains(mutedWordContent) && word.targets.contains(mutedWordTag)) { + "Text and Tags" + } else if(word.targets.contains(mutedWordContent)) { + "Text" + } else if(word.targets.contains(mutedWordTag)) { + "Tags" + } else word.targets.joinToString(", ") { it.mutedWordTarget } + append(targetsString) + pop() + + toAnnotatedString() + } + Text( + text = valueAndTargets, + style = MaterialTheme.typography.bodyMedium, + ) + val expiry = word.expiresAt?.let {Moment( Instant.parse(it)) } + val timeToExpire = if(expiry != null) { + getFormattedDateTimeSince(expiry) + } else null + val expiryAndExludes = buildAnnotatedString { + if(timeToExpire != null) { + append("Expires in ") + append(timeToExpire) + pushStyle( + SpanStyle(fontWeight = FontWeight.SemiBold) + ) + append(" - ") + pop() + } + if(word.actorTarget != MuteTargetGroup.ALL) { + append("Excludes users that you follow") + } + toAnnotatedString() + } + Text( + text = expiryAndExludes, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + + } + OutlinedIconButton( + onClick = onRemoveClicked, + modifier = Modifier.padding(4.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + ) + } + } + } +} + +@Composable +fun MutedWordDurationSelector( + initialDuration: MuteDuration, + text: String, + value: MuteDuration, + onSelected: (MuteDuration) -> Unit, + modifier: Modifier = Modifier, +) { + var duration by remember { mutableStateOf(initialDuration) } + Surface( + shape = MaterialTheme.shapes.small, + tonalElevation = 8.dp, + color = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), + modifier = modifier.padding(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp) + ) { + RadioButton( + selected = duration == value, + onClick = { + duration = value + onSelected(value) + } + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 12.dp) + ) + } + } +} + +enum class MuteDuration(val duration: Duration, val text: String) { + FOREVER(Duration.INFINITE, "Forever"), + ONE_DAY(Duration.parse("24h"), "24 hours"), + ONE_WEEK(Duration.parse("7d"), "7 days"), + ONE_MONTH(Duration.parse("30d"), "30 days"), +} + diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/PersonalModSettings.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/PersonalModSettings.kt new file mode 100644 index 0000000..c639832 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/PersonalModSettings.kt @@ -0,0 +1,125 @@ +package com.morpho.app.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.ReduceCapacity +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.morpho.app.data.MorphoAgent +import com.morpho.app.ui.elements.SettingsGroup +import com.morpho.app.ui.elements.SettingsItem +import kotlinx.serialization.Serializable +import org.koin.compose.getKoin + +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@Composable +fun PersonalModSettings( + agent: MorphoAgent = getKoin().get(), + modifier: Modifier = Modifier, + distinguish: Boolean = true, + navigator: Navigator = LocalNavigator.currentOrThrow, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + var sheetOption by remember { mutableStateOf(SheetOption.Hide) } + SettingsGroup( + title = "Moderation tools", + modifier = modifier, + distinguish = distinguish, + ) { + SettingsItem( + description = AnnotatedString("Muted words and tags"), + modifier = Modifier.clickable { + sheetOption = SheetOption.MuteWords + } + ){ + Icon( + Icons.Default.FilterAlt, + contentDescription = "Filter", + ) + } + + SettingsItem( + description = AnnotatedString("Moderation lists"), + modifier = Modifier.clickable { + + } + ){ + Icon( + Icons.Default.ReduceCapacity, + contentDescription = "People", + ) + } + + SettingsItem( + description = AnnotatedString("Muted accounts"), + modifier = Modifier.clickable { + + } + ) { + Icon( + Icons.Default.VisibilityOff, + contentDescription = "Mute/Hide", + ) + } + + SettingsItem( + description = AnnotatedString("Blocked accounts"), + modifier = Modifier.clickable { + + } + ){ + Icon( + Icons.Default.Block, + contentDescription = "Block", + ) + } + } + if(sheetOption != SheetOption.Hide) { + ModalBottomSheet( + onDismissRequest = { + sheetOption = SheetOption.Hide + }, + sheetState = sheetState + ) { + when(sheetOption) { + SheetOption.MuteWords -> { + MutedWordsSettings( + agent = agent, + scope = scope, + modifier = Modifier.padding(16.dp) + ) + } + SheetOption.Hide -> {} + } + } + } + +} + +@Serializable +@Immutable +enum class SheetOption { + MuteWords, + Hide +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/SettingsFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/SettingsFragment.kt new file mode 100644 index 0000000..1be0bd4 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/SettingsFragment.kt @@ -0,0 +1,173 @@ +package com.morpho.app.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Accessibility +import androidx.compose.material.icons.filled.BackHand +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.ImagesearchRoller +import androidx.compose.material.icons.filled.RssFeed +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.koin.koinNavigatorScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.morpho.app.data.MorphoAgent +import com.morpho.app.screens.main.tabbed.TabbedMainScreenModel +import com.morpho.app.screens.settings.AccessibilitySettingsScreen +import com.morpho.app.screens.settings.AppearanceSettingsScreen +import com.morpho.app.screens.settings.FeedSettingsScreen +import com.morpho.app.screens.settings.LanguageSettingsScreen +import com.morpho.app.screens.settings.ModerationSettingsScreen +import com.morpho.app.screens.settings.NotificationsSettingsScreen +import com.morpho.app.screens.settings.ThreadSettingsScreen +import com.morpho.app.ui.elements.SettingsItem +import org.koin.compose.getKoin + +@Composable +fun SettingsFragment( + navigator: Navigator = LocalNavigator.currentOrThrow, + sm: TabbedMainScreenModel = navigator.koinNavigatorScreenModel(), + modifier: Modifier = Modifier, + +) { + Column( + modifier = Modifier + .fillMaxWidth().then(modifier) + + ) { + UserManagement(sm = sm, navigator = navigator, + profiles = sm.agent.getAccounts(), + myProfile = sm.userProfile, + modifier = Modifier.padding(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Basics", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 12.dp) + ) + Surface( + elevation = 2.dp, + modifier = Modifier.padding(vertical = 8.dp) + ) { + Column { + SettingsItem( + description = AnnotatedString("Accessibility"), + modifier = Modifier.clickable { + navigator.push(AccessibilitySettingsScreen) + }.fillMaxWidth() + ) { + Icon(Icons.Default.Accessibility, contentDescription = "Accessibility") + } + SettingsItem( + description = AnnotatedString("Appearance"), + modifier = Modifier.clickable { + navigator.push(AppearanceSettingsScreen) + } + ) { + Icon(Icons.Default.ImagesearchRoller, contentDescription = "Appearance") + } + SettingsItem( + description = AnnotatedString("Languages"), + modifier = Modifier.clickable { + navigator.push(LanguageSettingsScreen) + } + ) { + Icon(Icons.Default.Translate, contentDescription = "Languages") + } + + SettingsItem( + description = AnnotatedString("Moderation"), + modifier = Modifier.clickable { + navigator.push(ModerationSettingsScreen) + } + ) { + Icon(Icons.Default.BackHand, contentDescription = "Moderation") + } + SettingsItem( + description = AnnotatedString("Notifications filtering"), + modifier = Modifier.clickable { + navigator.push(NotificationsSettingsScreen) + } + ) { + Icon(Icons.Default.FilterAlt, contentDescription = "Notifications") + } + SettingsItem( + description = AnnotatedString("Following Feed Preferences"), + modifier = Modifier.clickable { + navigator.push(FeedSettingsScreen) + } + ) { + Icon(Icons.Default.Tune, contentDescription = "Following Feed Preferences") + } + + SettingsItem( + description = AnnotatedString("Thread Preferences"), + modifier = Modifier.clickable { + navigator.push(ThreadSettingsScreen) + } + ) { + Icon(Icons.Default.Forum, contentDescription = "Thread Preferences") + } + SettingsItem( + description = AnnotatedString("My Saved Feeds"), + modifier = Modifier.clickable { } + ) { + Icon(Icons.Default.RssFeed, contentDescription = "My Saved Feeds") + } + + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Advanced", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 12.dp) + ) + Surface( + elevation = 2.dp, + modifier = Modifier.padding(vertical = 8.dp) + ){ + + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + onClick = { }, + colors = ButtonDefaults.elevatedButtonColors(), + modifier = Modifier.padding(vertical = 8.dp).padding(horizontal = 12.dp), + shape = RectangleShape, + ) { + Text("System Log", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier + ) + } + val version = com.morpho.app.BuildKonfig.versionString + Text( + "Version $version", + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.onBackground + ) + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/UserManagement.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/UserManagement.kt new file mode 100644 index 0000000..ed72355 --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/settings/UserManagement.kt @@ -0,0 +1,216 @@ +package com.morpho.app.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.DisableSelection +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.DropdownMenu +import androidx.compose.material.Surface +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.morpho.app.data.MorphoAgent +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.ui.elements.AvatarShape +import com.morpho.app.ui.elements.OutlinedAvatar +import com.morpho.app.ui.elements.SettingsGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.MenuItemColors +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import cafe.adriel.voyager.koin.koinNavigatorScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.navigator.tab.LocalTabNavigator +import com.morpho.app.screens.base.tabbed.MyProfileTab +import com.morpho.app.screens.login.LoginScreen +import com.morpho.app.screens.main.tabbed.TabbedMainScreenModel +import com.morpho.butterfly.Did +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import org.koin.compose.getKoin + +@Composable +fun UserManagement( + navigator: Navigator = LocalNavigator.currentOrThrow, + sm: TabbedMainScreenModel = navigator.koinNavigatorScreenModel(), + profiles: Flow> = sm.agent.getAccounts(), + myProfile: DetailedProfile? = null, + modifier: Modifier = Modifier, + distinguish: Boolean = false, + topLevel: Boolean = false, +) { + val users = profiles.collectAsState(initial = listOf()) + val loggedInUser = users.value.firstOrNull { it.did == sm.agent.id } + val otherUsers = users.value.filterNot { it.did == loggedInUser?.did } + val mainNav = remember { + when (navigator.level) { + 0 -> navigator + else -> navigator.parent!! + } + } + println("Users: $users") + println("Logged in user: $loggedInUser") + println("Other users: $otherUsers") + val rootNav = LocalTabNavigator.current + val menuOptionClicked: (AccountMenuOption, Did) -> Unit = remember { + { option, did -> + when(option) { + AccountMenuOption.RemoveAccount -> { + sm.agent.removeAccount(did) + if(sm.agent.id == did) { + sm.logout() + mainNav.popUntilRoot() + rootNav.current = LoginScreen + } + } + AccountMenuOption.LogOut -> { + sm.logout() + mainNav.popUntilRoot() + rootNav.current = LoginScreen + } + } + } + } + SettingsGroup( + title = if(!topLevel) "Account" else "", + modifier = modifier.fillMaxWidth(), + distinguish = distinguish, + ) { + if(loggedInUser != null || myProfile != null) { + val profile = loggedInUser ?: myProfile!! + Text( + text = "Logged in as ${profile.displayName ?: profile.handle.handle}", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(12.dp) + ) + AccountItem( + profile = profile, + onClick = { + mainNav.push(MyProfileTab) + }, + onMenuClicked = menuOptionClicked + ) + } + Text( + text = "Other accounts", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(12.dp) + ) + otherUsers.forEach { profile -> + AccountItem( + profile = profile, + onClick = { + sm.switchUser(profile.did) + mainNav.popUntilRoot() + }, + onMenuClicked = menuOptionClicked + ) + } + } + +} + +@Composable +fun AccountItem( + profile: DetailedProfile, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onMenuClicked: (AccountMenuOption, Did) -> Unit = { _, _ -> }, +) { + Row( + modifier = modifier.padding(12.dp).fillMaxWidth().clickable { onClick() } + ) { + OutlinedAvatar( + url = profile.avatar.orEmpty(), + contentDescription = "Avatar for ${profile.displayName}", + size = 50.dp, + avatarShape = AvatarShape.Rounded, + outlineColor = MaterialTheme.colorScheme.background, + ) + Column( + Modifier.padding(horizontal = 12.dp).weight(1f) + ) { + val name = profile.displayName ?: profile.handle.handle + Text( + text = name, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "${profile.handle}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + ) + } + var showMenu by remember { mutableStateOf(false) } + IconButton( + onClick = { + showMenu = !showMenu + } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Menu", + ) + DisableSelection { + AccountMenu(expanded = showMenu, onItemClicked = { + showMenu = false + onMenuClicked(it, profile.did) + }, onDismissRequest = { + showMenu = false + }) + } + } + + + } +} + +enum class AccountMenuOption(val value: String) { + RemoveAccount("Remove Account"), + LogOut("Log Out"), +} + +@Composable +fun AccountMenu( + expanded : Boolean = false, + onItemClicked: (AccountMenuOption) -> Unit = {}, + onDismissRequest: () -> Unit = {}, +) { + DropdownMenu( + expanded = expanded, onDismissRequest = {onDismissRequest()}, + modifier = Modifier.background( + MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + RoundedCornerShape(2.dp) + ) + ) { + AccountMenuOption.entries.forEach { + DropdownMenuItem(text = { Text(it.value) }, colors = MenuDefaults.itemColors().copy( + textColor = MaterialTheme.colorScheme.onSurface, + ), onClick = { onItemClicked(it) }) + } + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Color.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Color.kt index 7bd91b5..6dd94c7 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Color.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Color.kt @@ -7,13 +7,13 @@ import kotlin.math.max import kotlin.math.min -val morphoLightPrimary = Color(0xffb05cce) +val morphoLightPrimary =Color(0xff5079be) val morphoLightOnPrimary = Color(0xffdde2e7) -val morphoLightPrimaryContainer = Color(0xffbf75d6) +val morphoLightPrimaryContainer = Color(0xff95b4ea) val morphoLightOnPrimaryContainer = Color(0xff3a3746) -val morphoLightSecondary = Color(0xff5079be) +val morphoLightSecondary = Color(0xffb05cce) val morphoLightOnSecondary = Color(0xffc5cdd9) -val morphoLightSecondaryContainer = Color(0xff6996e0) +val morphoLightSecondaryContainer = Color(0xffcea1de) val morphoLightOnSecondaryContainer = Color(0xff313a44) val morphoLightTertiary = Color(0xff608e32) val morphoLightOnTertiary = Color(0xffe5eee4) @@ -35,32 +35,32 @@ val morphoLightInverseSurface = Color(0xff2a2b34) val morphoLightPrimaryInverse = Color(0xff2c1635) val morphoLightSurfaceDim = Color(0xffBAC3CB) -val morphoDarkPrimary = Color(0xffd38aea) -val morphoDarkOnPrimary = Color(0xff202023) -val morphoDarkPrimaryContainer = Color(0xffd38aea) -val morphoDarkOnPrimaryContainer = Color(0xffc5cdd9) -val morphoDarkSecondary = Color(0xff6cb6eb) -val morphoDarkOnSecondary = Color(0xff354157) -val morphoDarkSecondaryContainer = Color(0xff5cb6eb) +val morphoDarkPrimary = Color(0xff7f93e8) +val morphoDarkOnPrimary = Color(0xff242934) +val morphoDarkPrimaryContainer = Color(0xff6c8aeb) +val morphoDarkOnPrimaryContainer = Color(0xff22222d) +val morphoDarkSecondary = Color(0xffd38aea) +val morphoDarkOnSecondary = Color(0xff3f3557) +val morphoDarkSecondaryContainer =Color(0xff8f5da1) val morphoDarkOnSecondaryContainer = Color(0xffc5cdd9) val morphoDarkTertiary = Color(0xffa0c980) val morphoDarkOnTertiary = Color(0xff394634) val morphoDarkTertiaryContainer = Color(0xffa0c980) -val morphoDarkOnTertiaryContainer = Color(0xffc2bcea) +val morphoDarkOnTertiaryContainer = Color(0xffc6eabc) val morphoDarkError = Color(0xffec7279) val morphoDarkErrorContainer = Color(0xff55393d) val morphoDarkOnError = Color(0xff55393d) val morphoDarkOnErrorContainer = Color(0xffec7279) val morphoDarkBackground = Color(0xff2c2e34) -val morphoDarkOnBackground = Color(0xFFEAE1D9) +val morphoDarkOnBackground = Color(0xffb6b5c2) val morphoDarkSurface = Color(0xff33353f) -val morphoDarkOnSurface = Color(0xffd9eadb) +val morphoDarkOnSurface = Color(0xfff6f6f6) val morphoDarkSurfaceVariant = Color(0xff414550) val morphoDarkOnSurfaceVariant = Color(0xffc5cdd9) val morphoDarkOutline = Color(0xff80849c) val morphoDarkInverseOnSurface = Color(0xff535c6a) val morphoDarkSurfaceDim = Color(0xff24262a) -val morphoDarkInverseSurface = Color(0xffead9ea) +val morphoDarkInverseSurface = Color(0xffd7dee3) val morphoDarkPrimaryInverse = Color(0xff492452) fun Color.contrastAgainst(background: Color): Float { diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Shape.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Shape.kt index f03cc54..7e7e561 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Shape.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Shape.kt @@ -3,8 +3,11 @@ package com.morpho.app.ui.theme import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.ShapeDefaults import androidx.compose.material3.Shapes +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp + + val roundedTopLBotR = Shapes( extraSmall = ShapeDefaults.ExtraSmall.copy(topEnd = CornerSize(0.dp), bottomStart = CornerSize(0.dp)), small = ShapeDefaults.Small.copy(topEnd = CornerSize(0.dp), bottomStart = CornerSize(0.dp)), @@ -67,4 +70,52 @@ val roundedBotR = Shapes( topStart = CornerSize(0.dp), bottomStart = CornerSize(0.dp), ) -) \ No newline at end of file +) + +val segmentedButtonMiddle = RectangleShape + +val segmentedButtonStart = Shapes( + extraSmall = ShapeDefaults.ExtraSmall.copy( + bottomEnd = CornerSize(0.dp), + topEnd = CornerSize(0.dp), + ), + small = ShapeDefaults.Small.copy( + bottomEnd = CornerSize(0.dp), + topEnd = CornerSize(0.dp), + ), + medium = ShapeDefaults.Medium.copy( + bottomEnd = CornerSize(0.dp), + topEnd = CornerSize(0.dp), + ), + large = ShapeDefaults.Large.copy( + bottomEnd = CornerSize(0.dp), + topEnd = CornerSize(0.dp), + ), + extraLarge = ShapeDefaults.ExtraLarge.copy( + bottomEnd = CornerSize(0.dp), + topEnd = CornerSize(0.dp), + ) +) + +val segmentedButtonEnd = Shapes( + extraSmall = ShapeDefaults.ExtraSmall.copy( + bottomStart = CornerSize(0.dp), + topStart = CornerSize(0.dp), + ), + small = ShapeDefaults.Small.copy( + bottomStart = CornerSize(0.dp), + topStart = CornerSize(0.dp), + ), + medium = ShapeDefaults.Medium.copy( + bottomStart = CornerSize(0.dp), + topStart = CornerSize(0.dp), + ), + large = ShapeDefaults.Large.copy( + bottomStart = CornerSize(0.dp), + topStart = CornerSize(0.dp), + ), + extraLarge = ShapeDefaults.ExtraLarge.copy( + bottomStart = CornerSize(0.dp), + topStart = CornerSize(0.dp), + ) +) diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Type.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Type.kt index c9d663e..fc02712 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Type.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/theme/Type.kt @@ -1,34 +1,49 @@ package com.morpho.app.ui.theme import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle +import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp +import morpho.composeapp.generated.resources.IBMPlexSans_Bold +import morpho.composeapp.generated.resources.IBMPlexSans_ExtraLight +import morpho.composeapp.generated.resources.IBMPlexSans_Light +import morpho.composeapp.generated.resources.IBMPlexSans_Medium +import morpho.composeapp.generated.resources.IBMPlexSans_Regular +import morpho.composeapp.generated.resources.IBMPlexSans_SemiBold +import morpho.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.Font + +@OptIn(ExperimentalResourceApi::class) +@Composable +fun IBMPlexSans() = FontFamily( + Font(Res.font.IBMPlexSans_ExtraLight, weight = FontWeight.ExtraLight), + Font(Res.font.IBMPlexSans_Light, weight = FontWeight.Light), + Font(Res.font.IBMPlexSans_Regular, weight = FontWeight.Normal), + Font(Res.font.IBMPlexSans_Medium, weight = FontWeight.Medium), + Font(Res.font.IBMPlexSans_SemiBold, weight = FontWeight.SemiBold), + Font(Res.font.IBMPlexSans_Bold, weight = FontWeight.Bold), +) // Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp +@Composable +fun MorphoTypography() = Typography().run { + val fontFamily = IBMPlexSans() + copy( + headlineLarge = headlineLarge.copy(fontFamily = fontFamily), + headlineMedium = headlineMedium.copy(fontFamily = fontFamily), + headlineSmall = headlineSmall.copy(fontFamily = fontFamily), + displayLarge = displayLarge.copy(fontFamily = fontFamily), + displayMedium = displayMedium.copy(fontFamily = fontFamily), + displaySmall = displaySmall.copy(fontFamily = fontFamily), + titleLarge = titleLarge.copy(fontFamily = fontFamily), + titleMedium = titleMedium.copy(fontFamily = fontFamily), + titleSmall = titleSmall.copy(fontFamily = fontFamily), + bodyLarge = bodyLarge.copy(fontFamily = fontFamily), + bodyMedium = bodyMedium.copy(fontFamily = fontFamily), + bodySmall = bodySmall.copy(fontFamily = fontFamily), + labelLarge = labelLarge.copy(fontFamily = fontFamily), + labelMedium = labelMedium.copy(fontFamily = fontFamily), + labelSmall = labelSmall.copy(fontFamily = fontFamily) ) - */ -) \ No newline at end of file +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadFragment.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadFragment.kt index 3fdf3fa..d0655f1 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadFragment.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadFragment.kt @@ -11,21 +11,26 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import com.atproto.repo.StrongRef import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.BskyPostThread import com.morpho.app.model.bluesky.ThreadPost -import com.morpho.app.model.uidata.ContentHandling import com.morpho.app.ui.common.OnPostClicked import com.morpho.app.ui.elements.MenuOptions import com.morpho.app.ui.post.BlockedPostFragment import com.morpho.app.ui.post.FullPostFragment import com.morpho.app.ui.post.NotFoundPostFragment import com.morpho.app.ui.post.PostFragmentRole +import com.morpho.app.ui.utils.ItemClicked +import com.morpho.app.ui.utils.OnItemClicked import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri +import com.morpho.butterfly.ContentHandling import com.morpho.butterfly.model.RecordType @@ -40,7 +45,10 @@ fun ThreadFragment( it.hashCode().toLong() } }, - onItemClicked: OnPostClicked = {}, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), onProfileClicked: (AtIdentifier) -> Unit = {}, onReplyClicked: (BskyPost) -> Unit = { }, onRepostClicked: (BskyPost) -> Unit = { }, @@ -51,7 +59,7 @@ fun ThreadFragment( listState: LazyListState = rememberLazyListState(), contentPadding: PaddingValues = PaddingValues(0.dp) ) { - val threadPost = remember { ThreadPost.ViewablePost(thread.post, thread.replies) } + val threadPost = remember { ThreadPost.ViewablePost(thread.post, null, thread.replies) } val hasReplies = rememberSaveable { threadPost.replies.isNotEmpty()} val rootIndex = remember { thread.parents.size } @@ -74,7 +82,7 @@ fun ThreadFragment( item(key = threadPost.post.cid) { FullPostFragment( post = root.post, - onItemClicked = {onItemClicked(it) }, + onItemClicked = onItemClicked, onProfileClicked = { onProfileClicked(it) }, onUnClicked = { type,uri-> onUnClicked(type,uri) }, onRepostClicked = { onRepostClicked(it) }, @@ -105,6 +113,7 @@ fun ThreadFragment( indentLevel = 1, reason = reason, elevate = true, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), onItemClicked = onItemClicked, onProfileClicked = onProfileClicked, onUnClicked = onUnClicked, @@ -119,6 +128,7 @@ fun ThreadFragment( item { ThreadItem( item = threadPost, + indentLevel = 1, role = PostFragmentRole.PrimaryThreadRoot, reason = thread.post.reason, onItemClicked = onItemClicked, @@ -159,7 +169,7 @@ fun ThreadFragment( item = threadPost, role = PostFragmentRole.PrimaryThreadRoot, reason = thread.post.reason, - modifier = Modifier.padding(vertical = 4.dp), + modifier = Modifier.padding(vertical = 2.dp), onItemClicked = onItemClicked, onProfileClicked = onProfileClicked, onUnClicked = onUnClicked, @@ -173,39 +183,40 @@ fun ThreadFragment( } if (hasReplies){ replies.fastForEach { reply -> - if (reply.replies.isNotEmpty()) { - item { - ThreadTree( - reply = reply, modifier = Modifier.padding(4.dp), - indentLevel = 1, - comparator = comparator, - onItemClicked = {onItemClicked(it) }, - onProfileClicked = { onProfileClicked(it) }, - onUnClicked = { type,uri-> onUnClicked(type,uri) }, - onRepostClicked = { onRepostClicked(it) }, - onReplyClicked = { onReplyClicked(it) }, - onMenuClicked = { option, post -> onMenuClicked(option, post) }, - onLikeClicked = { onLikeClicked(it) }, - getContentHandling = { getContentHandling(it) } - ) - } - } else { - item { - ThreadItem( - item = reply, role = PostFragmentRole.Solo, indentLevel = 1, - modifier = Modifier.padding(4.dp), - onItemClicked = onItemClicked, - onProfileClicked = onProfileClicked, - onUnClicked = onUnClicked, - onRepostClicked = onRepostClicked, - onReplyClicked = onReplyClicked, - onMenuClicked = onMenuClicked, - onLikeClicked = onLikeClicked, - getContentHandling = getContentHandling - ) - } + if (reply.replies.isNotEmpty()) { + item { + ThreadTree( + reply = reply, + modifier = Modifier.padding(vertical = 1.dp, horizontal = 3.dp), + indentLevel = 1, + comparator = comparator, + onItemClicked = onItemClicked, + onProfileClicked = { onProfileClicked(it) }, + onUnClicked = { type,uri-> onUnClicked(type,uri) }, + onRepostClicked = { onRepostClicked(it) }, + onReplyClicked = { onReplyClicked(it) }, + onMenuClicked = { option, post -> onMenuClicked(option, post) }, + onLikeClicked = { onLikeClicked(it) }, + getContentHandling = { getContentHandling(it) } + ) } - + } else { + item { + ThreadItem( + item = reply, role = PostFragmentRole.Solo, indentLevel = 0, + elevate = true, + modifier = Modifier.padding(horizontal = 3.dp, vertical = 1.dp), + onItemClicked = onItemClicked, + onProfileClicked = onProfileClicked, + onUnClicked = onUnClicked, + onRepostClicked = onRepostClicked, + onReplyClicked = onReplyClicked, + onMenuClicked = onMenuClicked, + onLikeClicked = onLikeClicked, + getContentHandling = getContentHandling + ) + } + } } } } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadItem.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadItem.kt index 970170c..85912bd 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadItem.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadItem.kt @@ -2,16 +2,21 @@ package com.morpho.app.ui.thread import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import com.atproto.repo.StrongRef import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.BskyPostReason import com.morpho.app.model.bluesky.ThreadPost -import com.morpho.app.model.uidata.ContentHandling import com.morpho.app.ui.common.OnPostClicked import com.morpho.app.ui.elements.MenuOptions import com.morpho.app.ui.post.* +import com.morpho.app.ui.utils.ItemClicked +import com.morpho.app.ui.utils.OnItemClicked import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri +import com.morpho.butterfly.ContentHandling import com.morpho.butterfly.model.RecordType @Composable @@ -22,7 +27,10 @@ inline fun ThreadItem( role: PostFragmentRole = PostFragmentRole.ThreadBranchStart, elevate: Boolean = false, reason: BskyPostReason? = null, - crossinline onItemClicked: OnPostClicked = {}, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), crossinline onProfileClicked: (AtIdentifier) -> Unit = {}, crossinline onReplyClicked: (BskyPost) -> Unit = { }, crossinline onRepostClicked: (BskyPost) -> Unit = { }, @@ -35,8 +43,9 @@ inline fun ThreadItem( is ThreadPost.ViewablePost -> { if (role == PostFragmentRole.PrimaryThreadRoot) { FullPostFragment( - post = item.post.copy(reason = reason), - onItemClicked = {onItemClicked(it) }, + post = item.post.copy(reason = reason, reply = item.post.reply?.copy(parentPost = null)), + modifier = modifier, + onItemClicked = onItemClicked, onProfileClicked = { onProfileClicked(it) }, onUnClicked = { type,uri-> onUnClicked(type,uri) }, onRepostClicked = { onRepostClicked(it) }, @@ -47,11 +56,12 @@ inline fun ThreadItem( ) } else { PostFragment( - post = item.post.copy(reason = reason), + post = item.post.copy(reason = reason, reply = item.post.reply?.copy(parentPost = null)), role = role, + modifier = modifier, indentLevel = indentLevel, elevate = elevate, - onItemClicked = {onItemClicked(it) }, + onItemClicked = onItemClicked, onProfileClicked = { onProfileClicked(it) }, onUnClicked = { type,uri-> onUnClicked(type,uri) }, onRepostClicked = { onRepostClicked(it) }, @@ -64,6 +74,7 @@ inline fun ThreadItem( } is ThreadPost.BlockedPost -> { BlockedPostFragment( + modifier = modifier, post = item.uri, role = role, indentLevel = indentLevel, @@ -71,6 +82,7 @@ inline fun ThreadItem( } is ThreadPost.NotFoundPost -> { NotFoundPostFragment( + modifier = modifier, post = item.uri, role = role, indentLevel = indentLevel, diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadReply.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadReply.kt index 0f04da6..bce561f 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadReply.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadReply.kt @@ -2,18 +2,23 @@ package com.morpho.app.ui.thread import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import com.atproto.repo.StrongRef import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.ThreadPost -import com.morpho.app.model.uidata.ContentHandling import com.morpho.app.ui.common.OnPostClicked import com.morpho.app.ui.elements.MenuOptions import com.morpho.app.ui.post.BlockedPostFragment import com.morpho.app.ui.post.NotFoundPostFragment import com.morpho.app.ui.post.PostFragment import com.morpho.app.ui.post.PostFragmentRole +import com.morpho.app.ui.utils.ItemClicked +import com.morpho.app.ui.utils.OnItemClicked import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri +import com.morpho.butterfly.ContentHandling import com.morpho.butterfly.model.RecordType @Composable @@ -22,7 +27,10 @@ inline fun ThreadReply( modifier: Modifier = Modifier, indentLevel: Int = 1, role: PostFragmentRole = PostFragmentRole.ThreadBranchMiddle, - crossinline onItemClicked: OnPostClicked = {}, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), crossinline onProfileClicked: (AtIdentifier) -> Unit = {}, crossinline onReplyClicked: (BskyPost) -> Unit = { }, crossinline onRepostClicked: (BskyPost) -> Unit = { }, @@ -33,7 +41,9 @@ inline fun ThreadReply( ) { when(item) { is ThreadPost.ViewablePost -> { - val r = if (item.replies.isEmpty()) { + val r = if (role == PostFragmentRole.ThreadBranchStart || role == PostFragmentRole.Solo) { + role + } else if (item.replies.isEmpty()) { PostFragmentRole.ThreadBranchEnd } else { PostFragmentRole.ThreadBranchMiddle @@ -43,7 +53,8 @@ inline fun ThreadReply( role = r, indentLevel = indentLevel, modifier = modifier, - onItemClicked = {onItemClicked(it) }, + elevate = r != PostFragmentRole.ThreadBranchStart, + onItemClicked = onItemClicked, onProfileClicked = { onProfileClicked(it) }, onUnClicked = { type,uri-> onUnClicked(type,uri) }, onRepostClicked = { onRepostClicked(it) }, diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadTree.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadTree.kt index 93097a7..1937019 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadTree.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/thread/ThreadTree.kt @@ -1,6 +1,5 @@ package com.morpho.app.ui.thread -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyScopeMarker @@ -10,21 +9,29 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastForEachIndexed +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import com.atproto.repo.StrongRef import com.morpho.app.model.bluesky.BskyPost import com.morpho.app.model.bluesky.ThreadPost -import com.morpho.app.model.uidata.ContentHandling import com.morpho.app.ui.common.OnPostClicked import com.morpho.app.ui.elements.MenuOptions import com.morpho.app.ui.elements.WrappedColumn import com.morpho.app.ui.post.PostFragmentRole +import com.morpho.app.ui.utils.ItemClicked +import com.morpho.app.ui.utils.OnItemClicked import com.morpho.butterfly.AtIdentifier import com.morpho.butterfly.AtUri +import com.morpho.butterfly.ContentHandling import com.morpho.butterfly.model.RecordType import morpho.app.ui.utils.indentLevel @@ -36,27 +43,50 @@ fun ThreadTree( indentLevel: Int = 1, comparator: Comparator = compareBy { if (it is ThreadPost.ViewablePost) { - it.post.indexedAt.instant.epochSeconds + it.post.createdAt.instant.epochSeconds } else { it.hashCode().toLong() } }, - onItemClicked: OnPostClicked = {}, + onItemClicked: OnItemClicked = ItemClicked( + uriHandler = LocalUriHandler.current, + navigator = LocalNavigator.currentOrThrow, + ), onProfileClicked: (AtIdentifier) -> Unit = {}, onReplyClicked: (BskyPost) -> Unit = { }, onRepostClicked: (BskyPost) -> Unit = { }, onLikeClicked: (StrongRef) -> Unit = { }, onMenuClicked: (MenuOptions, BskyPost) -> Unit = { _, _ -> }, onUnClicked: (type: RecordType, uri: AtUri) -> Unit = { _, _ -> }, - getContentHandling: (BskyPost) -> List = { listOf() } + getContentHandling: (BskyPost) -> List = { listOf() }, + end: Boolean = false, ) { - + val lineColour = MaterialTheme.colorScheme.onTertiaryContainer//.copy(alpha = 0.8f) + val lineColour2 = MaterialTheme.colorScheme.outline//.copy(alpha = 0.8f) + val bgColour = MaterialTheme.colorScheme.background if(reply is ThreadPost.ViewablePost) { if (reply.replies.isEmpty()) { ThreadReply( - item = reply, role = PostFragmentRole.Solo, indentLevel = indentLevel, - modifier = Modifier.padding(top = 2.dp), + item = reply, role = PostFragmentRole.ThreadBranchEnd, indentLevel = indentLevel, + modifier = if(indentLevel > 1) Modifier + .drawBehind { + drawLine( + color = lineColour, + cap = StrokeCap.Butt, + start = Offset(9.dp.toPx(), 22.dp.toPx()), + end = Offset(100.dp.toPx(), 22.dp.toPx()), + strokeWidth = Stroke.HairlineWidth + ) + if(end) { + drawRect( + color = bgColour, + topLeft = Offset(4.dp.toPx(), 23.dp.toPx()), + size = Size(100.dp.toPx(), size.height - 23.dp.toPx()), + ) + } + }.padding(top = 2.dp, start = 6.dp,) + else Modifier.padding(top = 2.dp, start = 6.dp, bottom = 2.dp), onItemClicked = onItemClicked, onProfileClicked = onProfileClicked, onUnClicked = onUnClicked, @@ -69,44 +99,57 @@ fun ThreadTree( } else { val replies = remember { reply.replies.sortedWith(comparator) } WrappedColumn( - modifier = modifier - .fillMaxWidth() - .padding(top = 2.dp) - + modifier = if(indentLevel == 1) modifier.fillMaxWidth().padding(start = 0.dp, end = 4.dp) + else modifier.fillMaxWidth().padding(start = 1.dp, bottom = 2.dp) ) { - val lineColour = if (indentLevel % 4 == 0) { - MaterialTheme.colorScheme.tertiary.copy(0.7f) - } else if (indentLevel % 2 == 0) { - MaterialTheme.colorScheme.secondary.copy(0.7f) - } else { - MaterialTheme.colorScheme.primary.copy(0.7f) - } + Surface( - //shadowElevation = if (indentLevel > 0) 1.dp else 0.dp, - border = BorderStroke( - 1.dp, Brush.sweepGradient( - 0.0f to Color.Transparent, 0.2f to Color.Transparent, - 0.4f to lineColour, 0.7f to lineColour, - 0.9f to Color.Transparent, - center = Offset(100f, 500f) - ) - ), - tonalElevation = 2.dp, + //shadowElevation = if (indentLevel % 2 > 0) 2.dp else 0.dp, + tonalElevation = if(replies.size > 1) (indentLevel*2).dp else 0.dp, + //border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondaryContainer), + color = Color.Transparent, + //color = if(replies.size > 1) MaterialTheme.colorScheme.surfaceColorAtElevation((indentLevel*2).dp) + //else Color.Transparent, shape = MaterialTheme.shapes.small, - modifier = Modifier - .fillMaxWidth(indentLevel(indentLevel / 2.0f)) + modifier = Modifier.fillMaxWidth(indentLevel(indentLevel / 2.0f)) .align(Alignment.End) ) { WrappedColumn( + horizontalAlignment = Alignment.End, + modifier = if(replies.size > 1) Modifier.fillMaxWidth() + .drawBehind { + if(!end) + drawLine( + color = lineColour, + cap = StrokeCap.Butt, + start = Offset(8.dp.toPx(), 10.dp.toPx()), + end = Offset(8.dp.toPx(), size.height - 22.dp.toPx()), + strokeWidth = Stroke.HairlineWidth + ) + } else Modifier.fillMaxWidth() + .drawBehind { + if(replies.size == 1) + drawLine( + color = lineColour2, + cap = StrokeCap.Butt, + start = Offset(12.dp.toPx(), 6.dp.toPx()), + end = Offset(12.dp.toPx(), size.height - 22.dp.toPx()), + strokeWidth = 2.dp.toPx(), + ) + } + + ) { ThreadReply( item = reply, role = PostFragmentRole.ThreadBranchStart, - indentLevel = indentLevel, - modifier = Modifier.padding(top = 2.dp), - onItemClicked = {onItemClicked(it) }, + indentLevel = 1, + modifier = if(replies.size > 1) Modifier.padding(start = 2.dp, top = 2.dp) + else if(replies.size == 1) Modifier.padding(start = 1.dp, top = 1.dp) + else Modifier, + onItemClicked = onItemClicked, onProfileClicked = { onProfileClicked(it) }, onUnClicked = { type,uri-> onUnClicked(type,uri) }, onRepostClicked = { onRepostClicked(it) }, @@ -115,19 +158,69 @@ fun ThreadTree( onLikeClicked = { onLikeClicked(it) }, getContentHandling = { getContentHandling(it) } ) + if(replies.size > 1) { + Surface( + color = Color.Transparent, + //shadowElevation = if (indentLevel > 0) 2.dp else 0.dp, + //tonalElevation = (indentLevel*2).dp, + //border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiaryContainer), + //color = MaterialTheme.colorScheme.surfaceColorAtElevation((indentLevel*2).dp), + shape = MaterialTheme.shapes.small, + modifier = Modifier.padding(top = 2.dp, start = 0.dp) + .fillMaxWidth() + ) { + WrappedColumn( - replies.fastForEach { reply -> - ThreadTree( - reply = reply, modifier = modifier, indentLevel = indentLevel, - onItemClicked = { onItemClicked(it) }, + modifier = Modifier.fillMaxWidth() + ) { + replies.fastForEachIndexed { index,reply -> + ThreadTree( + reply = reply, + modifier = Modifier.drawBehind { + drawLine( + color = lineColour, + cap = StrokeCap.Butt, + start = Offset(9.dp.toPx(), 20.dp.toPx()), + end = Offset(100.dp.toPx(), 20.dp.toPx()), + strokeWidth = Stroke.HairlineWidth + ) + if(index == replies.lastIndex) { + drawRect( + color = bgColour, + topLeft = Offset(4.dp.toPx(), 21.dp.toPx()), + size = Size(100.dp.toPx(), size.height - 21.dp.toPx()), + ) + } + }.padding(start = 3.dp), + indentLevel = indentLevel + 1, + onItemClicked = onItemClicked, + onProfileClicked = { onProfileClicked(it) }, + onUnClicked = { type, uri -> onUnClicked(type, uri) }, + onRepostClicked = { onRepostClicked(it) }, + onReplyClicked = { onReplyClicked(it) }, + onMenuClicked = { option, p -> onMenuClicked(option, p) }, + onLikeClicked = { onLikeClicked(it) }, + getContentHandling = { getContentHandling(it) }, + end = index == replies.lastIndex + ) + } + } + } + } else if(replies.size == 1) { + ThreadReply( + item = replies.first(), + role = PostFragmentRole.ThreadBranchEnd, + indentLevel = indentLevel, + modifier = Modifier.padding(start = 4.dp, top = 2.dp), + onItemClicked = onItemClicked, onProfileClicked = { onProfileClicked(it) }, onUnClicked = { type, uri -> onUnClicked(type, uri) }, onRepostClicked = { onRepostClicked(it) }, onReplyClicked = { onReplyClicked(it) }, onMenuClicked = { option, p -> onMenuClicked(option, p) }, onLikeClicked = { onLikeClicked(it) }, - getContentHandling = { getContentHandling(it) } ) + } } } diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/utils/Interaction.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/utils/Interaction.kt new file mode 100644 index 0000000..221f0db --- /dev/null +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/ui/utils/Interaction.kt @@ -0,0 +1,117 @@ +package com.morpho.app.ui.utils + +import androidx.compose.ui.platform.UriHandler +import cafe.adriel.voyager.navigator.Navigator +import com.morpho.app.model.bluesky.FacetType +import com.morpho.app.screens.base.tabbed.ProfileTab +import com.morpho.app.screens.base.tabbed.ThreadTab +import com.morpho.app.util.openBrowser +import com.morpho.butterfly.AtIdentifier +import com.morpho.butterfly.AtUri +import com.morpho.butterfly.Did +import com.morpho.butterfly.Uri + +fun onProfileClickedImmediate( + actor: AtIdentifier, + navigator: Navigator, +) { + if(actor is Did) navigator.push(ProfileTab(actor)) +} + +fun onPostClickedImmediate( + uri: AtUri, + navigator: Navigator, +) { + navigator.push(ThreadTab(uri)) +} +typealias OnPostClicked = (AtUri) -> Unit +typealias OnFacetClicked = (FacetType) -> Unit +typealias OnLinkClicked = (Uri) -> Unit + +interface OnItemClicked { + val uriHandler: UriHandler + val navigator: Navigator + + fun onRichTextFacetClicked( + facet: FacetType? = null, + uri: AtUri? = null, + linkCallback: ((Uri) -> Unit)? = null, + profileCallback: ((AtIdentifier) -> Unit)? = null, + facetCallback: ((FacetType) -> Unit)? = null, + postCallback: ((AtUri?) -> Unit)? = null, + ) + + fun onPostClicked(uri: AtUri) + + fun onProfileClicked(id: AtIdentifier) +} + +data class ItemClicked( + override val uriHandler: UriHandler, + override val navigator: Navigator, + val linkCallback: (Uri) -> Unit = { link -> openBrowser(link.uri, uriHandler) }, + val profileCallback: (AtIdentifier) -> Unit = { onProfileClickedImmediate(it, navigator) }, + val facetCallback: (FacetType) -> Unit = { + when(it) { + is FacetType.UserDidMention -> { + profileCallback(it.did) + } + is FacetType.UserHandleMention -> { + profileCallback(it.handle) + } + is FacetType.ExternalLink -> { + linkCallback(it.uri) + } + else -> {} + } + }, + val postCallback: (AtUri?) -> Unit = { uri -> + if(uri != null) onPostClickedImmediate(uri, navigator) + }, + val callbackAlways: () -> Unit = { }, +): OnItemClicked { + + + override fun onRichTextFacetClicked( + facet: FacetType?, + uri: AtUri?, + linkCallback: ((Uri) -> Unit)?, + profileCallback: ((AtIdentifier) -> Unit)?, + facetCallback: ((FacetType) -> Unit)?, + postCallback: ((AtUri?) -> Unit)? + ) { + val facetFun = facetCallback ?: this.facetCallback + when(facet) { + is FacetType.UserDidMention -> { + if(profileCallback != null) profileCallback(facet.did) + else this.profileCallback(facet.did) + } + is FacetType.UserHandleMention -> { + if(profileCallback != null) profileCallback(facet.handle) + else this.profileCallback(facet.handle) + } + is FacetType.ExternalLink -> { + linkCallback(facet.uri) + } + is FacetType.Tag -> facetFun(facet) + is FacetType.PollBlueOption -> facetFun(facet) + is FacetType.BlueMoji -> facetFun(facet) + is FacetType.Format -> facetFun(facet) + FacetType.PollBlueQuestion -> facetFun(facet) + is FacetType.UnknownFacet -> facetFun(facet) + null -> if(postCallback != null) postCallback(uri) else this.postCallback(uri) + } + callbackAlways() + } + + override fun onPostClicked(uri: AtUri) { + postCallback(uri) + callbackAlways() + } + + override fun onProfileClicked(id: AtIdentifier) { + profileCallback(id) + callbackAlways() + } +} + diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/BlueskyText.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/BlueskyText.kt index ba529aa..e124455 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/BlueskyText.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/BlueskyText.kt @@ -3,14 +3,13 @@ package com.morpho.app.util import androidx.compose.ui.util.fastFilterNotNull import androidx.compose.ui.util.fastFlatMap import androidx.compose.ui.util.fastMap -import com.atproto.identity.ResolveHandleQuery import com.morpho.app.model.bluesky.BskyFacet import com.morpho.app.model.bluesky.FacetType import com.morpho.app.model.bluesky.RichTextFormat -import com.morpho.butterfly.Butterfly +import com.morpho.butterfly.ButterflyAgent import kotlinx.serialization.Serializable - +@Serializable data class BlueskyText( val text: String, val facets: List @@ -74,16 +73,16 @@ sealed interface Segment { expect fun makeBlueskyText(text: String): BlueskyText -suspend fun resolveBlueskyText(text: BlueskyText, api: Butterfly): Result = runCatching { +suspend fun resolveBlueskyText(text: BlueskyText, agent: ButterflyAgent): Result = runCatching { val facets:List = text.facets.fastFlatMap { facet: BskyFacet -> facet.facetType.fastMap { if (it is FacetType.UserHandleMention) { // Resolve handles - val response = api.api.resolveHandle(ResolveHandleQuery(it.handle)).getOrNull() - if (response != null) { + val did = agent.resolveHandle(it.handle).getOrNull() + if (did != null) { val index = facet.facetType.indexOf(it) val facetTypes = facet.facetType.toMutableList() - facetTypes[index] = FacetType.UserDidMention(response.did) + facetTypes[index] = FacetType.UserDidMention(did) facet.copy(facetType = facetTypes) } else null diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/LinkParsing.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/LinkParsing.kt index 48dc2c8..f255cf9 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/LinkParsing.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/LinkParsing.kt @@ -1,19 +1,20 @@ package com.morpho.app.util +import androidx.compose.ui.platform.UriHandler import cafe.adriel.voyager.navigator.Navigator import com.morpho.app.screens.base.tabbed.ProfileTab import com.morpho.butterfly.AtUri import com.morpho.butterfly.Cid import com.morpho.butterfly.Did -import com.morpho.butterfly.Handle -fun linkVisit(string: String, navigator: Navigator) { + +fun linkVisit(string: String, navigator: Navigator, uriHandler: UriHandler) { if(string.startsWith("@")) { if(string.startsWith("@did")) { navigator.push(ProfileTab(Did(string.removePrefix("@")))) } else { - navigator.push(ProfileTab(Handle(string.removePrefix("@")))) + //navigator.push(ProfileTab())) } } else if(string.startsWith("https://bsky.app/") || string.startsWith("https://staging.bsky.app/") @@ -22,11 +23,12 @@ fun linkVisit(string: String, navigator: Navigator) { string.replace("/post/", "/app.bsky.feed.post/") } } else if (string.startsWith("http")){ - checkValidUrl(string)?.let { openBrowser(it) } + checkValidUrl(string)?.let { openBrowser(it, uriHandler) } } } -expect fun openBrowser(url: String) + +expect fun openBrowser(url: String, uriHandler: UriHandler) fun didCidToImageLink(did: Did, cid: Cid, avatar: Boolean, type: String = "jpeg"): String { val collection = if (avatar) { diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/Savers.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/Savers.kt index 552c5ca..323ace5 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/Savers.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/Savers.kt @@ -3,10 +3,47 @@ package com.morpho.app.util import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import com.morpho.butterfly.AtUri +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder val atUriSaver: Saver = listSaver( save = { listOf(it.atUri)}, restore = { AtUri(it.first()) } -) \ No newline at end of file +) + + + +class StateFlowSerializer(private val dataSerializer: KSerializer) : KSerializer> { + override val descriptor: SerialDescriptor = dataSerializer.descriptor + override fun serialize(encoder: Encoder, value: StateFlow) = dataSerializer.serialize(encoder, value.value) + override fun deserialize(decoder: Decoder) = MutableStateFlow(dataSerializer.deserialize(decoder)).asStateFlow() +} + +class MutableStateFlowSerializer(private val dataSerializer: KSerializer) : KSerializer> { + override val descriptor: SerialDescriptor = dataSerializer.descriptor + override fun serialize(encoder: Encoder, value: MutableStateFlow) = dataSerializer.serialize(encoder, value.value) + override fun deserialize(decoder: Decoder) = MutableStateFlow(dataSerializer.deserialize(decoder)) +} + +class MutableSharedFlowSerializer(private val dataSerializer: KSerializer) : KSerializer> { + override val descriptor: SerialDescriptor = dataSerializer.descriptor + override fun serialize(encoder: Encoder, value: MutableSharedFlow) = dataSerializer.serialize(encoder, value.replayCache.last()) + override fun deserialize(decoder: Decoder): MutableSharedFlow { + val flow = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + flow.tryEmit(dataSerializer.deserialize(decoder)) + return flow + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/encodings.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/encodings.kt index 5ee51a6..db25887 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/encodings.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/encodings.kt @@ -36,7 +36,7 @@ fun Byte.isSingleByte(): Boolean { } fun String.utf16FacetIndex(utf8: ByteString, start: Int, end: Int): Pair { - val utf8FacetText = utf8.substring(start, end) + val utf8FacetText = utf8.substring(max(0, start), min(utf8.size-1, end)) //println("utf8FacetText: '${utf8FacetText.utf8()}'") //println("utf8Start: ${utf8.indexOf(utf8FacetText)}, utf8End: ${utf8.indexOf(utf8FacetText) + utf8FacetText.size}") val utf16FacetText = utf8FacetText.utf8() @@ -48,7 +48,7 @@ fun String.utf16FacetIndex(utf8: ByteString, start: Int, end: Int): Pair { val utf8 = this.encodeUtf8() - val utf8FacetText = utf8.substring(start, end-1) + val utf8FacetText = utf8.substring(max(0, start), min(utf8.size-1, end)) //println("utf8FacetText: '${utf8FacetText.utf8()}'") //println("utf8Start: ${utf8.indexOf(utf8FacetText)}, utf8End: ${utf8.indexOf(utf8FacetText) + utf8FacetText.size}") val utf16FacetText = utf8FacetText.utf8() diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/json.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/json.kt index 49fb31f..cc87d97 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/json.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/json.kt @@ -1,16 +1,47 @@ package com.morpho.app.util +import app.bsky.actor.PreferencesUnion +import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import kotlinx.serialization.serializer + +@OptIn(InternalSerializationApi::class) +val morphoSerializersModule = SerializersModule { + polymorphic(PreferencesUnion::class) { + subclass(PreferencesUnion.AdultContentPref::class) + subclass(PreferencesUnion.FeedViewPref::class) + subclass(PreferencesUnion.ThreadViewPref::class) + subclass(PreferencesUnion.SkyFeedBuilderFeedsPref::class) + subclass(PreferencesUnion.SavedFeedsPref::class) + subclass(PreferencesUnion.SavedFeedsPrefV2::class) + subclass(PreferencesUnion.PersonalDetailsPref::class) + subclass(PreferencesUnion.ContentLabelPref::class) + subclass(PreferencesUnion.LabelersPref::class) + subclass(PreferencesUnion.HiddenPostsPref::class) + subclass(PreferencesUnion.MutedWordsPref::class) + subclass(PreferencesUnion.InterestsPref::class) + subclass(PreferencesUnion.ButterflyPreference::class) + subclass(PreferencesUnion.UnknownPreference::class) + defaultDeserializer { _ -> + PreferencesUnion.UnknownPreference::class.serializer() + } + } +} + val json = Json { classDiscriminator = "${'$'}type" ignoreUnknownKeys = true prettyPrint = true + serializersModule = morphoSerializersModule } val JsonElement.recordType: String diff --git a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/time.kt b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/time.kt index f6893fd..0abee60 100644 --- a/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/time.kt +++ b/Morpho/composeApp/src/commonMain/kotlin/com/morpho/app/util/time.kt @@ -1,12 +1,7 @@ package com.morpho.app.util -import kotlinx.datetime.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.minus -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import kotlinx.datetime.todayIn import com.morpho.app.model.uidata.Moment +import kotlinx.datetime.* fun getFormattedDateTimeSince(moment: Moment): String { val postDate = moment.instant.toLocalDateTime(TimeZone.currentSystemDefault()).date @@ -19,19 +14,21 @@ fun getFormattedDateTimeSince(moment: Moment): String { deltaTime.toComponents { hours, minutes, seconds, _ -> return when { deltaDays >= 180 -> { - moment.instant.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() + postTime.date.toString() } - deltaDays >= 1 -> { - - "${if(dateDiff.years > 0) "${dateDiff.years} yrs " else ""}${if(dateDiff.months > 0) "${dateDiff.months} months " else ""}${if(dateDiff.days > 0 && dateDiff.months == 0) "${dateDiff.days} days " else ""}ago" + dateDiff.months > 0 -> { + "${dateDiff.months} months ago" + } + dateDiff.days > 0 -> { + "${dateDiff.days} days ago" } (deltaDays == 0 && hours >= 12)-> { "$hours h ago" } - (deltaDays == 0 && hours >= 1)-> { - "${hours}:${minutes} ago" + (deltaDays == 0 && hours >= 2)-> { + "$hours h $minutes m ago" } - (deltaDays == 0 && hours.toInt() == 0 && minutes > 1) -> { + (deltaDays == 0 && hours.toInt() <= 1 && minutes > 1) -> { "$minutes m ago" } (deltaDays == 0 && hours.toInt() == 0 && minutes == 0) -> { diff --git a/Morpho/composeApp/src/commonTest/kotlin/com/morpho/app/model/bluesky/BskyPostTest.kt b/Morpho/composeApp/src/commonTest/kotlin/com/morpho/app/model/bluesky/BskyPostTest.kt index 016f8cc..472666b 100644 --- a/Morpho/composeApp/src/commonTest/kotlin/com/morpho/app/model/bluesky/BskyPostTest.kt +++ b/Morpho/composeApp/src/commonTest/kotlin/com/morpho/app/model/bluesky/BskyPostTest.kt @@ -22,7 +22,7 @@ class BskyPostTest { is ThreadPost.ViewablePost -> { assertEquals( (thread.parents.first() as ThreadPost.ViewablePost).post.uri, - p.post.reply?.root?.uri, + p.post.reply?.rootPost?.uri, "Root post uri should match first(highest) parent uri" ) @@ -50,7 +50,7 @@ class BskyPostTest { is ThreadPost.ViewablePost -> { assertEquals( (thread.parents.first() as ThreadPost.ViewablePost).post.uri, - p.post.reply?.root?.uri, + p.post.reply?.rootPost?.uri, "Root post uri should match first(highest) parent uri" ) } diff --git a/Morpho/composeApp/src/desktopMain/kotlin/Platform.jvm.kt b/Morpho/composeApp/src/desktopMain/kotlin/Platform.jvm.kt deleted file mode 100644 index f5e7e49..0000000 --- a/Morpho/composeApp/src/desktopMain/kotlin/Platform.jvm.kt +++ /dev/null @@ -1,5 +0,0 @@ -class JVMPlatform: Platform { - override val name: String = "Java ${System.getProperty("java.version")}" -} - -actual fun getPlatform(): Platform = JVMPlatform() \ No newline at end of file diff --git a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/Platform.desktop.kt b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/Platform.desktop.kt new file mode 100644 index 0000000..946a350 --- /dev/null +++ b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/Platform.desktop.kt @@ -0,0 +1,39 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") +package com.morpho.app +import kotlinx.datetime.LocalDateTime +import net.harawata.appdirs.AppDirsFactory +import okio.Path.Companion.toPath +import java.util.Locale +import kotlin.io.path.createDirectories + +// Note: no need to define CommonParcelize here (bc its @OptionalExpectation) +actual interface CommonParcelable // not used on iOS + +// Note: no need to define CommonTypeParceler> here (bc its @OptionalExpectation) +actual interface CommonParceler // not used on iOS +actual object LocalDateTimeParceler : CommonParceler // not used on iOS + +// For Android @Parcelize +@Target(AnnotationTarget.TYPE) +@Retention(AnnotationRetention.SOURCE) +actual annotation class CommonRawValue + + +class JVMPlatform: Platform { + override val name: String = "Java ${System.getProperty("java.version")}" +} + +actual fun getPlatform(): Platform = JVMPlatform() + +actual val myCountry: String? + get() = Locale.getDefault().country +actual val myLang: String? + get() = Locale.getDefault().language + +actual fun getPlatformStorageDir(baseDir: String): String { + val storageDir = AppDirsFactory.getInstance() + .getUserDataDir(BuildKonfig.packageName, BuildKonfig.versionNumber, BuildKonfig.appName) + val path = storageDir.toPath() + path.toNioPath().createDirectories() + return storageDir.toString() +} \ No newline at end of file diff --git a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/Previews.kt b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/Previews.kt new file mode 100644 index 0000000..c19a926 --- /dev/null +++ b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/Previews.kt @@ -0,0 +1,25 @@ +package com.morpho.app + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import com.morpho.app.ui.post.PlaceholderSkylineItem +import com.morpho.app.ui.post.PostFragmentRole +import com.morpho.app.ui.theme.MorphoTheme + +@Preview +@Composable +fun PreviewPlaceholderSkylineItem() { + MorphoTheme { + Column { + PlaceholderSkylineItem() + PlaceholderSkylineItem(role = PostFragmentRole.PrimaryThreadRoot) + PlaceholderSkylineItem(role = PostFragmentRole.ThreadBranchStart) + PlaceholderSkylineItem(role = PostFragmentRole.ThreadBranchMiddle) + PlaceholderSkylineItem(role = PostFragmentRole.ThreadBranchEnd) + PlaceholderSkylineItem(role = PostFragmentRole.ThreadRootUnfocused) + PlaceholderSkylineItem(role = PostFragmentRole.ThreadEnd) + PlaceholderSkylineItem(elevate = true) + } + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.desktop.kt b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.desktop.kt index 0cf6e1f..83b59c9 100644 --- a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.desktop.kt +++ b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/common/TabbedScreenScaffold.desktop.kt @@ -3,6 +3,7 @@ package com.morpho.app.ui.common import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.navigationBars +import androidx.compose.material3.DrawerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBarScrollBehavior @@ -10,51 +11,70 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll +import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.uidata.Event +import com.morpho.app.model.uistate.ContentCardState import com.morpho.app.ui.elements.WrappedColumn @Composable -actual fun TabbedScreenScaffold( +actual fun TabbedScreenScaffold( navBar: @Composable () -> Unit, - content: @Composable (PaddingValues) -> Unit, + content: @Composable (PaddingValues, T?) -> Unit, topContent: @Composable () -> Unit, + state: T?, modifier: Modifier, + drawerState: DrawerState, + profile: DetailedProfile? ) { - Scaffold( - contentWindowInsets = WindowInsets.navigationBars, - modifier = modifier, - bottomBar = { navBar() }, - content = { - WrappedColumn( - modifier = modifier - ) { - topContent() - content(it) + NavDrawer( + drawerState = drawerState, + profile = profile, + ) { + Scaffold( + contentWindowInsets = WindowInsets.navigationBars, + modifier = modifier, + bottomBar = { navBar() }, + content = { + WrappedColumn( + modifier = modifier + ) { + topContent() + content(it, state) + } } - } - ) + ) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable -actual fun TabbedProfileScreenScaffold( +actual fun TabbedProfileScreenScaffold( navBar: @Composable () -> Unit, - content: @Composable (PaddingValues) -> Unit, + content: @Composable (PaddingValues, ContentCardState?) -> Unit, topContent: @Composable (TopAppBarScrollBehavior) -> Unit, + state: ContentCardState?, modifier: Modifier, scrollBehavior: TopAppBarScrollBehavior, nestedScrollConnection: NestedScrollConnection, + drawerState: DrawerState, + profile: DetailedProfile? ) { - Scaffold( - contentWindowInsets = WindowInsets.navigationBars, - modifier = modifier, - bottomBar = { navBar() }, - content = { - WrappedColumn( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - ) { - topContent(scrollBehavior) - content(it) + NavDrawer( + drawerState = drawerState, + profile = profile, + ) { + Scaffold( + contentWindowInsets = WindowInsets.navigationBars, + modifier = modifier, + bottomBar = { navBar() }, + content = { + WrappedColumn( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + topContent(scrollBehavior) + content(it, state) + } } - } - ) + ) + } } \ No newline at end of file diff --git a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/post/PostImage.desktop.kt b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/post/PostImage.desktop.kt index bba9528..1a7f9ba 100644 --- a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/post/PostImage.desktop.kt +++ b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/post/PostImage.desktop.kt @@ -1,9 +1,23 @@ package com.morpho.app.ui.post import MorphoDialog -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.HorizontalScrollbar +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme @@ -48,7 +62,7 @@ actual fun FullImageView( } val (undecorated, tabbed) = if (morphoPrefs != null) { log.d{ "Morpho Preferences: $morphoPrefs" } - morphoPrefs.tabbed to morphoPrefs.undecorated + (morphoPrefs.tabbed == true) to (morphoPrefs.undecorated == true) } else { log.d {"No Morpho Preferences found, using defaults" } true to true @@ -88,10 +102,10 @@ actual fun FullImageView( image = image, onDismissRequest = onDismissRequest ) - println("Image width: ${image.aspectRatio?.width} Image height: ${image.aspectRatio?.height} Ratio: $ratio") - println("Width: ${state.size.width.value.toInt()} Height: ${state.size.height.value.toInt()} Ratio: ${ - state.size.width.value.toInt().toFloat() / state.size.height.value.toInt().toFloat() - }") +// println("Image width: ${image.aspectRatio?.width} Image height: ${image.aspectRatio?.height} Ratio: $ratio") +// println("Width: ${state.size.width.value.toInt()} Height: ${state.size.height.value.toInt()} Ratio: ${ +// state.size.width.value.toInt().toFloat() / state.size.height.value.toInt().toFloat() +// }") } } else { DesktopImageViewContent( @@ -157,7 +171,7 @@ fun DesktopImageViewContent( text = image.alt, style = MaterialTheme.typography.bodyLarge, color = Color.White, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(top= 4.dp, bottom = 12.dp, start = 12.dp, end = 12.dp), textAlign = TextAlign.Start ) } diff --git a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.desktop.kt b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.desktop.kt index 9c92344..22abc6c 100644 --- a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.desktop.kt +++ b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/profile/DetailedProfileFragment.desktop.kt @@ -4,13 +4,38 @@ import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBackIosNew -import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -26,7 +51,10 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade +import com.morpho.app.model.bluesky.BskyLabelService import com.morpho.app.model.bluesky.DetailedProfile +import com.morpho.app.model.uidata.Event +import com.morpho.app.model.uidata.LabelerEvent import com.morpho.app.ui.elements.AvatarShape import com.morpho.app.ui.elements.OutlinedAvatar import com.morpho.app.ui.elements.RichTextElement @@ -49,6 +77,7 @@ actual fun DetailedProfileFragment( isTopLevel: Boolean, scrollBehavior: TopAppBarScrollBehavior, onBackClicked: () -> Unit, + eventCallback: (Event) -> Unit, ) { val scrollState = rememberScrollState(0) val name = profile.displayName ?: profile.handle.handle @@ -268,3 +297,213 @@ actual fun DetailedProfileFragment( } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun LabelerProfileFragment( + labeler: BskyLabelService, + modifier: Modifier, + isSubscribed: Boolean, + isTopLevel: Boolean, + scrollBehavior: TopAppBarScrollBehavior, + onBackClicked: () -> Unit, + eventCallback: (Event) -> Unit, +) { + val scrollState = rememberScrollState(0) + val name = labeler.displayName ?: labeler.handle.handle + val bannerHeight = if (scrollBehavior.state.collapsedFraction <= .2) { + 135.dp + } else { + (135.dp - (60 * scrollBehavior.state.collapsedFraction).dp) + } + val collapsed = scrollBehavior.state.collapsedFraction > 0.5 + LaunchedEffect(scrollState) { + println("Banner Height: $bannerHeight") + print("Collapsed: $collapsed") + } + + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + //.requiredHeight(bannerHeight*2) + .nestedScroll(scrollBehavior.nestedScrollConnection) + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(scrollState) + //.border(1.dp, Color.Red) + ) { + val (appbar, userStats, banner, labels, text, collapsedText) = createRefs() + + AsyncImage( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(labeler.creator?.banner.orEmpty()) + .crossfade(true) + .build(), + placeholder = painterResource(Res.drawable.test_banner), + contentDescription = "Profile Banner for ${labeler.displayName} ${labeler.handle}", + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + modifier = Modifier + .fillMaxWidth() + .constrainAs(banner) { + top.linkTo(parent.top) + } + .animateContentSize( + spring( + stiffness = Spring.StiffnessMediumLow, + dampingRatio = Spring.DampingRatioNoBouncy + ) + ) + .requiredHeight(bannerHeight)//.border(1.dp, Color.Blue) + ) + + LargeTopAppBar( + title = { + ConstraintLayout(//constraintSet = , + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + val (avatar, buttons, info) = createRefs() + val expanded = scrollBehavior.state.collapsedFraction <= 0.5 + val avatarSize = (80.dp - (30.0 * scrollBehavior.state.collapsedFraction).dp) + val centreGuideFraction = if(expanded) .6f else .5f + val avatarGuide = createGuidelineFromStart(.1f ) + val centreGuide = createGuidelineFromTop(centreGuideFraction) + + if(expanded){ + LabelerButtons( + subscribed = isSubscribed, + modifier = Modifier.zIndex(4f) + .constrainAs(buttons) { + centerAround(centreGuide) + end.linkTo(parent.end, 12.dp) + }, + onSubscribeClicked = { + eventCallback(LabelerEvent.Subscribe(labeler.did)) + }, + onUnsubscribeClicked = { + eventCallback(LabelerEvent.Unsubscribe(labeler.did)) + }, + onMenuClicked = { + // TODO: add labeler menu + }, + ) + OutlinedAvatar( + url = labeler.avatar.orEmpty(), + contentDescription = "Avatar for ${labeler.displayName} ${labeler.handle}", + modifier = Modifier.zIndex(4f) + .constrainAs(avatar) { + centerAround(avatarGuide) + }, + size = avatarSize, + outlineSize = 2.dp, + avatarShape = AvatarShape.Rounded + ) + } else { + Surface( + color = MaterialTheme.colorScheme.background, + shape = MaterialTheme.shapes.small, + modifier = Modifier + .height(avatarSize) + .constrainAs(info) { + centerAround(centreGuide) + start.linkTo(avatarGuide, (-20).dp) + }//.border(1.dp, Color.Green), + ) { + Row { + OutlinedAvatar( + url = labeler.avatar.orEmpty(), + contentDescription = "Avatar for ${labeler.displayName} ${labeler.handle}", + size = avatarSize, + outlineSize = 2.dp, + avatarShape = AvatarShape.Rounded + ) + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier.padding(start = 10.dp, end = 8.dp, bottom = 4.dp) + ) { + Text( + text = name, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = " @${labeler.handle}", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + } + } + + } + }, + navigationIcon = { + if (isTopLevel) { + IconButton( + onClick = { onBackClicked() }, + modifier = Modifier.size(30.dp).zIndex(4f), + + ) { + Icon( + imageVector = Icons.Default.ArrowBackIosNew, + contentDescription = "Back", + ) + } + } + }, + actions = {}, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.largeTopAppBarColors( + containerColor = Color.Transparent + ), + modifier = Modifier + .constrainAs(appbar) { + top.linkTo(parent.top, 15.dp) + }.zIndex(1f) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) + .wrapContentHeight(Alignment.Top) + //.border(1.dp, Color.Magenta) + , + windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top) + ) + if(!collapsed){ + + Column( + modifier = Modifier + .constrainAs(text) { + top.linkTo(userStats.bottom) + start.linkTo(parent.start) + } + .padding(start = 20.dp, end = 20.dp, top = bannerHeight +40.dp)//.border(1.dp, Color.Yellow) + ) { + + SelectionContainer { + Text( + text = name, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + SelectionContainer { + Text( + text = " @${labeler.handle}", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium, + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + SelectionContainer { + RichTextElement(labeler.creator?.description.orEmpty()) + } + } + + } + } +} \ No newline at end of file diff --git a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/theme/Theme.desktop.kt b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/theme/Theme.desktop.kt index 4732254..ef9b8cb 100644 --- a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/theme/Theme.desktop.kt +++ b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/ui/theme/Theme.desktop.kt @@ -15,7 +15,7 @@ actual fun MorphoTheme( } else { LightColorScheme }, - typography = Typography, + typography = MorphoTypography(), content = content ) } \ No newline at end of file diff --git a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/util/LinkParsing.desktop.kt b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/util/LinkParsing.desktop.kt index f2ebd1c..89e15ca 100644 --- a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/util/LinkParsing.desktop.kt +++ b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/util/LinkParsing.desktop.kt @@ -1,11 +1,12 @@ package com.morpho.app.util +import androidx.compose.ui.platform.UriHandler import java.awt.Desktop import java.net.URI import java.util.Locale -actual fun openBrowser(url: String) { +actual fun openBrowser(url: String, uriHandler: UriHandler) { val osName by lazy(LazyThreadSafetyMode.NONE) { System.getProperty("os.name").lowercase(Locale.ROOT) } val desktop = Desktop.getDesktop() try { diff --git a/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/util/Savers.desktop.kt b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/util/Savers.desktop.kt new file mode 100644 index 0000000..48368fc --- /dev/null +++ b/Morpho/composeApp/src/desktopMain/kotlin/com/morpho/app/util/Savers.desktop.kt @@ -0,0 +1,2 @@ +package com.morpho.app.util + diff --git a/Morpho/composeApp/src/desktopMain/kotlin/main.kt b/Morpho/composeApp/src/desktopMain/kotlin/main.kt index 00ec607..cfbac5d 100644 --- a/Morpho/composeApp/src/desktopMain/kotlin/main.kt +++ b/Morpho/composeApp/src/desktopMain/kotlin/main.kt @@ -1,7 +1,14 @@ import androidx.compose.foundation.Image -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.foundation.window.WindowDraggableArea @@ -10,76 +17,94 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.outlined.Rectangle -import androidx.compose.material3.* +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.* +import androidx.compose.ui.window.DialogState +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowScope +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberDialogState +import androidx.compose.ui.window.rememberWindowState +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import ch.qos.logback.classic.LoggerContext import ch.qos.logback.core.util.StatusPrinter2 +import com.github.tkuenneth.nativeparameterstoreaccess.MacOSDefaults.getDefaultsEntry +import com.github.tkuenneth.nativeparameterstoreaccess.NativeParameterStoreAccess.IS_MACOS +import com.github.tkuenneth.nativeparameterstoreaccess.NativeParameterStoreAccess.IS_WINDOWS +import com.github.tkuenneth.nativeparameterstoreaccess.WindowsRegistry.getWindowsRegistryEntry import com.morpho.app.App +import com.morpho.app.data.DarkModeSetting +import com.morpho.app.data.MorphoAgent import com.morpho.app.data.PreferencesRepository import com.morpho.app.di.appModule -import com.morpho.app.di.dataModule -import com.morpho.app.di.storageModule +import com.morpho.app.getPlatformStorageDir import com.morpho.app.ui.theme.MorphoTheme -import com.morpho.butterfly.Butterfly import com.morpho.butterfly.auth.SessionRepository import com.morpho.butterfly.auth.UserRepository -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.mapLatest import morpho.composeapp.generated.resources.Res import morpho.composeapp.generated.resources.morpho_icon_transparent -import net.harawata.appdirs.AppDirsFactory -import okio.Path.Companion.toPath import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.imageResource import org.jetbrains.compose.resources.painterResource import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.context.startKoin -import org.koin.core.logger.Level import org.koin.core.parameter.parametersOf -import org.lighthousegames.logging.* +import org.koin.logger.slf4jLogger +import org.lighthousegames.logging.KmLogging +import org.lighthousegames.logging.LogLevel +import org.lighthousegames.logging.PlatformLogger +import org.lighthousegames.logging.VariableLogLevel +import org.lighthousegames.logging.logging +import org.slf4j.Logger import org.slf4j.LoggerFactory -import kotlin.io.path.createDirectories val log = logging("main") -@OptIn(KoinExperimentalAPI::class, ExperimentalResourceApi::class) +fun getLogger(): Logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) + +fun getLogger(name: String): Logger = LoggerFactory.getLogger(name) + +@OptIn(KoinExperimentalAPI::class, ExperimentalResourceApi::class, ExperimentalVoyagerApi::class, + ExperimentalCoroutinesApi::class +) fun main() = application { StatusPrinter2().print(LoggerFactory.getILoggerFactory() as LoggerContext) KmLogging.setLoggers(PlatformLogger(VariableLogLevel(LogLevel.Verbose))) val koin = startKoin { - printLogger(Level.DEBUG) - modules(appModule, storageModule, dataModule) + slf4jLogger() + modules(appModule)//, storageModule, dataModule) }.koin - val storageDir = AppDirsFactory.getInstance() - .getUserDataDir("com.morpho.app", "0.1.0", "Morpho") - val path = storageDir.toPath() - path.toNioPath().createDirectories() - val cacheDir = AppDirsFactory.getInstance() - .getUserCacheDir("com.morpho.app", "0.1.0", "Morpho") - val cachePath = cacheDir.toPath() - cachePath.toNioPath().createDirectories() - koin.get { parametersOf(storageDir) } - koin.get { parametersOf(storageDir) } - val api = koin.get() - val prefs = koin.get { parametersOf(storageDir) } - - - val morphoPrefs = runBlocking { - prefs.prefs.firstOrNull()?.firstOrNull()?.morphoPrefs + val storageDir = getPlatformStorageDir() + val sessionRepo = koin.get { parametersOf(storageDir) } + val userRepo = koin.get { parametersOf(storageDir) } + val prefsRepo = koin.get { parametersOf(storageDir) } + val agent = koin.get { + parametersOf(sessionRepo, userRepo, prefsRepo) } - val (undecorated, tabbed) = if (morphoPrefs != null) { + val morphoPrefs by derivedStateOf { agent.morphoPrefs } + val (undecorated, tabbed) = remember { log.d{ "Morpho Preferences: $morphoPrefs" } - morphoPrefs.tabbed to morphoPrefs.undecorated - } else { - log.d {"No Morpho Preferences found, using defaults" } - true to true + (morphoPrefs.value.tabbed == true) to (morphoPrefs.value.undecorated == true) } val windowState = rememberWindowState( placement = WindowPlacement.Floating, @@ -88,9 +113,6 @@ fun main() = application { Window( onCloseRequest = { - runBlocking { - api.refreshSession().join() - } (::exitApplication)() }, state = windowState, @@ -99,14 +121,24 @@ fun main() = application { transparent = undecorated, icon = painterResource(Res.drawable.morpho_icon_transparent) ) { - MorphoTheme(darkTheme = isSystemInDarkTheme()) { + val darkTheme by morphoPrefs.mapLatest { + when(it.darkMode){ + DarkModeSetting.SYSTEM -> isSystemInDarkTheme() + DarkModeSetting.LIGHT -> false + DarkModeSetting.DARK -> true + null -> isSystemInDarkTheme() + } + }.collectAsState(when(morphoPrefs.value.darkMode){ + DarkModeSetting.SYSTEM -> isSystemInDarkTheme() + DarkModeSetting.LIGHT -> false + DarkModeSetting.DARK -> true + null -> isSystemInDarkTheme() + }) + MorphoTheme(darkTheme = darkTheme, dynamicColor = false) { if(undecorated) { MorphoWindow( windowState = windowState, onCloseRequest = { - runBlocking { - api.refreshSession().join() - } (::exitApplication)() } ) { @@ -118,9 +150,10 @@ fun main() = application { } } + } -/* + fun isSystemInDarkTheme(): Boolean { return when { IS_WINDOWS -> { @@ -140,7 +173,7 @@ fun isSystemInDarkTheme(): Boolean { // just default to dark mode for now } } -}*/ +} @OptIn(ExperimentalResourceApi::class) @Composable diff --git a/Morpho/composeApp/src/desktopMain/resources/logback.xml b/Morpho/composeApp/src/desktopMain/resources/logback.xml new file mode 100644 index 0000000..062ef69 --- /dev/null +++ b/Morpho/composeApp/src/desktopMain/resources/logback.xml @@ -0,0 +1,42 @@ + + + + + + + + morpho-app-run_${bySecond}.log + + %-4relative [%thread] %-5level %logger{35} -%kvp -%msg%n + + + + + + true + + morpho-app-ongoing.%d{yyyy-MM-dd}.log + 30 + 3GB + + + + %-4relative [%thread] %-5level %logger{35} -%kvp -%msg%n + + + + + + %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n + + + + + + + + + \ No newline at end of file diff --git a/Morpho/composeApp/src/iosMain/kotlin/Platform.ios.kt b/Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/Platform.ios.kt similarity index 90% rename from Morpho/composeApp/src/iosMain/kotlin/Platform.ios.kt rename to Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/Platform.ios.kt index 5cef987..f79a606 100644 --- a/Morpho/composeApp/src/iosMain/kotlin/Platform.ios.kt +++ b/Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/Platform.ios.kt @@ -1,3 +1,4 @@ +package com.morpho.app import platform.UIKit.UIDevice class IOSPlatform: Platform { diff --git a/Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/ui/theme/Theme.ios.kt b/Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/ui/theme/Theme.ios.kt index 5cab5f3..ef9b8cb 100644 --- a/Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/ui/theme/Theme.ios.kt +++ b/Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/ui/theme/Theme.ios.kt @@ -1,5 +1,6 @@ package com.morpho.app.ui.theme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @Composable @@ -8,4 +9,13 @@ actual fun MorphoTheme( dynamicColor: Boolean, content: @Composable () -> Unit ) { + MaterialTheme( + colorScheme = if (darkTheme) { + DarkColorScheme + } else { + LightColorScheme + }, + typography = MorphoTypography(), + content = content + ) } \ No newline at end of file diff --git a/Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/util/LinkParsing.ios.kt b/Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/util/LinkParsing.ios.kt index 0dc5955..5ce6388 100644 --- a/Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/util/LinkParsing.ios.kt +++ b/Morpho/composeApp/src/iosMain/kotlin/com/morpho/app/util/LinkParsing.ios.kt @@ -1,4 +1,6 @@ package com.morpho.app.util -actual fun openBrowser(url: String) { +import androidx.compose.ui.platform.UriHandler + +actual fun openBrowser(url: String, uriHandler: UriHandler) { } \ No newline at end of file diff --git a/Morpho/composeApp/src/main/res/font/ibm_plex_sans.ttf b/Morpho/composeApp/src/main/res/font/ibm_plex_sans.ttf new file mode 100644 index 0000000..f18ce21 Binary files /dev/null and b/Morpho/composeApp/src/main/res/font/ibm_plex_sans.ttf differ diff --git a/Morpho/gradle.properties b/Morpho/gradle.properties index 7971b8d..a71343f 100644 --- a/Morpho/gradle.properties +++ b/Morpho/gradle.properties @@ -13,4 +13,6 @@ kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.enableCInteropCommonization=true #Development -development=true \ No newline at end of file +development=true + +buildkonfig.flavor=dev \ No newline at end of file diff --git a/Morpho/gradle/libs.versions.toml b/Morpho/gradle/libs.versions.toml index 4b61153..4fa05c9 100644 --- a/Morpho/gradle/libs.versions.toml +++ b/Morpho/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.2.2" +agp = "8.5.2" android-compileSdk = "34" android-minSdk = "24" android-targetSdk = "34" @@ -12,9 +12,9 @@ androidx-espresso-core = "3.6.1" androidx-material = "1.12.0" androidx-test-junit = "1.2.1" appdirs = "1.2.2" -compose = "1.6.8" -compose-plugin = "1.6.11" -constraintlayoutComposeMultiplatform = "0.3.0-alpha01" +compose = "1.6.7" +compose-plugin = "1.7.0" +constraintlayoutComposeMultiplatform = "0.4.0" datastorePreferencesCore = "1.1.1" imageLoader = "1.7.8" filekit = "0.8.2" @@ -22,34 +22,43 @@ junit = "4.13.2" jwt = "1.2.6" kjwt = "0.9.0" kmmViewmodelCore = "1.0.0-ALPHA-20" -kotlin = "2.0.10" -ksp-version = "2.0.10-1.0.24" +kotlin = "2.0.20" +ksp-version = "2.0.20-1.0.24" koin-bom = "3.5.3" koin-ksp = "1.3.1" koin-compose = "1.1.2" kotlinJwt = "1.3.1" -kotlin-reflect = "2.0.10" +kotlin-reflect = "2.0.20" kotlin-gradle-plugin = "1.9.0" kotlinx-serialization = "1.6.3" kotlinx-datetime = "0.6.0" kotlinx-coroutines = "1.8.1" kotlinx-immutable = "0.3.7" -kstore = "0.7.1" +kstore = "0.8.0" +pagingCommon = "3.3.0-alpha02-0.5.1" +pagingComposeCommon = "3.3.0-alpha02-0.5.1" +pagingRuntime = "3.3.0-alpha02" +pagingRuntimeUikit = "3.3.0-alpha02-0.5.1" +pagingTesting = "3.3.0-alpha02-0.5.1" +parcelize = "0.9.0" ktor = "2.3.9" ktorClientAndroid = "[ktor-version]" logbackClassic = "1.5.7" logbackCore = "1.5.7" logging = "1.4.2" +logkmpanion = "1.8.0" nativeparameterstoreaccess = "0.1.0" okio = "3.9.0" slf4j-api = "2.0.13" github-kotlin-logging-jvm = "5.1.0" kotlinx-abi-plugin = "0.13.2" window = "1.3.0" +toolargetool = "0.3.0" +voyagerLifecycleKmp = "1.1.0-beta02" material3-android = "1.2.1" accompanist-permissions = "0.32.0" coil = "3.0.0-alpha06" -voyager = "1.0.0" +voyager = "1.1.0-beta02" kmpalette = "3.1.0" @@ -57,10 +66,12 @@ kmpalette = "3.1.0" androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastorePreferencesCore" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingRuntime" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } -androidx-material3 = { group = "com.google.android.material3", name = "material" } +androidx-material3 = { group = "com.google.android.material3", name = "material", version.ref = "androidx-material" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } @@ -125,16 +136,23 @@ filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "file logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logbackClassic" } logback-core = { module = "ch.qos.logback:logback-core", version.ref = "logbackCore" } logging = { module = "org.lighthousegames:logging", version.ref = "logging" } +logkmpanion = { module = "io.github.idfinance-oss:logkmpanion", version.ref = "logkmpanion" } nativeparameterstoreaccess = { module = "com.github.tkuenneth:nativeparameterstoreaccess", version.ref = "nativeparameterstoreaccess" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-api" } oshai-kotlin-logging-jvm = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "github-kotlin-logging-jvm" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version = "2.0.9" } apache-commons = { module = "org.apache.commons:commons-lang3", version = "3.13.0" } +paging-common = { module = "app.cash.paging:paging-common", version.ref = "pagingCommon" } +paging-compose-common = { module = "app.cash.paging:paging-compose-common", version.ref = "pagingComposeCommon" } +paging-runtime-uikit = { module = "app.cash.paging:paging-runtime-uikit", version.ref = "pagingRuntimeUikit" } +paging-testing = { module = "app.cash.paging:paging-testing", version.ref = "pagingTesting" } +parcelize = { module = "dev.icerock.moko:parcelize", version.ref = "parcelize" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-gradle-plugin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin-reflect" } ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" } ktor-client-resources = { module = "io.ktor:ktor-client-resources", version.ref = "ktor" } ktor-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } @@ -150,8 +168,12 @@ ksp-symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processin kstore = { module = "io.github.xxfast:kstore", version.ref = "kstore" } kstore-file = { module = "io.github.xxfast:kstore-file", version.ref = "kstore" } + +toolargetool = { module = "com.gu.android:toolargetool", version.ref = "toolargetool" } voyager-bottom-sheet-navigator = { module = "cafe.adriel.voyager:voyager-bottom-sheet-navigator", version.ref = "voyager" } voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" } + +voyager-lifecycle-kmp = { module = "cafe.adriel.voyager:voyager-lifecycle-kmp", version.ref = "voyager" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" } voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } diff --git a/Morpho/gradle/wrapper/gradle-wrapper.properties b/Morpho/gradle/wrapper/gradle-wrapper.properties index 3fa8f86..09523c0 100644 --- a/Morpho/gradle/wrapper/gradle-wrapper.properties +++ b/Morpho/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/Morpho/settings.gradle.kts b/Morpho/settings.gradle.kts index e90a61e..7e05248 100644 --- a/Morpho/settings.gradle.kts +++ b/Morpho/settings.gradle.kts @@ -15,6 +15,7 @@ pluginManagement { } dependencyResolutionManagement { + @Suppress("UnstableApiUsage") repositories { mavenLocal() mavenCentral() diff --git a/gradle.properties b/gradle.properties index 877fcb1..8badbb9 100755 --- a/gradle.properties +++ b/gradle.properties @@ -15,4 +15,6 @@ kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.enableCInteropCommonization=true #Development -development=true \ No newline at end of file +development=true + +buildkonfig.flavor=dev \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9725199..1ffb2ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.2.2" +agp = "8.5.2" android-compileSdk = "34" android-minSdk = "24" android-targetSdk = "34" @@ -12,9 +12,9 @@ androidx-espresso-core = "3.6.1" androidx-material = "1.12.0" androidx-test-junit = "1.2.1" appdirs = "1.2.2" -compose = "1.6.8" -compose-plugin = "1.6.11" -constraintlayoutComposeMultiplatform = "0.3.0-alpha01" +compose = "1.6.7" +compose-plugin = "1.7.0" +constraintlayoutComposeMultiplatform = "0.4.0" datastorePreferencesCore = "1.1.1" filekit = "0.8.2" imageLoader = "1.7.8" @@ -22,44 +22,56 @@ junit = "4.13.2" jwt = "1.2.6" kjwt = "0.9.0" kmmViewmodelCore = "1.0.0-ALPHA-20" -kotlin = "2.0.10" +kotlin = "2.0.20" kotlinJwt = "1.3.1" -ksp-version = "2.0.10-1.0.24" +ksp-version = "2.0.20-1.0.24" koin-bom = "3.5.3" koin-ksp = "1.3.1" koin-compose = "1.1.2" -kotlin-reflect = "2.0.10" +kotlin-reflect = "2.0.20" kotlin-gradle-plugin = "2.0.10" kotlinx-serialization = "1.6.3" kotlinx-datetime = "0.6.0" kotlinx-coroutines = "1.8.1" kotlinx-immutable = "0.3.7" -kstore = "0.7.1" +kstore = "0.8.0" ktor = "2.3.9" +ktorClientEncoding = "2.3.9" logbackClassic = "1.5.7" logbackCore = "1.5.7" logging = "1.4.2" +logkmpanion = "1.8.0" nativeparameterstoreaccess = "0.1.0" okio = "3.9.0" +pagingCommon = "3.3.0-alpha02-0.5.1" +pagingComposeCommon = "3.3.0-alpha02-0.5.1" +pagingRuntime = "3.3.0-alpha02" +pagingRuntimeUikit = "3.3.0-alpha02-0.5.1" +pagingTesting = "3.3.0-alpha02-0.5.1" +parcelize = "0.9.0" slf4j-api = "2.0.13" github-kotlin-logging-jvm = "5.1.0" kotlinx-abi-plugin = "0.13.2" +toolargetool = "0.3.0" +voyagerLifecycleKmp = "1.1.0-beta02" window = "1.3.0" material3-android = "1.2.1" accompanist-permissions = "0.32.0" coil = "3.0.0-alpha06" -voyager = "1.0.0" +voyager = "1.1.0-beta02" kmpalette = "3.1.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastorePreferencesCore" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingRuntime" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } -androidx-material3 = { group = "com.google.android.material3", name = "material" } +androidx-material3 = { group = "com.google.android.material3", name = "material", version.ref = "androidx-material" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist-permissions" } @@ -120,10 +132,17 @@ koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", versi koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin-compose" } +ktor-client-encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logbackClassic" } logback-core = { module = "ch.qos.logback:logback-core", version.ref = "logbackCore" } logging = { module = "org.lighthousegames:logging", version.ref = "logging" } +logkmpanion = { module = "io.github.idfinance-oss:logkmpanion", version.ref = "logkmpanion" } nativeparameterstoreaccess = { module = "com.github.tkuenneth:nativeparameterstoreaccess", version.ref = "nativeparameterstoreaccess" } +paging-common = { module = "app.cash.paging:paging-common", version.ref = "pagingCommon" } +paging-compose-common = { module = "app.cash.paging:paging-compose-common", version.ref = "pagingComposeCommon" } +paging-runtime-uikit = { module = "app.cash.paging:paging-runtime-uikit", version.ref = "pagingRuntimeUikit" } +paging-testing = { module = "app.cash.paging:paging-testing", version.ref = "pagingTesting" } +parcelize = { module = "dev.icerock.moko:parcelize", version.ref = "parcelize" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-api" } oshai-kotlin-logging-jvm = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "github-kotlin-logging-jvm" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version = "2.0.9" } @@ -147,8 +166,10 @@ ksp-symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processin kstore = { module = "io.github.xxfast:kstore", version.ref = "kstore" } kstore-file = { module = "io.github.xxfast:kstore-file", version.ref = "kstore" } +toolargetool = { module = "com.gu.android:toolargetool", version.ref = "toolargetool" } voyager-bottom-sheet-navigator = { module = "cafe.adriel.voyager:voyager-bottom-sheet-navigator", version.ref = "voyager" } voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" } +voyager-lifecycle-kmp = { module = "cafe.adriel.voyager:voyager-lifecycle-kmp", version.ref = "voyager" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" } voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c..2c35211 100755 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3fa8f86..09523c0 100755 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index fcb6fca..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -83,7 +85,9 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -144,7 +148,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +156,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -201,11 +205,11 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ diff --git a/gradlew.bat b/gradlew.bat index 93e3f59..9d21a21 100755 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail