From 5258902feaa235687987b0c979040e1e9b8ea5e4 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Thu, 15 Dec 2022 12:08:57 -0500 Subject: [PATCH 01/28] feed data flow --- data/network/build.gradle.kts | 3 +- .../androiddev/common/network/MastodonApi.kt | 8 ++ .../common/network/MastodonApiKtor.kt | 18 +++ .../common/network/di/NetworkModule.kt | 8 ++ .../common/network/model/Application.kt | 4 +- data/persistence/build.gradle.kts | 6 +- .../di/AndroidPersistenceModule.kt | 10 ++ .../persistence/di/PersistenceModule.kt | 1 + .../common/timeline/ConflictResolution.sq | 19 +++ .../androiddev/common/timeline/Timeline.sq | 17 +++ data/repository/build.gradle.kts | 22 ++- .../common/repository/di/RepositoryModule.kt | 14 ++ .../timeline/HomeTimelineRepository.kt | 37 ++++++ .../repository/timeline/TimelineRepoModule.kt | 125 ++++++++++++++++++ di/build.gradle.kts | 4 +- .../social/androiddev/common/di/AppModule.kt | 2 + gradle/libs.versions.toml | 3 + .../root/navigation/DefaultRootComponent.kt | 2 + ui/signed-in/build.gradle.kts | 6 + .../src/androidMain/AndroidManifest.xml | 4 +- .../composables/SignedInRootContent.kt | 11 +- .../DefaultSignedInRootComponent.kt | 10 +- ui/timeline/build.gradle.kts | 3 + .../androiddev/timeline/TimelineContent.kt | 35 ++++- .../navigation/DefaultTimelineComponent.kt | 16 --- .../timeline/navigation/TimelineComponent.kt | 62 ++++++++- 26 files changed, 407 insertions(+), 43 deletions(-) create mode 100644 data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/ConflictResolution.sq create mode 100644 data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq create mode 100644 data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt create mode 100644 data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt delete mode 100644 ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt diff --git a/data/network/build.gradle.kts b/data/network/build.gradle.kts index 26b85a26..cda7ca5a 100644 --- a/data/network/build.gradle.kts +++ b/data/network/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { implementation(libs.io.ktor.client.serialization) implementation(libs.io.ktor.serialization.kotlinx.json) implementation(libs.io.ktor.client.content.negotiation) + implementation(libs.io.ktor.client.auth) implementation(libs.io.ktor.client.logging) implementation(libs.org.jetbrains.kotlinx.serialization.json) implementation(libs.io.insert.koin.core) @@ -74,13 +75,11 @@ kotlin { // iOS val iosX64Main by getting val iosArm64Main by getting - val iosSimulatorArm64Main by getting val iosMain by creating { dependsOn(getByName("commonMain")) iosX64Main.dependsOn(this) iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) dependencies { implementation(libs.io.ktor.client.darwin) diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApi.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApi.kt index f2c858ab..647721d3 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApi.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApi.kt @@ -13,6 +13,7 @@ import social.androiddev.common.network.model.Application import social.androiddev.common.network.model.AvailableInstance import social.androiddev.common.network.model.Instance import social.androiddev.common.network.model.NewOauthApplication +import social.androiddev.common.network.model.Status import social.androiddev.common.network.model.Token interface MastodonApi { @@ -77,4 +78,11 @@ interface MastodonApi { * @return an instance entity */ suspend fun getInstance(domain: String? = null): Result + + /** + * Fetch home feed for a particular user + * @param accessToken representing the user + * @return a list of [Status] + */ + suspend fun getHomeFeed(domain: String, accessToken: String): Result> } diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt index 4eebdf7d..bf5a2338 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt @@ -25,6 +25,7 @@ import social.androiddev.common.network.model.Application import social.androiddev.common.network.model.AvailableInstance import social.androiddev.common.network.model.Instance import social.androiddev.common.network.model.NewOauthApplication +import social.androiddev.common.network.model.Status import social.androiddev.common.network.model.Token import social.androiddev.common.network.model.request.CreateAccessTokenBody import social.androiddev.common.network.model.request.CreateApplicationBody @@ -124,4 +125,21 @@ internal class MastodonApiKtor( Result.failure(exception = exception) } } + + override suspend fun getHomeFeed(domain: String, accessToken: String): Result> { + return try { + val url = "https://$domain/api/v1/timelines/home" + val body = httpClient.get(url) { + headers { + append(HttpHeaders.Authorization, "Bearer $accessToken") + } + }.body>() + Result.success( + body + ) + } catch (exception: SerializationException) { + Result.failure(exception = exception) + } catch (exception: ResponseException) { + Result.failure(exception = exception) + } } } diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/di/NetworkModule.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/di/NetworkModule.kt index a5d3fef4..30f67194 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/di/NetworkModule.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/di/NetworkModule.kt @@ -52,6 +52,14 @@ val networkModule: Module = module { } ) } +// install(Auth) { +// bearer { +// loadTokens { +// // Load tokens from a local storage and return them as the 'BearerTokens' instance +// BearerTokens("abc123", "xyz111") +// } +// } +// } defaultRequest { url { protocol = URLProtocol.HTTPS diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt index 016bdb71..7d495f1c 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt @@ -17,9 +17,9 @@ import kotlinx.serialization.Serializable */ @Serializable data class Application( - val id: String, + val id: String?=null, val name: String, - @SerialName("vapid_key") val vapidKey: String, + @SerialName("vapid_key") val vapidKey: String?=null, // optional attributes val website: String? = null, diff --git a/data/persistence/build.gradle.kts b/data/persistence/build.gradle.kts index 7f93a650..d9a147cd 100644 --- a/data/persistence/build.gradle.kts +++ b/data/persistence/build.gradle.kts @@ -12,6 +12,10 @@ sqldelight { packageName = "social.androiddev.common.persistence" sourceFolders = listOf("sqldelight") } + database("TimelineDatabase") { + packageName = "social.androiddev.common.timeline" + sourceFolders = listOf("sqldelightTimeline") + } } val targetSDKVersion: Int by rootProject.extra @@ -79,13 +83,11 @@ kotlin { // iOS val iosX64Main by getting val iosArm64Main by getting - val iosSimulatorArm64Main by getting val iosMain by creating { dependsOn(getByName("commonMain")) iosX64Main.dependsOn(this) iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) dependencies { implementation(libs.com.squareup.sqldelight.native.driver) diff --git a/data/persistence/src/androidMain/kotlin/social/androiddev/common/persistence/di/AndroidPersistenceModule.kt b/data/persistence/src/androidMain/kotlin/social/androiddev/common/persistence/di/AndroidPersistenceModule.kt index b0c2f899..0534a8c7 100644 --- a/data/persistence/src/androidMain/kotlin/social/androiddev/common/persistence/di/AndroidPersistenceModule.kt +++ b/data/persistence/src/androidMain/kotlin/social/androiddev/common/persistence/di/AndroidPersistenceModule.kt @@ -17,6 +17,7 @@ import org.koin.dsl.module import social.androiddev.common.persistence.AuthenticationDatabase import social.androiddev.common.persistence.localstorage.DodoAuthStorage import social.androiddev.common.persistence.localstorage.DodoAuthStorageImpl +import social.androiddev.common.timeline.TimelineDatabase /** * Koin DI module for all android specific persistence dependencies @@ -44,4 +45,13 @@ actual val persistenceModule: Module = module { ) AuthenticationDatabase(driver) } + + single { + val driver = AndroidSqliteDriver( + schema = TimelineDatabase.Schema, + context = get(), + name = FEED_DB_NAME, + ) + TimelineDatabase(driver) + } } diff --git a/data/persistence/src/commonMain/kotlin/social/androiddev/common/persistence/di/PersistenceModule.kt b/data/persistence/src/commonMain/kotlin/social/androiddev/common/persistence/di/PersistenceModule.kt index 39aadc65..de956122 100644 --- a/data/persistence/src/commonMain/kotlin/social/androiddev/common/persistence/di/PersistenceModule.kt +++ b/data/persistence/src/commonMain/kotlin/social/androiddev/common/persistence/di/PersistenceModule.kt @@ -18,4 +18,5 @@ import org.koin.core.module.Module expect val persistenceModule: Module internal const val AUTH_DB_NAME = "authentication.db" +internal const val FEED_DB_NAME = "feed.db" internal const val AUTH_SETTINGS_NAME = "DodoAuthSettings" diff --git a/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/ConflictResolution.sq b/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/ConflictResolution.sq new file mode 100644 index 00000000..f3f875ac --- /dev/null +++ b/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/ConflictResolution.sq @@ -0,0 +1,19 @@ +CREATE TABLE failedWrite ( + key TEXT NOT NULL PRIMARY KEY, + datetime INTEGER AS Long +); + +get: +SELECT * +FROM failedWrite +WHERE key = ?; + +upsert: +INSERT OR REPLACE INTO failedWrite VALUES ?; + +delete: +DELETE FROM failedWrite +WHERE key = ?; + +deleteAll: +DELETE FROM failedWrite; \ No newline at end of file diff --git a/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq b/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq new file mode 100644 index 00000000..3cd2daae --- /dev/null +++ b/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq @@ -0,0 +1,17 @@ +CREATE TABLE TimelineItem ( + statusId Text NOT NULL PRIMARY KEY, + type TEXT NOT NULL, + createdAt TEXT NOT NULL +); + +insertFeedItem: +INSERT OR REPLACE INTO TimelineItem +VALUES ?; + +selectHomeItems: +SELECT * FROM TimelineItem +WHERE type = "HOME" +ORDER BY createdAt; + +deleteAll: +DELETE FROM TimelineItem; diff --git a/data/repository/build.gradle.kts b/data/repository/build.gradle.kts index 7c68c099..4f0fd8cf 100644 --- a/data/repository/build.gradle.kts +++ b/data/repository/build.gradle.kts @@ -33,11 +33,11 @@ android { } kotlin { - jvm("desktop") android() iosX64() iosArm64() - iosSimulatorArm64() +// iosSimulatorArm64() + jvm("desktop") sourceSets { // shared @@ -48,6 +48,8 @@ kotlin { implementation(projects.domain.authentication) implementation(libs.io.insert.koin.core) implementation(libs.kotlinx.coroutines.core) + api(libs.store) + implementation ("com.squareup.sqldelight:coroutines-extensions:1.5.4") } } @@ -55,28 +57,34 @@ kotlin { // android getByName("androidMain") { dependsOn(commonMain) - dependencies {} + dependencies { + implementation(libs.store) + } } // desktop getByName("desktopMain") { - dependencies {} + dependencies { + implementation(libs.store) + } } // iOS val iosX64Main by getting val iosArm64Main by getting - val iosSimulatorArm64Main by getting +// val iosSimulatorArm64Main by getting val iosMain by creating { dependsOn(getByName("commonMain")) iosX64Main.dependsOn(this) iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) +// iosSimulatorArm64Main.dependsOn(this) - dependencies {} + dependencies { +// implementation(libs.store) + } } // testing diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt index 4355d407..0ee51a2a 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt @@ -9,10 +9,23 @@ */ package social.androiddev.common.repository.di +import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList import kotlinx.coroutines.Dispatchers import org.koin.core.module.Module import org.koin.dsl.module +import org.mobilenativefoundation.store.store5.Bookkeeper +import org.mobilenativefoundation.store.store5.Market +import org.mobilenativefoundation.store.store5.NetworkFetcher +import org.mobilenativefoundation.store.store5.NetworkUpdater +import org.mobilenativefoundation.store.store5.OnNetworkCompletion +import org.mobilenativefoundation.store.store5.Store +import social.androiddev.common.network.MastodonApi +import social.androiddev.common.network.model.Status +import social.androiddev.common.persistence.localstorage.DodoAuthStorage import social.androiddev.common.repository.AuthenticationRepositoryImpl +import social.androiddev.common.timeline.TimelineDatabase +import social.androiddev.common.timeline.TimelineItem import social.androiddev.domain.authentication.repository.AuthenticationRepository /** @@ -31,3 +44,4 @@ val repositoryModule: Module = module { ) } } + diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt new file mode 100644 index 00000000..e118a064 --- /dev/null +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt @@ -0,0 +1,37 @@ +package social.androiddev.common.repository.timeline + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import org.mobilenativefoundation.store.store5.Market +import org.mobilenativefoundation.store.store5.MarketResponse +import org.mobilenativefoundation.store.store5.ReadRequest +import social.androiddev.common.timeline.TimelineItem + +interface HomeTimelineRepository { + suspend fun read(): Flow>> +} + +class RealHomeTimelineRepository( + private val market: Market, List> +) : HomeTimelineRepository { + /** + * returns a flow of home feed items from a database + * anytime table rows are created/updated will return a new list of timeline items + * on first return will also call network fetcher to get + * latest from network and update local storage with it] + */ + + + override suspend fun read(): Flow>> { + return market.read(ReadRequest.of( + FeedType.Home, + emptyList(), + null, + true)) + .distinctUntilChanged() + } + + +} + + diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt new file mode 100644 index 00000000..6fc65772 --- /dev/null +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt @@ -0,0 +1,125 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ +package social.androiddev.common.repository.timeline + +import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList +import kotlinx.coroutines.flow.map +import org.koin.core.module.Module +import org.koin.dsl.module +import org.mobilenativefoundation.store.store5.Bookkeeper +import org.mobilenativefoundation.store.store5.Market +import org.mobilenativefoundation.store.store5.NetworkFetcher +import org.mobilenativefoundation.store.store5.NetworkUpdater +import org.mobilenativefoundation.store.store5.OnNetworkCompletion +import org.mobilenativefoundation.store.store5.Store +import social.androiddev.common.network.MastodonApi +import social.androiddev.common.network.model.Status +import social.androiddev.common.persistence.localstorage.DodoAuthStorage +import social.androiddev.common.timeline.TimelineDatabase +import social.androiddev.common.timeline.TimelineItem + +/** + * Koin module containing all koin/bean definitions for + * timeline repository delegates. + */ +val timelineRepoModule: Module = module { + + factory { RealHomeTimelineRepository(get()) } + + factory, List>> { + val database = get() + Store.by( + reader = { key: FeedType -> + when(key){ + is FeedType.Home -> get() + .timelineQueries + .selectHomeItems() + .asFlow() + .mapToList().map { + it.ifEmpty { throw Exception("Empty list") } + } + } + + }, + writer = { _, input -> + input.forEach(database::tryWriteItem) + true + }, + deleter = { TODO() }, + clearer = { TODO() } + ) + } + factory { + //Todo Add logic for conflict resolution handling for when we start posting toots + Bookkeeper.by( + read = { _: FeedType -> null }, + write = { _, _ -> true }, + delete = { TODO() }, + deleteAll = { TODO() } + ) + } + + factory { + NetworkFetcher.by( + get = { key: FeedType -> + when(key) { + is FeedType.Home -> { + val authStorage = get() + get() + .getHomeFeed(authStorage.currentDomain!!, authStorage.getAccessToken(authStorage.currentDomain!!)!!) + .getOrThrow() + .map(::timelineItem) + } + } + }, + post = { key, item -> TODO() }, + converter = { it } + ) + } + + + factory { + NetworkUpdater.by( + post = { key: FeedType, _: List -> + get() + TODO() + }, + onCompletion = OnNetworkCompletion( + onSuccess = {}, + onFailure = {} + ), + converter = { TODO() } + ) + } + + factory, List>> { + Market.of, List>( + stores = listOf(get()), //TODO MIKE: ADD memory cache + bookkeeper = get(), + fetcher = get(), + updater = get() + ) + } +} + +private fun timelineItem(it: Status) = + TimelineItem(it.id, FeedType.Home.type, it.createdAt) + +fun TimelineDatabase.tryWriteItem(timelineItem: TimelineItem): Boolean = try { + timelineQueries.insertFeedItem(timelineItem) + true +} catch (t: Throwable) { + throw RuntimeException(t) +} + +sealed class FeedType(val type:String){ + object Home: FeedType("HOME") +} \ No newline at end of file diff --git a/di/build.gradle.kts b/di/build.gradle.kts index 3f1bb5f6..c4b5ca21 100644 --- a/di/build.gradle.kts +++ b/di/build.gradle.kts @@ -35,7 +35,7 @@ kotlin { android() iosX64() iosArm64() - iosSimulatorArm64() +// iosSimulatorArm64() sourceSets { named("commonMain") { @@ -59,13 +59,11 @@ kotlin { // iOS val iosX64Main by getting val iosArm64Main by getting - val iosSimulatorArm64Main by getting val iosMain by creating { dependsOn(getByName("commonMain")) iosX64Main.dependsOn(this) iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) dependencies {} } diff --git a/di/src/commonMain/kotlin/social/androiddev/common/di/AppModule.kt b/di/src/commonMain/kotlin/social/androiddev/common/di/AppModule.kt index a13c48d0..57cc7415 100644 --- a/di/src/commonMain/kotlin/social/androiddev/common/di/AppModule.kt +++ b/di/src/commonMain/kotlin/social/androiddev/common/di/AppModule.kt @@ -12,6 +12,7 @@ package social.androiddev.common.di import social.androiddev.common.network.di.networkModule import social.androiddev.common.persistence.di.persistenceModule import social.androiddev.common.repository.di.repositoryModule +import social.androiddev.common.repository.timeline.timelineRepoModule import social.androiddev.domain.authentication.di.domainAuthModule /** @@ -23,4 +24,5 @@ fun appModule() = listOf( persistenceModule, domainAuthModule, repositoryModule, + timelineRepoModule ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d01aeb71..882f30c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ org-xerial = "3.8.10.2" io-insert-koin = "3.2.0" com-russhwolf = "1.0.0-RC" kotlinx-serialization = "1.4.0" +store = "5.0.0-alpha02" io-github-aakira = "2.6.1" [libraries] @@ -38,6 +39,7 @@ io-ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "io-kto io-ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "io-ktor" } io-ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "io-ktor" } io-ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "io-ktor" } +io-ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "io-ktor" } io-ktor-client-mock-jvm = { module = "io.ktor:ktor-client-mock-jvm", version.ref = "io-ktor" } io-ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "io-ktor" } io-ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "io-ktor" } @@ -70,6 +72,7 @@ com-arkivanov-decompose-extensions-compose-jetbrains = { module = "com.arkivanov com-arkivanov-decompose-extensions-compose-jetpack = { module = "com.arkivanov.decompose:extensions-compose-jetpack", version.ref = "com-arkivanov-decompose" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "com-russhwolf" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +store = {module = "org.mobilenativefoundation.store:store5", version.ref = "store"} io-github-aakira-napier = { module = "io.github.aakira:napier", version.ref = "io-github-aakira" } [plugins] diff --git a/ui/root/src/commonMain/kotlin/social/androiddev/root/navigation/DefaultRootComponent.kt b/ui/root/src/commonMain/kotlin/social/androiddev/root/navigation/DefaultRootComponent.kt index 4eed91a0..e454d71e 100644 --- a/ui/root/src/commonMain/kotlin/social/androiddev/root/navigation/DefaultRootComponent.kt +++ b/ui/root/src/commonMain/kotlin/social/androiddev/root/navigation/DefaultRootComponent.kt @@ -18,6 +18,7 @@ import com.arkivanov.decompose.router.stack.replaceCurrent import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.parcelable.Parcelable import com.arkivanov.essenty.parcelable.Parcelize +import social.androiddev.root.navigation.DefaultRootComponent.Config import social.androiddev.signedin.navigation.DefaultSignedInRootComponent import social.androiddev.signedout.root.DefaultSignedOutRootComponent import kotlin.coroutines.CoroutineContext @@ -67,6 +68,7 @@ class DefaultRootComponent( componentContext: ComponentContext, ) = DefaultSignedInRootComponent( componentContext = componentContext, + mainContext=mainContext ) private fun createSplashComponent( diff --git a/ui/signed-in/build.gradle.kts b/ui/signed-in/build.gradle.kts index 1adbce0a..241610f4 100644 --- a/ui/signed-in/build.gradle.kts +++ b/ui/signed-in/build.gradle.kts @@ -33,6 +33,9 @@ android { } } } +dependencies { + implementation(project(mapOf("path" to ":data:persistence"))) +} kotlin { jvm("desktop") @@ -46,6 +49,9 @@ kotlin { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material) + implementation(projects.data.persistence) + implementation(projects.data.repository) + implementation(libs.io.insert.koin.core) } } diff --git a/ui/signed-in/src/androidMain/AndroidManifest.xml b/ui/signed-in/src/androidMain/AndroidManifest.xml index 10728cc7..a8800291 100644 --- a/ui/signed-in/src/androidMain/AndroidManifest.xml +++ b/ui/signed-in/src/androidMain/AndroidManifest.xml @@ -1,2 +1,4 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt index 844676e4..b9550c1f 100644 --- a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt +++ b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt @@ -18,9 +18,11 @@ import androidx.compose.ui.Modifier import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState +import kotlinx.coroutines.flow.StateFlow +import org.mobilenativefoundation.store.store5.MarketResponse +import social.androiddev.common.timeline.TimelineItem import social.androiddev.signedin.navigation.SignedInRootComponent import social.androiddev.timeline.TimelineContent -import social.androiddev.timeline.navigation.TimelineComponent /** * The root composable for when the user launches the app and is @@ -47,7 +49,8 @@ fun SignedInRootContent( ) { createdChild -> when (val child = createdChild.instance) { is SignedInRootComponent.Child.Timeline -> { - TimelineTab(child.component) + + TimelineTab(child.component.state) } } } @@ -56,10 +59,10 @@ fun SignedInRootContent( @Composable private fun TimelineTab( - component: TimelineComponent + state: StateFlow>> ) { TimelineContent( - component = component, + state=state, modifier = Modifier.fillMaxSize(), ) } diff --git a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/navigation/DefaultSignedInRootComponent.kt b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/navigation/DefaultSignedInRootComponent.kt index 2978e449..98d10551 100644 --- a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/navigation/DefaultSignedInRootComponent.kt +++ b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/navigation/DefaultSignedInRootComponent.kt @@ -16,7 +16,9 @@ import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.parcelable.Parcelable import com.arkivanov.essenty.parcelable.Parcelize +import social.androiddev.signedin.navigation.DefaultSignedInRootComponent.Config import social.androiddev.timeline.navigation.DefaultTimelineComponent +import kotlin.coroutines.CoroutineContext /** * Default impl of the [SignedInRootComponent] that manages the navigation stack for the @@ -24,8 +26,9 @@ import social.androiddev.timeline.navigation.DefaultTimelineComponent * See [Config] and [SignedInRootComponent.Child] for more details. */ class DefaultSignedInRootComponent( - private val componentContext: ComponentContext -) : SignedInRootComponent, ComponentContext by componentContext { + private val componentContext: ComponentContext, + private val mainContext: CoroutineContext, + ) : SignedInRootComponent, ComponentContext by componentContext { // StackNavigation accepts navigation commands and forwards them to all subscribed observers. private val navigation = StackNavigation() @@ -50,7 +53,8 @@ class DefaultSignedInRootComponent( private fun createTimelineComponent( componentContext: ComponentContext, ) = DefaultTimelineComponent( - componentContext = componentContext + componentContext = componentContext, + mainContext = mainContext ) /** diff --git a/ui/timeline/build.gradle.kts b/ui/timeline/build.gradle.kts index 1403adf8..6b94c839 100644 --- a/ui/timeline/build.gradle.kts +++ b/ui/timeline/build.gradle.kts @@ -39,10 +39,13 @@ kotlin { val commonMain by getting { dependencies { implementation(projects.domain.timeline) + implementation(projects.data.persistence) + implementation(projects.data.repository) implementation(projects.ui.common) implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material) + implementation(libs.io.insert.koin.core) } } diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt index 97f2e6be..b88d8701 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt @@ -17,10 +17,14 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.StateFlow +import org.mobilenativefoundation.store.store5.MarketResponse import social.androiddev.common.theme.DodoTheme +import social.androiddev.common.timeline.TimelineItem import social.androiddev.timeline.navigation.TimelineComponent /** @@ -29,10 +33,37 @@ import social.androiddev.timeline.navigation.TimelineComponent */ @Composable fun TimelineContent( - component: TimelineComponent, + state: StateFlow>>, modifier: Modifier = Modifier, ) { - // TODO: Hook up to View Model for fetching timeline items + val items = state.collectAsState() + + when(val value = items.value){ + is MarketResponse.Success-> { + val feedItems = value.value.map { + dummyFeedItem.copy( + id=it.statusId, + date = it.createdAt + ) + } + + TimelineContent( + items = feedItems, + modifier = modifier, + ) + } + + is MarketResponse.Empty -> { + value + } + is MarketResponse.Failure -> { + val error = value.error + } + is MarketResponse.Loading -> { + value + } + } + TimelineContent( items = listOf(dummyFeedItem), modifier = modifier, diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt deleted file mode 100644 index e60ed20b..00000000 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * This file is part of Dodo. - * - * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Dodo. If not, see . - */ -package social.androiddev.timeline.navigation - -import com.arkivanov.decompose.ComponentContext - -class DefaultTimelineComponent( - private val componentContext: ComponentContext, -) : TimelineComponent diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt index 35236f68..c18cfd3d 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt @@ -9,7 +9,67 @@ */ package social.androiddev.timeline.navigation +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.essenty.instancekeeper.InstanceKeeper +import com.arkivanov.essenty.instancekeeper.getOrCreate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectIndexed +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.mobilenativefoundation.store.store5.MarketResponse +import social.androiddev.common.repository.timeline.HomeTimelineRepository +import social.androiddev.common.timeline.TimelineItem +import kotlin.coroutines.CoroutineContext + /** * The base component describing all business logic needed for the timeline view */ -interface TimelineComponent +interface TimelineComponent{ + val state: StateFlow>> +} + +class DefaultTimelineComponent( + mainContext: CoroutineContext, + private val componentContext: ComponentContext, +) : TimelineComponent, KoinComponent, ComponentContext by componentContext { + + private val homeTimelineRepository:HomeTimelineRepository by inject() + + private val viewModel = instanceKeeper.getOrCreate { + TimelineViewModel( + mainContext = mainContext, + homeTimelineRepository + ) + } + + override val state: StateFlow>> = viewModel.state + +} + +class TimelineViewModel( + private val mainContext: CoroutineContext, + private val homeTimelineRepository: HomeTimelineRepository +) : InstanceKeeper.Instance { + private val scope = CoroutineScope(mainContext + SupervisorJob()) + private val _state = MutableStateFlow>>(MarketResponse.Empty) + val state: StateFlow>> = _state.asStateFlow() + init { + scope.launch { + homeTimelineRepository.read().collect{ + _state.value = it + } + + } + } + override fun onDestroy() { + scope.cancel() // Cancel the scope when the instance is destroyed + } + +} From c11a9a58b03f7f8b6067a5f5bb24ca21258d1600 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Thu, 15 Dec 2022 15:32:29 -0500 Subject: [PATCH 02/28] feed data flow --- .../kotlin/social/androiddev/common/network/model/Status.kt | 2 +- .../kotlin/social/androiddev/common/network/model/Tag.kt | 2 +- .../androiddev/common/repository/timeline/TimelineRepoModule.kt | 2 +- gradle/libs.versions.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt index d2bed412..15f782ec 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt @@ -27,7 +27,7 @@ data class Status( @SerialName("sensitive") val sensitive: Boolean, @SerialName("spoiler_text") val spoilerText: String, @SerialName("media_attachments") val mediaAttachments: List, - @SerialName("application") val application: Application, + @SerialName("application") val application: Application?=null, // rendering attributes @SerialName("mentions") val mentions: List? = null, diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Tag.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Tag.kt index 0aa8cf78..0746da5c 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Tag.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Tag.kt @@ -22,5 +22,5 @@ data class Tag( @SerialName("url") val url: String, // optional attributes - @SerialName("history") val history: List, + @SerialName("history") val history: List?= emptyList(), ) diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt index 6fc65772..21532e00 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt @@ -44,7 +44,7 @@ val timelineRepoModule: Module = module { .selectHomeItems() .asFlow() .mapToList().map { - it.ifEmpty { throw Exception("Empty list") } + it.ifEmpty { return@map null } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 882f30c7..444ff302 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ org-xerial = "3.8.10.2" io-insert-koin = "3.2.0" com-russhwolf = "1.0.0-RC" kotlinx-serialization = "1.4.0" -store = "5.0.0-alpha02" +store = "0.0.1" io-github-aakira = "2.6.1" [libraries] From d59e1c8448d6b61cd9ed497885e17e12019f2128 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Thu, 15 Dec 2022 15:33:03 -0500 Subject: [PATCH 03/28] feed data flow --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 444ff302..882f30c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ org-xerial = "3.8.10.2" io-insert-koin = "3.2.0" com-russhwolf = "1.0.0-RC" kotlinx-serialization = "1.4.0" -store = "0.0.1" +store = "5.0.0-alpha02" io-github-aakira = "2.6.1" [libraries] From 4c0bbe586ff0bc2a56acd7c301d14344d0012ba6 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Sun, 18 Dec 2022 13:47:09 -0500 Subject: [PATCH 04/28] pr feedback and more data modeling --- .../androiddev/common/network/MastodonApi.kt | 1 + .../common/network/di/NetworkModule.kt | 8 - .../common/network/model/Application.kt | 1 - .../androiddev/common/network/model/Status.kt | 4 +- .../androiddev/common/timeline/Timeline.sq | 19 +- data/repository/build.gradle.kts | 8 +- .../common/repository/di/RepositoryModule.kt | 13 -- .../timeline/HomeTimelineRepository.kt | 31 ++-- .../repository/timeline/TimelineRepoModule.kt | 167 ++++++++++-------- domain/authentication/build.gradle.kts | 4 +- domain/timeline/build.gradle.kts | 7 +- .../domain/timeline/HomeTimelineRepository.kt | 13 ++ .../timeline/model/{Status.kt => StatusUI.kt} | 20 ++- gradle/libs.versions.toml | 3 +- settings.gradle.kts | 1 + ui/signed-in/build.gradle.kts | 4 +- .../composables/SignedInRootContent.kt | 7 +- .../selectserver/SelectServerContent.kt | 2 +- ui/timeline/build.gradle.kts | 2 - .../androiddev/timeline/TimelineContent.kt | 38 ++-- .../social/androiddev/timeline/TootContent.kt | 10 +- .../navigation/DefaultTimelineComponent.kt | 41 +++++ .../timeline/navigation/TimelineComponent.kt | 73 +------- .../timeline/navigation/TimelineViewModel.kt | 41 +++++ 24 files changed, 287 insertions(+), 231 deletions(-) create mode 100644 domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt rename domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/{Status.kt => StatusUI.kt} (68%) create mode 100644 ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt create mode 100644 ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApi.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApi.kt index 647721d3..837d92b5 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApi.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApi.kt @@ -81,6 +81,7 @@ interface MastodonApi { /** * Fetch home feed for a particular user + * @see https://docs.joinmastodon.org/methods/timelines/#home * @param accessToken representing the user * @return a list of [Status] */ diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/di/NetworkModule.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/di/NetworkModule.kt index 30f67194..a5d3fef4 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/di/NetworkModule.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/di/NetworkModule.kt @@ -52,14 +52,6 @@ val networkModule: Module = module { } ) } -// install(Auth) { -// bearer { -// loadTokens { -// // Load tokens from a local storage and return them as the 'BearerTokens' instance -// BearerTokens("abc123", "xyz111") -// } -// } -// } defaultRequest { url { protocol = URLProtocol.HTTPS diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt index 7d495f1c..9d9e9a39 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt @@ -17,7 +17,6 @@ import kotlinx.serialization.Serializable */ @Serializable data class Application( - val id: String?=null, val name: String, @SerialName("vapid_key") val vapidKey: String?=null, diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt index 15f782ec..10b5e6dd 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt @@ -21,12 +21,12 @@ data class Status( @SerialName("id") val id: String, @SerialName("uri") val uri: String, @SerialName("created_at") val createdAt: String, - @SerialName("account") val account: Account, + @SerialName("account") val account: Account? = null, @SerialName("content") val content: String, @SerialName("visibility") val visibility: Privacy, @SerialName("sensitive") val sensitive: Boolean, @SerialName("spoiler_text") val spoilerText: String, - @SerialName("media_attachments") val mediaAttachments: List, + @SerialName("media_attachments") val mediaAttachments: List?, @SerialName("application") val application: Application?=null, // rendering attributes diff --git a/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq b/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq index 3cd2daae..9cd8f96d 100644 --- a/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq +++ b/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq @@ -1,12 +1,25 @@ CREATE TABLE TimelineItem ( - statusId Text NOT NULL PRIMARY KEY, type TEXT NOT NULL, - createdAt TEXT NOT NULL + remoteId Text NOT NULL PRIMARY KEY, + uri Text NOT NULL, + createdAt Text NOT NULL, + content Text NOT NULL, + accountId Text, + visibility Text NOT NULL, + sensitive INTEGER AS Boolean DEFAULT 0, + spoilerText Text NOT NULL, + avatarUrl Text NOT NULL, + accountAddress Text NOT NULL, + applicationName Text NOT NULL, + userName Text NOT NULL, + repliesCount INTEGER, + favouritesCount INTEGER, + reblogsCount INTEGER ); insertFeedItem: INSERT OR REPLACE INTO TimelineItem -VALUES ?; +VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); selectHomeItems: SELECT * FROM TimelineItem diff --git a/data/repository/build.gradle.kts b/data/repository/build.gradle.kts index 4f0fd8cf..a044203a 100644 --- a/data/repository/build.gradle.kts +++ b/data/repository/build.gradle.kts @@ -46,10 +46,12 @@ kotlin { implementation(projects.data.network) implementation(projects.data.persistence) implementation(projects.domain.authentication) + implementation(projects.domain.timeline) implementation(libs.io.insert.koin.core) implementation(libs.kotlinx.coroutines.core) + //temp until we map to UI models api(libs.store) - implementation ("com.squareup.sqldelight:coroutines-extensions:1.5.4") + implementation(libs.com.squareup.sqldelight.coroutines.extensions) } } @@ -58,7 +60,7 @@ kotlin { getByName("androidMain") { dependsOn(commonMain) dependencies { - implementation(libs.store) + api ("org.jetbrains.kotlinx:atomicfu:0.18.5") } } @@ -66,7 +68,6 @@ kotlin { // desktop getByName("desktopMain") { dependencies { - implementation(libs.store) } } @@ -83,7 +84,6 @@ kotlin { // iosSimulatorArm64Main.dependsOn(this) dependencies { -// implementation(libs.store) } } diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt index 0ee51a2a..87bb8826 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt @@ -9,23 +9,10 @@ */ package social.androiddev.common.repository.di -import com.squareup.sqldelight.runtime.coroutines.asFlow -import com.squareup.sqldelight.runtime.coroutines.mapToList import kotlinx.coroutines.Dispatchers import org.koin.core.module.Module import org.koin.dsl.module -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.Market -import org.mobilenativefoundation.store.store5.NetworkFetcher -import org.mobilenativefoundation.store.store5.NetworkUpdater -import org.mobilenativefoundation.store.store5.OnNetworkCompletion -import org.mobilenativefoundation.store.store5.Store -import social.androiddev.common.network.MastodonApi -import social.androiddev.common.network.model.Status -import social.androiddev.common.persistence.localstorage.DodoAuthStorage import social.androiddev.common.repository.AuthenticationRepositoryImpl -import social.androiddev.common.timeline.TimelineDatabase -import social.androiddev.common.timeline.TimelineItem import social.androiddev.domain.authentication.repository.AuthenticationRepository /** diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt index e118a064..13ae8104 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt @@ -2,17 +2,16 @@ package social.androiddev.common.repository.timeline import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged -import org.mobilenativefoundation.store.store5.Market -import org.mobilenativefoundation.store.store5.MarketResponse -import org.mobilenativefoundation.store.store5.ReadRequest -import social.androiddev.common.timeline.TimelineItem +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.StoreRequest +import org.mobilenativefoundation.store.store5.StoreResponse +import social.androiddev.domain.timeline.FeedType +import social.androiddev.domain.timeline.HomeTimelineRepository +import social.androiddev.domain.timeline.model.StatusUI -interface HomeTimelineRepository { - suspend fun read(): Flow>> -} class RealHomeTimelineRepository( - private val market: Market, List> + private val store: Store> ) : HomeTimelineRepository { /** * returns a flow of home feed items from a database @@ -20,16 +19,12 @@ class RealHomeTimelineRepository( * on first return will also call network fetcher to get * latest from network and update local storage with it] */ - - - override suspend fun read(): Flow>> { - return market.read(ReadRequest.of( - FeedType.Home, - emptyList(), - null, - true)) - .distinctUntilChanged() - } + override suspend fun read( + feedType: FeedType, + refresh: Boolean + ): Flow>> { + return store.stream(StoreRequest.cached(key = feedType, refresh = true)) + .distinctUntilChanged() } } diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt index 21532e00..82c3af7c 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt @@ -13,18 +13,19 @@ import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.mapToList import kotlinx.coroutines.flow.map import org.koin.core.module.Module +import org.koin.core.parameter.ParametersHolder import org.koin.dsl.module -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.Market -import org.mobilenativefoundation.store.store5.NetworkFetcher -import org.mobilenativefoundation.store.store5.NetworkUpdater -import org.mobilenativefoundation.store.store5.OnNetworkCompletion -import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.Fetcher +import org.mobilenativefoundation.store.store5.SourceOfTruth +import org.mobilenativefoundation.store.store5.StoreBuilder import social.androiddev.common.network.MastodonApi import social.androiddev.common.network.model.Status import social.androiddev.common.persistence.localstorage.DodoAuthStorage import social.androiddev.common.timeline.TimelineDatabase import social.androiddev.common.timeline.TimelineItem +import social.androiddev.domain.timeline.FeedType +import social.androiddev.domain.timeline.HomeTimelineRepository +import social.androiddev.domain.timeline.model.StatusUI /** * Koin module containing all koin/bean definitions for @@ -34,92 +35,118 @@ val timelineRepoModule: Module = module { factory { RealHomeTimelineRepository(get()) } - factory, List>> { + factory, List>> { it: ParametersHolder -> val database = get() - Store.by( + + SourceOfTruth.of( reader = { key: FeedType -> - when(key){ - is FeedType.Home -> get() + when (key) { + is FeedType.Home -> get() .timelineQueries .selectHomeItems() .asFlow() - .mapToList().map { - it.ifEmpty { return@map null } + .mapToList() + .map { + it.ifEmpty { + return@map null + } + it.map { item -> + StatusUI( + remoteId = item.remoteId, + feedType = key, + createdAt = item.createdAt, + repliesCount = item.repliesCount, + reblogsCount = item.favouritesCount, + favoritesCount = item.favouritesCount, + content = item.content, + sensitive = item.sensitive ?: false, + spoilerText = item.spoilerText, + visibility = item.visibility, + avatarUrl = item.avatarUrl, + accountAddress = item.accountAddress, + userName = item.userName + ) + } } } - }, - writer = { _, input -> - input.forEach(database::tryWriteItem) - true - }, - deleter = { TODO() }, - clearer = { TODO() } - ) - } - factory { - //Todo Add logic for conflict resolution handling for when we start posting toots - Bookkeeper.by( - read = { _: FeedType -> null }, - write = { _, _ -> true }, - delete = { TODO() }, - deleteAll = { TODO() } + writer = { key, input -> + input.forEach { database.tryWriteItem(it, key) } + } ) } - factory { - NetworkFetcher.by( - get = { key: FeedType -> - when(key) { - is FeedType.Home -> { - val authStorage = get() - get() - .getHomeFeed(authStorage.currentDomain!!, authStorage.getAccessToken(authStorage.currentDomain!!)!!) - .getOrThrow() - .map(::timelineItem) - } + factory>> { + Fetcher.of { key: FeedType -> + when (key) { + is FeedType.Home -> { + val authStorage = get() + get() + .getHomeFeed( + authStorage.currentDomain!!, + authStorage.getAccessToken(authStorage.currentDomain!!)!! + ) + .getOrThrow() + .map(::timelineItem) } - }, - post = { key, item -> TODO() }, - converter = { it } - ) + } + } } - factory { - NetworkUpdater.by( - post = { key: FeedType, _: List -> - get() - TODO() - }, - onCompletion = OnNetworkCompletion( - onSuccess = {}, - onFailure = {} - ), - converter = { TODO() } - ) - } - - factory, List>> { - Market.of, List>( - stores = listOf(get()), //TODO MIKE: ADD memory cache - bookkeeper = get(), - fetcher = get(), - updater = get() - ) + val fetcher = get>>() + val sourceOfTruth = get, List>>() + StoreBuilder + .from( + fetcher = fetcher, + sourceOfTruth = sourceOfTruth + ) + .build() } } private fun timelineItem(it: Status) = - TimelineItem(it.id, FeedType.Home.type, it.createdAt) + TimelineItem( + type = FeedType.Home.type, + remoteId = it.id, + uri = it.uri, + createdAt = it.createdAt, + content = it.content, + accountId = it.account?.id, + visibility = it.visibility.name, + sensitive = it.sensitive, + spoilerText = it.spoilerText, + applicationName = it.application?.name ?: "", + repliesCount = it.repliesCount?.toLong(), + reblogsCount = it.reblogsCount?.toLong(), + favouritesCount = it.favouritesCount?.toLong(), + avatarUrl = it.account?.avatar?:"", + accountAddress = it.account?.acct?:"", + userName = it.account?.username?:" " + ) + -fun TimelineDatabase.tryWriteItem(timelineItem: TimelineItem): Boolean = try { - timelineQueries.insertFeedItem(timelineItem) +fun TimelineDatabase.tryWriteItem(it: TimelineItem, type: FeedType): Boolean = try { + timelineQueries.insertFeedItem( + type = type.type, + remoteId = it.remoteId, + uri = it.uri, + createdAt = it.createdAt, + content = it.content, + accountId = it.accountId, + visibility = it.visibility, + sensitive = it.sensitive, + spoilerText = it.spoilerText, + applicationName = it.applicationName, + repliesCount = it.repliesCount, + favouritesCount = it.favouritesCount, + reblogsCount = it.reblogsCount, + avatarUrl = it.avatarUrl, + accountAddress = it.accountAddress, + userName = it.userName + ) true } catch (t: Throwable) { throw RuntimeException(t) } -sealed class FeedType(val type:String){ - object Home: FeedType("HOME") -} \ No newline at end of file diff --git a/domain/authentication/build.gradle.kts b/domain/authentication/build.gradle.kts index 08755970..f0425ed3 100644 --- a/domain/authentication/build.gradle.kts +++ b/domain/authentication/build.gradle.kts @@ -35,13 +35,15 @@ kotlin { android() iosX64() iosArm64() - iosSimulatorArm64() + //temp until Mike can recompile store +// iosSimulatorArm64() sourceSets { // shared val commonMain by getting { dependencies { implementation(libs.io.insert.koin.core) + } } diff --git a/domain/timeline/build.gradle.kts b/domain/timeline/build.gradle.kts index 9d49fb8a..6d7a2fc8 100644 --- a/domain/timeline/build.gradle.kts +++ b/domain/timeline/build.gradle.kts @@ -33,15 +33,18 @@ android { kotlin { jvm("desktop") android() + iosX64() + iosArm64() sourceSets { // shared val commonMain by getting { - dependencies {} + dependencies { + api(libs.store) + implementation(libs.kotlinx.coroutines.core) } } - // android getByName("androidMain") { dependsOn(commonMain) diff --git a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt new file mode 100644 index 00000000..935fe99b --- /dev/null +++ b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt @@ -0,0 +1,13 @@ +package social.androiddev.domain.timeline + +import kotlinx.coroutines.flow.Flow + +import org.mobilenativefoundation.store.store5.StoreResponse +import social.androiddev.domain.timeline.model.StatusUI + +interface HomeTimelineRepository { + suspend fun read(feedType: FeedType, refresh: Boolean = false): Flow>> +} +sealed class FeedType(val type:String){ + object Home: FeedType("HOME") +} \ No newline at end of file diff --git a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/Status.kt b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusUI.kt similarity index 68% rename from domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/Status.kt rename to domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusUI.kt index 9c2938b0..5cc60185 100644 --- a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/Status.kt +++ b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusUI.kt @@ -9,15 +9,21 @@ */ package social.androiddev.domain.timeline.model -data class Status( - val id: String, +import social.androiddev.domain.timeline.FeedType + +data class StatusUI( + val remoteId:String, + val feedType: FeedType, val createdAt: String, - val repliesCount: Int, - val reblogsCount: Int, - val favouritesCount: Int, + val repliesCount: Long?, + val reblogsCount: Long?, + val favoritesCount: Long?, val content: String, - val account: Account, + val account: Account?=null, val sensitive: Boolean = false, val spoilerText: String? = null, - val visibility: Visibility = Visibility.PUBLIC + val visibility: String = "Public", + val avatarUrl:String="", + val accountAddress:String="", + val userName:String ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 882f30c7..c0fb9eda 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ org-xerial = "3.8.10.2" io-insert-koin = "3.2.0" com-russhwolf = "1.0.0-RC" kotlinx-serialization = "1.4.0" -store = "5.0.0-alpha02" +store = "5.0.0-SNAPSHOT" io-github-aakira = "2.6.1" [libraries] @@ -60,6 +60,7 @@ org-jetbrains-kotlin-test-annotations-common = { module = "org.jetbrains.kotlin: com-squareup-sqldelight-android-driver = { module = "com.squareup.sqldelight:android-driver", version.ref = "com-squareup-sqldelight" } com-squareup-sqldelight-native-driver = { module = "com.squareup.sqldelight:native-driver", version.ref = "com-squareup-sqldelight" } com-squareup-sqldelight-sqlite-driver = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "com-squareup-sqldelight" } +com-squareup-sqldelight-coroutines-extensions = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "com-squareup-sqldelight" } org-xerial-sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "org-xerial" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3d213f0c..d779230a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,6 +15,7 @@ dependencyResolutionManagement { mavenCentral() mavenLocal() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") } } diff --git a/ui/signed-in/build.gradle.kts b/ui/signed-in/build.gradle.kts index 241610f4..553ec2da 100644 --- a/ui/signed-in/build.gradle.kts +++ b/ui/signed-in/build.gradle.kts @@ -33,9 +33,6 @@ android { } } } -dependencies { - implementation(project(mapOf("path" to ":data:persistence"))) -} kotlin { jvm("desktop") @@ -51,6 +48,7 @@ kotlin { implementation(compose.material) implementation(projects.data.persistence) implementation(projects.data.repository) + implementation(projects.domain.timeline) implementation(libs.io.insert.koin.core) } } diff --git a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt index b9550c1f..5bed7ffe 100644 --- a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt +++ b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt @@ -19,8 +19,8 @@ import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import kotlinx.coroutines.flow.StateFlow -import org.mobilenativefoundation.store.store5.MarketResponse -import social.androiddev.common.timeline.TimelineItem +import org.mobilenativefoundation.store.store5.StoreResponse +import social.androiddev.domain.timeline.model.StatusUI import social.androiddev.signedin.navigation.SignedInRootComponent import social.androiddev.timeline.TimelineContent @@ -49,7 +49,6 @@ fun SignedInRootContent( ) { createdChild -> when (val child = createdChild.instance) { is SignedInRootComponent.Child.Timeline -> { - TimelineTab(child.component.state) } } @@ -59,7 +58,7 @@ fun SignedInRootContent( @Composable private fun TimelineTab( - state: StateFlow>> + state: StateFlow>> ) { TimelineContent( state=state, diff --git a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerContent.kt b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerContent.kt index e401278c..67849f19 100644 --- a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerContent.kt +++ b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/selectserver/SelectServerContent.kt @@ -52,7 +52,7 @@ fun SelectServerContent( onServerSelected: (String) -> Unit, ) { - var server by rememberSaveable { mutableStateOf("") } + var server by rememberSaveable { mutableStateOf("androiddev.social") } Column( modifier = modifier, verticalArrangement = Arrangement.Center, diff --git a/ui/timeline/build.gradle.kts b/ui/timeline/build.gradle.kts index 6b94c839..88dd30b0 100644 --- a/ui/timeline/build.gradle.kts +++ b/ui/timeline/build.gradle.kts @@ -39,8 +39,6 @@ kotlin { val commonMain by getting { dependencies { implementation(projects.domain.timeline) - implementation(projects.data.persistence) - implementation(projects.data.repository) implementation(projects.ui.common) implementation(compose.runtime) implementation(compose.foundation) diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt index b88d8701..246cd579 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt @@ -22,9 +22,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow -import org.mobilenativefoundation.store.store5.MarketResponse +import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.common.theme.DodoTheme -import social.androiddev.common.timeline.TimelineItem +import social.androiddev.domain.timeline.model.StatusUI import social.androiddev.timeline.navigation.TimelineComponent /** @@ -33,18 +33,24 @@ import social.androiddev.timeline.navigation.TimelineComponent */ @Composable fun TimelineContent( - state: StateFlow>>, + state: StateFlow>>, modifier: Modifier = Modifier, ) { val items = state.collectAsState() - when(val value = items.value){ - is MarketResponse.Success-> { + val feedItems = when(val value = items.value){ + is StoreResponse.Data-> { val feedItems = value.value.map { - dummyFeedItem.copy( - id=it.statusId, - date = it.createdAt - ) + FeedItemState( + id= it.remoteId, + userAvatarUrl = it.avatarUrl, + date = it.createdAt, + username = it.userName, + acctAddress = it.accountAddress, + message = it.content, + images = emptyList(), + videoUrl = null, + ) } TimelineContent( @@ -52,22 +58,10 @@ fun TimelineContent( modifier = modifier, ) } + else ->{ - is MarketResponse.Empty -> { - value - } - is MarketResponse.Failure -> { - val error = value.error - } - is MarketResponse.Loading -> { - value } } - - TimelineContent( - items = listOf(dummyFeedItem), - modifier = modifier, - ) } @Composable diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt index 8add20da..d481af7c 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt @@ -18,6 +18,10 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle import social.androiddev.common.theme.DodoTheme @Composable @@ -45,7 +49,9 @@ fun TootContent( if (message != null) { Text( modifier = Modifier.fillMaxWidth(), - text = message, + text = buildAnnotatedString { + append(message) + }, style = MaterialTheme.typography.caption ) VerticalSpacer() @@ -53,6 +59,8 @@ fun TootContent( } } + + // @Preview @Composable private fun PreviewTootContentLight() { diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt new file mode 100644 index 00000000..2de7a04c --- /dev/null +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt @@ -0,0 +1,41 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ +package social.androiddev.timeline.navigation + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.essenty.instancekeeper.getOrCreate +import kotlinx.coroutines.flow.StateFlow +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.mobilenativefoundation.store.store5.StoreResponse +import social.androiddev.domain.timeline.HomeTimelineRepository +import social.androiddev.domain.timeline.model.StatusUI +import kotlin.coroutines.CoroutineContext + + + +class DefaultTimelineComponent( + mainContext: CoroutineContext, + private val componentContext: ComponentContext, +) : TimelineComponent, KoinComponent, ComponentContext by componentContext { + + private val homeTimelineRepository: HomeTimelineRepository by inject() + + private val viewModel = instanceKeeper.getOrCreate { + TimelineViewModel( + mainContext = mainContext, + homeTimelineRepository + ) + } + + override val state: StateFlow>> = viewModel.state + +} + diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt index c18cfd3d..a65e119b 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt @@ -1,75 +1,12 @@ -/* - * This file is part of Dodo. - * - * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Dodo. If not, see . - */ package social.androiddev.timeline.navigation -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.essenty.instancekeeper.InstanceKeeper -import com.arkivanov.essenty.instancekeeper.getOrCreate -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectIndexed -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.mobilenativefoundation.store.store5.MarketResponse -import social.androiddev.common.repository.timeline.HomeTimelineRepository -import social.androiddev.common.timeline.TimelineItem -import kotlin.coroutines.CoroutineContext +import org.mobilenativefoundation.store.store5.StoreResponse +import social.androiddev.domain.timeline.model.StatusUI /** * The base component describing all business logic needed for the timeline view */ -interface TimelineComponent{ - val state: StateFlow>> -} - -class DefaultTimelineComponent( - mainContext: CoroutineContext, - private val componentContext: ComponentContext, -) : TimelineComponent, KoinComponent, ComponentContext by componentContext { - - private val homeTimelineRepository:HomeTimelineRepository by inject() - - private val viewModel = instanceKeeper.getOrCreate { - TimelineViewModel( - mainContext = mainContext, - homeTimelineRepository - ) - } - - override val state: StateFlow>> = viewModel.state - -} - -class TimelineViewModel( - private val mainContext: CoroutineContext, - private val homeTimelineRepository: HomeTimelineRepository -) : InstanceKeeper.Instance { - private val scope = CoroutineScope(mainContext + SupervisorJob()) - private val _state = MutableStateFlow>>(MarketResponse.Empty) - val state: StateFlow>> = _state.asStateFlow() - init { - scope.launch { - homeTimelineRepository.read().collect{ - _state.value = it - } - - } - } - override fun onDestroy() { - scope.cancel() // Cancel the scope when the instance is destroyed - } - -} +interface TimelineComponent { + val state: StateFlow>> +} \ No newline at end of file diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt new file mode 100644 index 00000000..c302303f --- /dev/null +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt @@ -0,0 +1,41 @@ +package social.androiddev.timeline.navigation + +import com.arkivanov.essenty.instancekeeper.InstanceKeeper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.mobilenativefoundation.store.store5.ResponseOrigin +import org.mobilenativefoundation.store.store5.StoreResponse +import social.androiddev.domain.timeline.FeedType +import social.androiddev.domain.timeline.HomeTimelineRepository +import social.androiddev.domain.timeline.model.StatusUI +import kotlin.coroutines.CoroutineContext + +class TimelineViewModel( + private val mainContext: CoroutineContext, + private val homeTimelineRepository: HomeTimelineRepository +) : InstanceKeeper.Instance { + private val scope = CoroutineScope(mainContext + SupervisorJob()) + private val _state = MutableStateFlow>>(StoreResponse.Loading(ResponseOrigin.SourceOfTruth)) + val state: StateFlow>> = _state.asStateFlow() + init { + scope.launch { + try { + homeTimelineRepository.read(FeedType.Home, refresh = true).collect{ + _state.value = it + } + } catch (e:Exception){ + e.message + } + + } + } + override fun onDestroy() { + scope.cancel() // Cancel the scope when the instance is destroyed + } + +} \ No newline at end of file From 9c5edfb5df303f84b60a92c771f71562f2b90ed0 Mon Sep 17 00:00:00 2001 From: Mike Nakhimovich Date: Thu, 15 Dec 2022 18:40:19 -0500 Subject: [PATCH 05/28] Update data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt Co-authored-by: Omid Ghenatnevi --- .../androiddev/common/network/MastodonApiKtor.kt | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt index bf5a2338..63afc8a3 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt @@ -127,19 +127,11 @@ internal class MastodonApiKtor( } override suspend fun getHomeFeed(domain: String, accessToken: String): Result> { - return try { - val url = "https://$domain/api/v1/timelines/home" - val body = httpClient.get(url) { + return runCatchingIgnoreCancelled<> { + httpClient.get("https://$domain/api/v1/timelines/home") { headers { append(HttpHeaders.Authorization, "Bearer $accessToken") } - }.body>() - Result.success( - body - ) - } catch (exception: SerializationException) { - Result.failure(exception = exception) - } catch (exception: ResponseException) { - Result.failure(exception = exception) - } } + }.body() + } } From 146f20c91c595ecf8cbeaa6ea8380f1282ae61ca Mon Sep 17 00:00:00 2001 From: Mike Nakhimovich Date: Thu, 15 Dec 2022 18:48:26 -0500 Subject: [PATCH 06/28] Update data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt Co-authored-by: Omid Ghenatnevi --- .../common/repository/timeline/HomeTimelineRepository.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt index 13ae8104..8b8e0643 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt @@ -29,4 +29,3 @@ class RealHomeTimelineRepository( } - From 42c9a8957152e5e631e7fe216b4b200683d268d1 Mon Sep 17 00:00:00 2001 From: Mike Nakhimovich Date: Thu, 15 Dec 2022 18:53:21 -0500 Subject: [PATCH 07/28] Update gradle/libs.versions.toml Co-authored-by: Omid Ghenatnevi --- gradle/libs.versions.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c0fb9eda..2f258d22 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,6 @@ com-arkivanov-decompose-extensions-compose-jetbrains = { module = "com.arkivanov com-arkivanov-decompose-extensions-compose-jetpack = { module = "com.arkivanov.decompose:extensions-compose-jetpack", version.ref = "com-arkivanov-decompose" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "com-russhwolf" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -store = {module = "org.mobilenativefoundation.store:store5", version.ref = "store"} +store = { module = "org.mobilenativefoundation.store:store5", version.ref = "store"} io-github-aakira-napier = { module = "io.github.aakira:napier", version.ref = "io-github-aakira" } - [plugins] From a6bac8e20da7ff81a355878d4e39c7f77fa88951 Mon Sep 17 00:00:00 2001 From: Mike Nakhimovich Date: Thu, 15 Dec 2022 18:53:44 -0500 Subject: [PATCH 08/28] Update ui/root/src/commonMain/kotlin/social/androiddev/root/navigation/DefaultRootComponent.kt Co-authored-by: Omid Ghenatnevi --- .../social/androiddev/root/navigation/DefaultRootComponent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/root/src/commonMain/kotlin/social/androiddev/root/navigation/DefaultRootComponent.kt b/ui/root/src/commonMain/kotlin/social/androiddev/root/navigation/DefaultRootComponent.kt index e454d71e..1911a8f2 100644 --- a/ui/root/src/commonMain/kotlin/social/androiddev/root/navigation/DefaultRootComponent.kt +++ b/ui/root/src/commonMain/kotlin/social/androiddev/root/navigation/DefaultRootComponent.kt @@ -68,7 +68,7 @@ class DefaultRootComponent( componentContext: ComponentContext, ) = DefaultSignedInRootComponent( componentContext = componentContext, - mainContext=mainContext + mainContext = mainContext ) private fun createSplashComponent( From 324fcddaa95660840dab2fa1d6ad23d1fd97fd96 Mon Sep 17 00:00:00 2001 From: Mike Nakhimovich Date: Thu, 15 Dec 2022 18:55:09 -0500 Subject: [PATCH 09/28] Update ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt Co-authored-by: Omid Ghenatnevi --- .../kotlin/social/androiddev/timeline/TimelineContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt index 246cd579..80b86463 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt @@ -39,7 +39,7 @@ fun TimelineContent( val items = state.collectAsState() val feedItems = when(val value = items.value){ - is StoreResponse.Data-> { + is StoreResponse.Data -> { val feedItems = value.value.map { FeedItemState( id= it.remoteId, From 1201e83515980161b367a8fff140cbb3f2df3933 Mon Sep 17 00:00:00 2001 From: Mike Nakhimovich Date: Thu, 15 Dec 2022 18:56:09 -0500 Subject: [PATCH 10/28] Update ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt Co-authored-by: Omid Ghenatnevi --- .../kotlin/social/androiddev/timeline/TimelineContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt index 80b86463..e3475dba 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt @@ -42,7 +42,7 @@ fun TimelineContent( is StoreResponse.Data -> { val feedItems = value.value.map { FeedItemState( - id= it.remoteId, + id = it.remoteId, userAvatarUrl = it.avatarUrl, date = it.createdAt, username = it.userName, From 2308c257ecda44c65df445294feca61d0c8a2a1a Mon Sep 17 00:00:00 2001 From: Mike Nakhimovich Date: Thu, 15 Dec 2022 18:56:25 -0500 Subject: [PATCH 11/28] Update ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt Co-authored-by: Omid Ghenatnevi --- .../androiddev/signedin/composables/SignedInRootContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt index 5bed7ffe..2427a3b4 100644 --- a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt +++ b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt @@ -61,7 +61,7 @@ private fun TimelineTab( state: StateFlow>> ) { TimelineContent( - state=state, + state = state, modifier = Modifier.fillMaxSize(), ) } From f5a4c93c6b9429992d99fffffc074eea2345e01c Mon Sep 17 00:00:00 2001 From: Mike Nakhimovich Date: Thu, 15 Dec 2022 18:56:45 -0500 Subject: [PATCH 12/28] Update ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt Co-authored-by: Omid Ghenatnevi --- .../kotlin/social/androiddev/timeline/TimelineContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt index e3475dba..bc0ae766 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt @@ -40,7 +40,7 @@ fun TimelineContent( val feedItems = when(val value = items.value){ is StoreResponse.Data -> { - val feedItems = value.value.map { + val feedItems = value.value.map { FeedItemState( id = it.remoteId, userAvatarUrl = it.avatarUrl, From cb895a595a3a5148b1fa9181bbb367b5952936d7 Mon Sep 17 00:00:00 2001 From: Mike Nakhimovich Date: Thu, 15 Dec 2022 18:56:56 -0500 Subject: [PATCH 13/28] Update data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt Co-authored-by: Omid Ghenatnevi --- .../kotlin/social/androiddev/common/network/MastodonApiKtor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt index 63afc8a3..13abf432 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt @@ -127,7 +127,7 @@ internal class MastodonApiKtor( } override suspend fun getHomeFeed(domain: String, accessToken: String): Result> { - return runCatchingIgnoreCancelled<> { + return runCatchingIgnoreCancelled> { httpClient.get("https://$domain/api/v1/timelines/home") { headers { append(HttpHeaders.Authorization, "Bearer $accessToken") From 2c82f3b16e09dc7ba17130c5559cf0c31a741685 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Sun, 18 Dec 2022 18:37:08 -0500 Subject: [PATCH 14/28] split timelineRepoModule into delegates --- .../common/network/MastodonApiKtor.kt | 3 +- .../androiddev/common/timeline/Timeline.sq | 8 +- .../timeline/HomeTimelineRepository.kt | 6 +- .../repository/timeline/ModelMappers.kt | 45 +++++++ .../repository/timeline/TimelineFetcher.kt | 26 ++++ .../repository/timeline/TimelineRepoModule.kt | 117 ++---------------- .../timeline/TimelineSourceOfTruth.kt | 58 +++++++++ .../domain/timeline/HomeTimelineRepository.kt | 4 +- .../model/{StatusUI.kt => StatusLocal.kt} | 2 +- .../composables/SignedInRootContent.kt | 4 +- .../androiddev/timeline/TimelineContent.kt | 4 +- .../navigation/DefaultTimelineComponent.kt | 4 +- .../timeline/navigation/TimelineComponent.kt | 4 +- .../timeline/navigation/TimelineViewModel.kt | 6 +- 14 files changed, 159 insertions(+), 132 deletions(-) create mode 100644 data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt create mode 100644 data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineFetcher.kt create mode 100644 data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt rename domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/{StatusUI.kt => StatusLocal.kt} (97%) diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt index 13abf432..3c092a1c 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt @@ -128,10 +128,11 @@ internal class MastodonApiKtor( override suspend fun getHomeFeed(domain: String, accessToken: String): Result> { return runCatchingIgnoreCancelled> { - httpClient.get("https://$domain/api/v1/timelines/home") { + httpClient.get("https://$domain/api/v1/timelines/home") { headers { append(HttpHeaders.Authorization, "Bearer $accessToken") } }.body() } + } } diff --git a/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq b/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq index 9cd8f96d..dfa86776 100644 --- a/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq +++ b/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq @@ -1,4 +1,4 @@ -CREATE TABLE TimelineItem ( +CREATE TABLE StatusDB ( type TEXT NOT NULL, remoteId Text NOT NULL PRIMARY KEY, uri Text NOT NULL, @@ -18,13 +18,13 @@ CREATE TABLE TimelineItem ( ); insertFeedItem: -INSERT OR REPLACE INTO TimelineItem +INSERT OR REPLACE INTO StatusDB VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); selectHomeItems: -SELECT * FROM TimelineItem +SELECT * FROM StatusDB WHERE type = "HOME" ORDER BY createdAt; deleteAll: -DELETE FROM TimelineItem; +DELETE FROM StatusDB; diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt index 8b8e0643..445e0371 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt @@ -7,11 +7,11 @@ import org.mobilenativefoundation.store.store5.StoreRequest import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.domain.timeline.FeedType import social.androiddev.domain.timeline.HomeTimelineRepository -import social.androiddev.domain.timeline.model.StatusUI +import social.androiddev.domain.timeline.model.StatusLocal class RealHomeTimelineRepository( - private val store: Store> + private val store: Store> ) : HomeTimelineRepository { /** * returns a flow of home feed items from a database @@ -22,7 +22,7 @@ class RealHomeTimelineRepository( override suspend fun read( feedType: FeedType, refresh: Boolean - ): Flow>> { + ): Flow>> { return store.stream(StoreRequest.cached(key = feedType, refresh = true)) .distinctUntilChanged() } diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt new file mode 100644 index 00000000..527ad98a --- /dev/null +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt @@ -0,0 +1,45 @@ +package social.androiddev.common.repository.timeline + +import social.androiddev.common.network.model.Status +import social.androiddev.common.timeline.StatusDB +import social.androiddev.domain.timeline.FeedType +import social.androiddev.domain.timeline.model.StatusLocal + +fun StatusDB.toLocal( + key: FeedType +) = StatusLocal( + remoteId = remoteId, + feedType = key, + createdAt = createdAt, + repliesCount = repliesCount, + reblogsCount = favouritesCount, + favoritesCount = favouritesCount, + content = content, + sensitive = sensitive ?: false, + spoilerText = spoilerText, + visibility = visibility, + avatarUrl = avatarUrl, + accountAddress = accountAddress, + userName = userName +) + + +fun Status.statusDB() = + StatusDB( + type = FeedType.Home.type, + remoteId = id, + uri = uri, + createdAt = createdAt, + content = content, + accountId = account?.id, + visibility = visibility.name, + sensitive = sensitive, + spoilerText = spoilerText, + applicationName = application?.name ?: "", + repliesCount = repliesCount?.toLong(), + reblogsCount = reblogsCount?.toLong(), + favouritesCount = favouritesCount?.toLong(), + avatarUrl = account?.avatar?:"", + accountAddress = account?.acct?:"", + userName = account?.username?:" " + ) \ No newline at end of file diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineFetcher.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineFetcher.kt new file mode 100644 index 00000000..f97e94a9 --- /dev/null +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineFetcher.kt @@ -0,0 +1,26 @@ +package social.androiddev.common.repository.timeline + +import org.mobilenativefoundation.store.store5.Fetcher +import social.androiddev.common.network.MastodonApi +import social.androiddev.common.persistence.localstorage.DodoAuthStorage +import social.androiddev.common.timeline.StatusDB +import social.androiddev.domain.timeline.FeedType + +/** + * Wrapper for [MastodonApi.getHomeFeed] while also getting an auth token from storage + * and mapping result to list of [StatusDB] + */ + +fun MastodonApi.timelineFetcher(authStorage: DodoAuthStorage): Fetcher> = + Fetcher.of { key: FeedType -> + when (key) { + is FeedType.Home -> { + getHomeFeed( + authStorage.currentDomain!!, + authStorage.getAccessToken(authStorage.currentDomain!!)!! + ) + .getOrThrow() + .map { it.statusDB() } + } + } + } diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt index 82c3af7c..49e1fa66 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt @@ -9,23 +9,17 @@ */ package social.androiddev.common.repository.timeline -import com.squareup.sqldelight.runtime.coroutines.asFlow -import com.squareup.sqldelight.runtime.coroutines.mapToList -import kotlinx.coroutines.flow.map import org.koin.core.module.Module -import org.koin.core.parameter.ParametersHolder import org.koin.dsl.module import org.mobilenativefoundation.store.store5.Fetcher import org.mobilenativefoundation.store.store5.SourceOfTruth import org.mobilenativefoundation.store.store5.StoreBuilder import social.androiddev.common.network.MastodonApi -import social.androiddev.common.network.model.Status -import social.androiddev.common.persistence.localstorage.DodoAuthStorage +import social.androiddev.common.timeline.StatusDB import social.androiddev.common.timeline.TimelineDatabase -import social.androiddev.common.timeline.TimelineItem import social.androiddev.domain.timeline.FeedType import social.androiddev.domain.timeline.HomeTimelineRepository -import social.androiddev.domain.timeline.model.StatusUI +import social.androiddev.domain.timeline.model.StatusLocal /** * Koin module containing all koin/bean definitions for @@ -35,69 +29,15 @@ val timelineRepoModule: Module = module { factory { RealHomeTimelineRepository(get()) } - factory, List>> { it: ParametersHolder -> - val database = get() + factory { get().asSourceOfTruth() } - SourceOfTruth.of( - reader = { key: FeedType -> - when (key) { - is FeedType.Home -> get() - .timelineQueries - .selectHomeItems() - .asFlow() - .mapToList() - .map { - it.ifEmpty { - return@map null - } - it.map { item -> - StatusUI( - remoteId = item.remoteId, - feedType = key, - createdAt = item.createdAt, - repliesCount = item.repliesCount, - reblogsCount = item.favouritesCount, - favoritesCount = item.favouritesCount, - content = item.content, - sensitive = item.sensitive ?: false, - spoilerText = item.spoilerText, - visibility = item.visibility, - avatarUrl = item.avatarUrl, - accountAddress = item.accountAddress, - userName = item.userName - ) - } - } - } - }, - writer = { key, input -> - input.forEach { database.tryWriteItem(it, key) } - } - ) - } + factory { get().timelineFetcher(authStorage = get()) } - factory>> { - Fetcher.of { key: FeedType -> - when (key) { - is FeedType.Home -> { - val authStorage = get() - get() - .getHomeFeed( - authStorage.currentDomain!!, - authStorage.getAccessToken(authStorage.currentDomain!!)!! - ) - .getOrThrow() - .map(::timelineItem) - } - } - } - } factory { - val fetcher = get>>() - val sourceOfTruth = get, List>>() - StoreBuilder - .from( + val fetcher = get>>() + val sourceOfTruth = get, List>>() + StoreBuilder.from( fetcher = fetcher, sourceOfTruth = sourceOfTruth ) @@ -105,48 +45,5 @@ val timelineRepoModule: Module = module { } } -private fun timelineItem(it: Status) = - TimelineItem( - type = FeedType.Home.type, - remoteId = it.id, - uri = it.uri, - createdAt = it.createdAt, - content = it.content, - accountId = it.account?.id, - visibility = it.visibility.name, - sensitive = it.sensitive, - spoilerText = it.spoilerText, - applicationName = it.application?.name ?: "", - repliesCount = it.repliesCount?.toLong(), - reblogsCount = it.reblogsCount?.toLong(), - favouritesCount = it.favouritesCount?.toLong(), - avatarUrl = it.account?.avatar?:"", - accountAddress = it.account?.acct?:"", - userName = it.account?.username?:" " - ) -fun TimelineDatabase.tryWriteItem(it: TimelineItem, type: FeedType): Boolean = try { - timelineQueries.insertFeedItem( - type = type.type, - remoteId = it.remoteId, - uri = it.uri, - createdAt = it.createdAt, - content = it.content, - accountId = it.accountId, - visibility = it.visibility, - sensitive = it.sensitive, - spoilerText = it.spoilerText, - applicationName = it.applicationName, - repliesCount = it.repliesCount, - favouritesCount = it.favouritesCount, - reblogsCount = it.reblogsCount, - avatarUrl = it.avatarUrl, - accountAddress = it.accountAddress, - userName = it.userName - ) - true -} catch (t: Throwable) { - throw RuntimeException(t) -} - diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt new file mode 100644 index 00000000..e226df7d --- /dev/null +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt @@ -0,0 +1,58 @@ +package social.androiddev.common.repository.timeline + +import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList +import kotlinx.coroutines.flow.map +import org.mobilenativefoundation.store.store5.SourceOfTruth +import social.androiddev.common.timeline.StatusDB +import social.androiddev.common.timeline.TimelineDatabase +import social.androiddev.common.timeline.TimelineQueries +import social.androiddev.domain.timeline.FeedType +import social.androiddev.domain.timeline.model.StatusLocal + +fun TimelineDatabase.asSourceOfTruth(): SourceOfTruth, List> = + SourceOfTruth.of( + reader = reader(), + writer = { key, input -> + input.forEach { item -> tryWriteItem(item, key) } + } + ) + +private fun TimelineDatabase.reader() = { key: FeedType -> + when (key) { + is FeedType.Home -> + timelineQueries.homeItemsAsLocal(key) + } +} + +private fun TimelineQueries.homeItemsAsLocal(key: FeedType) = selectHomeItems() + .asFlow() + .mapToList() + .map { + it.ifEmpty { return@map null } //treat empty list as no result otherwise + it.map { item -> item.toLocal(key) } + } + +fun TimelineDatabase.tryWriteItem(it: StatusDB, type: FeedType): Boolean = try { + timelineQueries.insertFeedItem( + type = type.type, + remoteId = it.remoteId, + uri = it.uri, + createdAt = it.createdAt, + content = it.content, + accountId = it.accountId, + visibility = it.visibility, + sensitive = it.sensitive, + spoilerText = it.spoilerText, + applicationName = it.applicationName, + repliesCount = it.repliesCount, + favouritesCount = it.favouritesCount, + reblogsCount = it.reblogsCount, + avatarUrl = it.avatarUrl, + accountAddress = it.accountAddress, + userName = it.userName + ) + true +} catch (t: Throwable) { + throw RuntimeException(t) +} diff --git a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt index 935fe99b..df55a497 100644 --- a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt +++ b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt @@ -3,10 +3,10 @@ package social.androiddev.domain.timeline import kotlinx.coroutines.flow.Flow import org.mobilenativefoundation.store.store5.StoreResponse -import social.androiddev.domain.timeline.model.StatusUI +import social.androiddev.domain.timeline.model.StatusLocal interface HomeTimelineRepository { - suspend fun read(feedType: FeedType, refresh: Boolean = false): Flow>> + suspend fun read(feedType: FeedType, refresh: Boolean = false): Flow>> } sealed class FeedType(val type:String){ object Home: FeedType("HOME") diff --git a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusUI.kt b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt similarity index 97% rename from domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusUI.kt rename to domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt index 5cc60185..bad21132 100644 --- a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusUI.kt +++ b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt @@ -11,7 +11,7 @@ package social.androiddev.domain.timeline.model import social.androiddev.domain.timeline.FeedType -data class StatusUI( +data class StatusLocal( val remoteId:String, val feedType: FeedType, val createdAt: String, diff --git a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt index 2427a3b4..eebbdcfc 100644 --- a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt +++ b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt @@ -20,7 +20,7 @@ import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import kotlinx.coroutines.flow.StateFlow import org.mobilenativefoundation.store.store5.StoreResponse -import social.androiddev.domain.timeline.model.StatusUI +import social.androiddev.domain.timeline.model.StatusLocal import social.androiddev.signedin.navigation.SignedInRootComponent import social.androiddev.timeline.TimelineContent @@ -58,7 +58,7 @@ fun SignedInRootContent( @Composable private fun TimelineTab( - state: StateFlow>> + state: StateFlow>> ) { TimelineContent( state = state, diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt index bc0ae766..a1e6b206 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.common.theme.DodoTheme -import social.androiddev.domain.timeline.model.StatusUI +import social.androiddev.domain.timeline.model.StatusLocal import social.androiddev.timeline.navigation.TimelineComponent /** @@ -33,7 +33,7 @@ import social.androiddev.timeline.navigation.TimelineComponent */ @Composable fun TimelineContent( - state: StateFlow>>, + state: StateFlow>>, modifier: Modifier = Modifier, ) { val items = state.collectAsState() diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt index 2de7a04c..9e7aea9a 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt @@ -16,7 +16,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.domain.timeline.HomeTimelineRepository -import social.androiddev.domain.timeline.model.StatusUI +import social.androiddev.domain.timeline.model.StatusLocal import kotlin.coroutines.CoroutineContext @@ -35,7 +35,7 @@ class DefaultTimelineComponent( ) } - override val state: StateFlow>> = viewModel.state + override val state: StateFlow>> = viewModel.state } diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt index a65e119b..921e47dd 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt @@ -2,11 +2,11 @@ package social.androiddev.timeline.navigation import kotlinx.coroutines.flow.StateFlow import org.mobilenativefoundation.store.store5.StoreResponse -import social.androiddev.domain.timeline.model.StatusUI +import social.androiddev.domain.timeline.model.StatusLocal /** * The base component describing all business logic needed for the timeline view */ interface TimelineComponent { - val state: StateFlow>> + val state: StateFlow>> } \ No newline at end of file diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt index c302303f..71efd9b0 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt @@ -12,7 +12,7 @@ import org.mobilenativefoundation.store.store5.ResponseOrigin import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.domain.timeline.FeedType import social.androiddev.domain.timeline.HomeTimelineRepository -import social.androiddev.domain.timeline.model.StatusUI +import social.androiddev.domain.timeline.model.StatusLocal import kotlin.coroutines.CoroutineContext class TimelineViewModel( @@ -20,8 +20,8 @@ class TimelineViewModel( private val homeTimelineRepository: HomeTimelineRepository ) : InstanceKeeper.Instance { private val scope = CoroutineScope(mainContext + SupervisorJob()) - private val _state = MutableStateFlow>>(StoreResponse.Loading(ResponseOrigin.SourceOfTruth)) - val state: StateFlow>> = _state.asStateFlow() + private val _state = MutableStateFlow>>(StoreResponse.Loading(ResponseOrigin.SourceOfTruth)) + val state: StateFlow>> = _state.asStateFlow() init { scope.launch { try { From 36ce08a3a4875bd16614bb12f1e75e55ba1c575a Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Sun, 18 Dec 2022 19:11:11 -0500 Subject: [PATCH 15/28] pr comments --- .../common/network/MastodonApiKtor.kt | 8 +++- .../androiddev/common/timeline/Timeline.sq | 2 +- .../repository/timeline/ModelMappers.kt | 6 +-- .../timeline/TimelineSourceOfTruth.kt | 17 +------- .../domain/timeline/model/StatusLocal.kt | 6 +-- .../composables/SignedInRootContent.kt | 3 +- .../androiddev/timeline/TimelineContent.kt | 28 ++++--------- .../navigation/DefaultTimelineComponent.kt | 8 ++-- .../timeline/navigation/TimelineComponent.kt | 4 +- .../timeline/navigation/TimelineViewModel.kt | 40 ++++++++++++++----- 10 files changed, 60 insertions(+), 62 deletions(-) diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt index 3c092a1c..81c53387 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt @@ -127,8 +127,12 @@ internal class MastodonApiKtor( } override suspend fun getHomeFeed(domain: String, accessToken: String): Result> { - return runCatchingIgnoreCancelled> { - httpClient.get("https://$domain/api/v1/timelines/home") { + return runCatchingIgnoreCancelled { + httpClient.get{ + url { + host = domain + path("/api/v1/timelines/home") + } headers { append(HttpHeaders.Authorization, "Bearer $accessToken") } diff --git a/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq b/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq index dfa86776..76071528 100644 --- a/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq +++ b/data/persistence/src/commonMain/sqldelightTimeline/social/androiddev/common/timeline/Timeline.sq @@ -19,7 +19,7 @@ CREATE TABLE StatusDB ( insertFeedItem: INSERT OR REPLACE INTO StatusDB -VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); +VALUES ?; selectHomeItems: SELECT * FROM StatusDB diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt index 527ad98a..3eaa7967 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt @@ -11,9 +11,9 @@ fun StatusDB.toLocal( remoteId = remoteId, feedType = key, createdAt = createdAt, - repliesCount = repliesCount, - reblogsCount = favouritesCount, - favoritesCount = favouritesCount, + repliesCount = repliesCount?:0, + reblogsCount = favouritesCount?:0, + favoritesCount = favouritesCount?:0, content = content, sensitive = sensitive ?: false, spoilerText = spoilerText, diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt index e226df7d..88cb2b9d 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt @@ -35,22 +35,7 @@ private fun TimelineQueries.homeItemsAsLocal(key: FeedType) = selectHomeItems() fun TimelineDatabase.tryWriteItem(it: StatusDB, type: FeedType): Boolean = try { timelineQueries.insertFeedItem( - type = type.type, - remoteId = it.remoteId, - uri = it.uri, - createdAt = it.createdAt, - content = it.content, - accountId = it.accountId, - visibility = it.visibility, - sensitive = it.sensitive, - spoilerText = it.spoilerText, - applicationName = it.applicationName, - repliesCount = it.repliesCount, - favouritesCount = it.favouritesCount, - reblogsCount = it.reblogsCount, - avatarUrl = it.avatarUrl, - accountAddress = it.accountAddress, - userName = it.userName + it.copy(type = type.type) ) true } catch (t: Throwable) { diff --git a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt index bad21132..292f44d5 100644 --- a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt +++ b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt @@ -15,9 +15,9 @@ data class StatusLocal( val remoteId:String, val feedType: FeedType, val createdAt: String, - val repliesCount: Long?, - val reblogsCount: Long?, - val favoritesCount: Long?, + val repliesCount: Long=0, + val reblogsCount: Long=0, + val favoritesCount: Long=0, val content: String, val account: Account?=null, val sensitive: Boolean = false, diff --git a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt index eebbdcfc..4f48e424 100644 --- a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt +++ b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.StateFlow import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.domain.timeline.model.StatusLocal import social.androiddev.signedin.navigation.SignedInRootComponent +import social.androiddev.timeline.FeedItemState import social.androiddev.timeline.TimelineContent /** @@ -58,7 +59,7 @@ fun SignedInRootContent( @Composable private fun TimelineTab( - state: StateFlow>> + state: StateFlow>> ) { TimelineContent( state = state, diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt index a1e6b206..8dc636b2 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -33,33 +34,18 @@ import social.androiddev.timeline.navigation.TimelineComponent */ @Composable fun TimelineContent( - state: StateFlow>>, + state: StateFlow>>, modifier: Modifier = Modifier, ) { - val items = state.collectAsState() - - val feedItems = when(val value = items.value){ - is StoreResponse.Data -> { - val feedItems = value.value.map { - FeedItemState( - id = it.remoteId, - userAvatarUrl = it.avatarUrl, - date = it.createdAt, - username = it.userName, - acctAddress = it.accountAddress, - message = it.content, - images = emptyList(), - videoUrl = null, - ) - } - + when (val items = state.collectAsState().value) { + is StoreResponse.Data -> { TimelineContent( - items = feedItems, + items = items.value, modifier = modifier, ) } - else ->{ - + else -> { + //handle error/loading } } } diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt index 9e7aea9a..1ba97c97 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt @@ -15,8 +15,9 @@ import kotlinx.coroutines.flow.StateFlow import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.mobilenativefoundation.store.store5.StoreResponse +import social.androiddev.domain.timeline.FeedType import social.androiddev.domain.timeline.HomeTimelineRepository -import social.androiddev.domain.timeline.model.StatusLocal +import social.androiddev.timeline.FeedItemState import kotlin.coroutines.CoroutineContext @@ -31,11 +32,12 @@ class DefaultTimelineComponent( private val viewModel = instanceKeeper.getOrCreate { TimelineViewModel( mainContext = mainContext, - homeTimelineRepository + homeTimelineRepository, + FeedType.Home ) } - override val state: StateFlow>> = viewModel.state + override val state: StateFlow>> = viewModel.state } diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt index 921e47dd..12798d18 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt @@ -2,11 +2,11 @@ package social.androiddev.timeline.navigation import kotlinx.coroutines.flow.StateFlow import org.mobilenativefoundation.store.store5.StoreResponse -import social.androiddev.domain.timeline.model.StatusLocal +import social.androiddev.timeline.FeedItemState /** * The base component describing all business logic needed for the timeline view */ interface TimelineComponent { - val state: StateFlow>> + val state: StateFlow>> } \ No newline at end of file diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt index 71efd9b0..656d01eb 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt @@ -13,27 +13,47 @@ import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.domain.timeline.FeedType import social.androiddev.domain.timeline.HomeTimelineRepository import social.androiddev.domain.timeline.model.StatusLocal +import social.androiddev.timeline.FeedItemState import kotlin.coroutines.CoroutineContext class TimelineViewModel( private val mainContext: CoroutineContext, - private val homeTimelineRepository: HomeTimelineRepository + private val homeTimelineRepository: HomeTimelineRepository, + private val feedType: FeedType ) : InstanceKeeper.Instance { private val scope = CoroutineScope(mainContext + SupervisorJob()) - private val _state = MutableStateFlow>>(StoreResponse.Loading(ResponseOrigin.SourceOfTruth)) - val state: StateFlow>> = _state.asStateFlow() + private val _state = + MutableStateFlow>>(StoreResponse.Loading(ResponseOrigin.SourceOfTruth)) + val state: StateFlow>> = _state.asStateFlow() + init { scope.launch { - try { - homeTimelineRepository.read(FeedType.Home, refresh = true).collect{ - _state.value = it - } - } catch (e:Exception){ - e.message - } + homeTimelineRepository.read(refresh = true).collect { + when (val response: StoreResponse> = it) { + is StoreResponse.Data -> { + val result = StoreResponse.Data(response.value.map { + FeedItemState( + id = it.remoteId, + userAvatarUrl = it.avatarUrl, + date = it.createdAt, + username = it.userName, + acctAddress = it.accountAddress, + message = it.content, + images = emptyList(), + videoUrl = null, + ) + }, response.origin) + _state.value = result + } + else -> { + //TODO display error/loading + } + } + } } } + override fun onDestroy() { scope.cancel() // Cancel the scope when the instance is destroyed } From ee77e1b3e3c37fc7d2b339f5ae476880c6c6b9cc Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Mon, 19 Dec 2022 17:14:01 -0500 Subject: [PATCH 16/28] start of tests --- .../androiddev/common/network/model/Status.kt | 2 +- .../common/network/MastodonApiTests.kt | 19 +- .../common/network/fixtures/TestFixtures.kt | 2403 +++++++++++++++++ .../timeline/TimelineFetcherKtTest.kt | 41 + .../timeline/fixtures/TestFixtures.kt | 81 + .../androiddev/timeline/TimelineContent.kt | 3 +- 6 files changed, 2545 insertions(+), 4 deletions(-) create mode 100644 data/network/src/commonTest/kotlin/social/androiddev/common/network/fixtures/TestFixtures.kt create mode 100644 data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/TimelineFetcherKtTest.kt create mode 100644 data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt index 10b5e6dd..90a207db 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt @@ -26,7 +26,7 @@ data class Status( @SerialName("visibility") val visibility: Privacy, @SerialName("sensitive") val sensitive: Boolean, @SerialName("spoiler_text") val spoilerText: String, - @SerialName("media_attachments") val mediaAttachments: List?, + @SerialName("media_attachments") val mediaAttachments: List? = emptyList(), @SerialName("application") val application: Application?=null, // rendering attributes diff --git a/data/network/src/commonTest/kotlin/social/androiddev/common/network/MastodonApiTests.kt b/data/network/src/commonTest/kotlin/social/androiddev/common/network/MastodonApiTests.kt index 9733b0d2..af63a3df 100644 --- a/data/network/src/commonTest/kotlin/social/androiddev/common/network/MastodonApiTests.kt +++ b/data/network/src/commonTest/kotlin/social/androiddev/common/network/MastodonApiTests.kt @@ -22,6 +22,7 @@ import io.ktor.utils.io.ByteReadChannel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json +import social.androiddev.common.network.fixtures.homeFeed import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -29,7 +30,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) -class MastodonApiTests { + class MastodonApiTests { @Test fun `instance list should be parsed correctly`() = runTest { @@ -214,6 +215,22 @@ class MastodonApiTests { // then assertTrue(actual = result.isSuccess) } + //TODO MIKE: Harden tests to validate inputs + @Test + fun `view home feed should succeed`() = runTest { + val mastodonApi = MastodonApiKtor( + httpClient = createMockClient( + statusCode = HttpStatusCode.OK, content = ByteReadChannel(text = homeFeed) + ) + ) + + // when + val result = mastodonApi.getHomeFeed("","") + + // then + assertTrue(actual = result.isSuccess) + assertTrue { result.getOrThrow().isNotEmpty() } + } private fun createMockClient( statusCode: HttpStatusCode = HttpStatusCode.OK, diff --git a/data/network/src/commonTest/kotlin/social/androiddev/common/network/fixtures/TestFixtures.kt b/data/network/src/commonTest/kotlin/social/androiddev/common/network/fixtures/TestFixtures.kt new file mode 100644 index 00000000..984f05a2 --- /dev/null +++ b/data/network/src/commonTest/kotlin/social/androiddev/common/network/fixtures/TestFixtures.kt @@ -0,0 +1,2403 @@ +package social.androiddev.common.network.fixtures + + val homeFeed = """ + [ + { + "id": "109518540195637455", + "created_at": "2022-12-15T16:05:00.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://mastodon.social/users/caseynewton/statuses/109518539992018383", + "url": "https://mastodon.social/@caseynewton/109518539992018383", + "replies_count": 9, + "reblogs_count": 1, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

Is there a good desktop Mastodon client for Mac that will let me 'pin the timeline to the top' so I can watch toots stream down all day like I used to with Twitter?

", + "filtered": [], + "reblog": null, + "account": { + "id": "109305876702749697", + "username": "caseynewton", + "acct": "caseynewton@mastodon.social", + "display_name": "Casey Newton", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2017-04-03T00:00:00.000Z", + "note": "

Email salesman at Platformer and podcast co-host at Hard Fork.

", + "url": "https://mastodon.social/@caseynewton", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/305/876/702/749/697/original/8a29e7bd52e49c59.png", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/305/876/702/749/697/original/8a29e7bd52e49c59.png", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/305/876/702/749/697/original/d84511f154d21b4b.jpg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/305/876/702/749/697/original/d84511f154d21b4b.jpg", + "followers_count": 10268, + "following_count": 144, + "statuses_count": 62, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518455001667429", + "created_at": "2022-12-15T15:43:23.321Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://androiddev.social/users/zachklipp/statuses/109518455001667429/activity", + "url": "https://androiddev.social/users/zachklipp/statuses/109518455001667429/activity", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109515255677164600", + "created_at": "2022-12-15T02:09:45.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://fosstodon.org/users/ids1024/statuses/109515255660791632", + "url": "https://fosstodon.org/@ids1024/109515255660791632", + "replies_count": 8, + "reblogs_count": 76, + "favourites_count": 4, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

Using #vim is easy once you learn a few basic keybindings.

h and l - move left and right
j and k - move down and up
η and λ - move backwards and forwards through time
ξ and κ - translation through additional temporal dimension (if applicable)
ᚻ, ᛄ, ᚳ and ᛚ - moving left, down, up, and right through celestial spheres
𐤄 and 𐤋 - switch deity to pantheon member to left or right
ᛄ - supplicate to chosen deity
ᚳ - challenge chosen deity (dangerous)
:q - exit

", + "filtered": [], + "reblog": null, + "account": { + "id": "109439855539266132", + "username": "ids1024", + "acct": "ids1024@fosstodon.org", + "display_name": "Ian Douglas Scott", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-23T00:00:00.000Z", + "note": "

Compiler of compilers.

", + "url": "https://fosstodon.org/@ids1024", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/439/855/539/266/132/original/93abae9801e99ec9.png", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/439/855/539/266/132/original/93abae9801e99ec9.png", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 18, + "following_count": 101, + "statuses_count": 40, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "Github", + "value": "https://github.com/ids1024", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [ + { + "name": "vim", + "url": "https://androiddev.social/tags/vim" + } + ], + "emojis": [], + "card": null, + "poll": null + }, + "application": null, + "account": { + "id": "109275230741524764", + "username": "zachklipp", + "acct": "zachklipp", + "display_name": "Zach Klipp (he/him)🌻", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-02T00:00:00.000Z", + "note": "

Engineer working on Android & Jetpack Compose @ Google, but opinions are my own. Formerly Square, Amazon.

#StopAsianHate #BLM #ACAB

#Android #AndroidDev #Kotlin #JetpackCompose

", + "url": "https://androiddev.social/@zachklipp", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/275/230/741/524/764/original/0d99f7672672fe66.jpeg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/275/230/741/524/764/original/0d99f7672672fe66.jpeg", + "header": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/275/230/741/524/764/original/80ed812fe9fab016.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/275/230/741/524/764/original/80ed812fe9fab016.jpeg", + "followers_count": 1321, + "following_count": 325, + "statuses_count": 1204, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "Blog", + "value": "https://dev.to/zachklipp", + "verified_at": "2022-11-15T23:41:20.469+00:00" + }, + { + "name": "GitHub", + "value": "https://github.com/zach-klippenstein", + "verified_at": "2022-11-15T23:41:21.051+00:00" + }, + { + "name": "Website", + "value": "http://www.zachklipp.com/", + "verified_at": "2022-11-04T15:31:46.178+00:00" + }, + { + "name": "Location", + "value": "San Francisco, CA", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518429334530487", + "created_at": "2022-12-15T15:36:51.671Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://androiddev.social/users/zachklipp/statuses/109518429334530487/activity", + "url": "https://androiddev.social/users/zachklipp/statuses/109518429334530487/activity", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109518409983578086", + "created_at": "2022-12-15T15:31:44.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://hachyderm.io/users/shanselman/statuses/109518409218018946", + "url": "https://hachyderm.io/@shanselman/109518409218018946", + "replies_count": 3, + "reblogs_count": 12, + "favourites_count": 1, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

US based folks - free Covid tests just started today again. Each address can get FOUR FREE https://www.covid.gov/tests

", + "filtered": [], + "reblog": null, + "account": { + "id": "109378871970418249", + "username": "shanselman", + "acct": "shanselman@hachyderm.io", + "display_name": "Scott Hanselman :verified:👸🏽🐝🌮", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-20T00:00:00.000Z", + "note": "

Code, OSS, STEM, Beyoncé, #T1D, Hanselminutes inclusive tech podcast! MSFT Developer Division Community #DevRel🐹🌮YouTube+TikTok My opinions https://hanselman.com

", + "url": "https://hachyderm.io/@shanselman", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/378/871/970/418/249/original/b4fabccafae3c151.png", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/378/871/970/418/249/original/b4fabccafae3c151.png", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/378/871/970/418/249/original/04d152efd3d470f1.jpg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/378/871/970/418/249/original/04d152efd3d470f1.jpg", + "followers_count": 23818, + "following_count": 1555, + "statuses_count": 806, + "last_status_at": "2022-12-15", + "emojis": [ + { + "shortcode": "verified", + "url": "https://cdn.masto.host/androiddevsocial/cache/custom_emojis/images/000/000/873/original/1b5b55f5f2ed3752.png", + "static_url": "https://cdn.masto.host/androiddevsocial/cache/custom_emojis/images/000/000/873/static/1b5b55f5f2ed3752.png", + "visible_in_picker": true + } + ], + "fields": [ + { + "name": "Website", + "value": "https://hanselman.com", + "verified_at": "2022-12-14T08:17:07.089+00:00" + }, + { + "name": "Podcast", + "value": "https://hanselminutes.com", + "verified_at": "2022-12-14T08:17:07.949+00:00" + }, + { + "name": "GitHub", + "value": "https://github.com/shanselman", + "verified_at": "2022-12-14T08:17:08.573+00:00" + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": { + "url": "https://www.covid.gov/tests", + "title": "COVID.gov/tests - Free at-home COVID-19 tests", + "description": "Every U.S. household is eligible to order 4 free at-home COVID-⁠19 tests.", + "type": "link", + "author_name": "HHS", + "author_url": "", + "provider_name": "COVID.gov", + "provider_url": "", + "html": "", + "width": 400, + "height": 213, + "image": "https://cdn.masto.host/androiddevsocial/cache/preview_cards/images/000/207/585/original/6a473c23a3aff2a0.jpg", + "embed_url": "", + "blurhash": "UhL4NgDi00E1E1M{o#xuM{jZozt7WBkCs:ni" + }, + "poll": null + }, + "application": null, + "account": { + "id": "109275230741524764", + "username": "zachklipp", + "acct": "zachklipp", + "display_name": "Zach Klipp (he/him)🌻", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-02T00:00:00.000Z", + "note": "

Engineer working on Android & Jetpack Compose @ Google, but opinions are my own. Formerly Square, Amazon.

#StopAsianHate #BLM #ACAB

#Android #AndroidDev #Kotlin #JetpackCompose

", + "url": "https://androiddev.social/@zachklipp", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/275/230/741/524/764/original/0d99f7672672fe66.jpeg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/275/230/741/524/764/original/0d99f7672672fe66.jpeg", + "header": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/275/230/741/524/764/original/80ed812fe9fab016.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/275/230/741/524/764/original/80ed812fe9fab016.jpeg", + "followers_count": 1321, + "following_count": 325, + "statuses_count": 1204, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "Blog", + "value": "https://dev.to/zachklipp", + "verified_at": "2022-11-15T23:41:20.469+00:00" + }, + { + "name": "GitHub", + "value": "https://github.com/zach-klippenstein", + "verified_at": "2022-11-15T23:41:21.051+00:00" + }, + { + "name": "Website", + "value": "http://www.zachklipp.com/", + "verified_at": "2022-11-04T15:31:46.178+00:00" + }, + { + "name": "Location", + "value": "San Francisco, CA", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518421979144592", + "created_at": "2022-12-15T15:34:59.437Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://androiddev.social/users/zachklipp/statuses/109518421979144592/activity", + "url": "https://androiddev.social/users/zachklipp/statuses/109518421979144592/activity", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109516134979876793", + "created_at": "2022-12-15T05:53:20.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://hachyderm.io/users/dfeldman/statuses/109516134813975737", + "url": "https://hachyderm.io/@dfeldman/109516134813975737", + "replies_count": 44, + "reblogs_count": 52, + "favourites_count": 1, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

Fun fact: the MP3 file format is as old today as 8-track tapes were when MP3 was invented

", + "filtered": [], + "reblog": null, + "account": { + "id": "109344795589274997", + "username": "dfeldman", + "acct": "dfeldman@hachyderm.io", + "display_name": "Daniel Feldm :verified: n", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-14T00:00:00.000Z", + "note": "

I’m a software engineer at a big tech company. I work on SPIFFE, an open source cloud security tool. I’m mostly here to toot about cloud security, science, AI art, 3D printing, computer trivia, and life in Minneapolis.

He/him

", + "url": "https://hachyderm.io/@dfeldman", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/344/795/589/274/997/original/04a8bff4a9d16951.jpeg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/344/795/589/274/997/original/04a8bff4a9d16951.jpeg", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 1533, + "following_count": 1727, + "statuses_count": 732, + "last_status_at": "2022-12-15", + "emojis": [ + { + "shortcode": "verified", + "url": "https://cdn.masto.host/androiddevsocial/cache/custom_emojis/images/000/000/873/original/1b5b55f5f2ed3752.png", + "static_url": "https://cdn.masto.host/androiddevsocial/cache/custom_emojis/images/000/000/873/static/1b5b55f5f2ed3752.png", + "visible_in_picker": true + } + ], + "fields": [ + { + "name": "Twitter", + "value": "Twitter.com/d_feldman", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + "application": null, + "account": { + "id": "109275230741524764", + "username": "zachklipp", + "acct": "zachklipp", + "display_name": "Zach Klipp (he/him)🌻", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-02T00:00:00.000Z", + "note": "

Engineer working on Android & Jetpack Compose @ Google, but opinions are my own. Formerly Square, Amazon.

#StopAsianHate #BLM #ACAB

#Android #AndroidDev #Kotlin #JetpackCompose

", + "url": "https://androiddev.social/@zachklipp", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/275/230/741/524/764/original/0d99f7672672fe66.jpeg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/275/230/741/524/764/original/0d99f7672672fe66.jpeg", + "header": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/275/230/741/524/764/original/80ed812fe9fab016.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/275/230/741/524/764/original/80ed812fe9fab016.jpeg", + "followers_count": 1321, + "following_count": 325, + "statuses_count": 1204, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "Blog", + "value": "https://dev.to/zachklipp", + "verified_at": "2022-11-15T23:41:20.469+00:00" + }, + { + "name": "GitHub", + "value": "https://github.com/zach-klippenstein", + "verified_at": "2022-11-15T23:41:21.051+00:00" + }, + { + "name": "Website", + "value": "http://www.zachklipp.com/", + "verified_at": "2022-11-04T15:31:46.178+00:00" + }, + { + "name": "Location", + "value": "San Francisco, CA", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518381026146507", + "created_at": "2022-12-15T15:23:05.000Z", + "in_reply_to_id": "109518376787333334", + "in_reply_to_account_id": "109274267286908118", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://toot.thoughtworks.com/users/mfowler/statuses/109518375176392680", + "url": "https://toot.thoughtworks.com/@mfowler/109518375176392680", + "replies_count": 1, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

I say that knowing I'm fortunate to not be one of those who suffer real harm from internet thugs due to that simplistic ideology

", + "filtered": [], + "reblog": null, + "account": { + "id": "109274267286908118", + "username": "mfowler", + "acct": "mfowler@toot.thoughtworks.com", + "display_name": "Martin Fowler", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-04-25T00:00:00.000Z", + "note": "

Author and loudmouth on software development. Works at Thoughtworks. Also hikes, watches theater, and plays modern board games.

", + "url": "https://toot.thoughtworks.com/@mfowler", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/267/286/908/118/original/da3fd245793af66e.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/267/286/908/118/original/da3fd245793af66e.jpg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/267/286/908/118/original/40758799f029b7eb.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/267/286/908/118/original/40758799f029b7eb.jpeg", + "followers_count": 10106, + "following_count": 79, + "statuses_count": 151, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://martinfowler.com", + "verified_at": "2022-12-06T20:47:24.044+00:00" + }, + { + "name": "Pronouns", + "value": "he/him", + "verified_at": null + }, + { + "name": "email", + "value": "martin@martinfowler.com", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518376787333334", + "created_at": "2022-12-15T15:23:04.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://toot.thoughtworks.com/users/mfowler/statuses/109518375128337637", + "url": "https://toot.thoughtworks.com/@mfowler/109518375128337637", + "replies_count": 4, + "reblogs_count": 2, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

I'm glad to note that even after the change of ownership of Twitter, I can still indulge in the amusement of watching a simplistic ideology (free-speech absolutism) collapse under its own weight when faced with reality

", + "filtered": [], + "reblog": null, + "account": { + "id": "109274267286908118", + "username": "mfowler", + "acct": "mfowler@toot.thoughtworks.com", + "display_name": "Martin Fowler", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-04-25T00:00:00.000Z", + "note": "

Author and loudmouth on software development. Works at Thoughtworks. Also hikes, watches theater, and plays modern board games.

", + "url": "https://toot.thoughtworks.com/@mfowler", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/267/286/908/118/original/da3fd245793af66e.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/267/286/908/118/original/da3fd245793af66e.jpg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/267/286/908/118/original/40758799f029b7eb.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/267/286/908/118/original/40758799f029b7eb.jpeg", + "followers_count": 10106, + "following_count": 79, + "statuses_count": 151, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://martinfowler.com", + "verified_at": "2022-12-06T20:47:24.044+00:00" + }, + { + "name": "Pronouns", + "value": "he/him", + "verified_at": null + }, + { + "name": "email", + "value": "martin@martinfowler.com", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518367474303996", + "created_at": "2022-12-15T15:21:07.759Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://androiddev.social/users/sepdroid/statuses/109518367474303996/activity", + "url": "https://androiddev.social/users/sepdroid/statuses/109518367474303996/activity", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109515140011524901", + "created_at": "2022-12-15T01:40:19.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://mastodon.social/users/scalzi/statuses/109515139953245731", + "url": "https://mastodon.social/@scalzi/109515139953245731", + "replies_count": 278, + "reblogs_count": 4, + "favourites_count": 2, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

I have fewer followers on Mastodon than on Twitter by an order of magnitude, but I'm guessing the number of followers here who are bots/trolls is much closer to zero than over there. Hello, real humans!

", + "filtered": [], + "reblog": null, + "account": { + "id": "109275971660663198", + "username": "scalzi", + "acct": "scalzi@mastodon.social", + "display_name": "Scalzi", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2017-11-05T00:00:00.000Z", + "note": "

I enjoy pie.

", + "url": "https://mastodon.social/@scalzi", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/275/971/660/663/198/original/c9a3713c8f2c0df0.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/275/971/660/663/198/original/c9a3713c8f2c0df0.jpg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/275/971/660/663/198/original/65707a3677d4a873.jpg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/275/971/660/663/198/original/65707a3677d4a873.jpg", + "followers_count": 13167, + "following_count": 72, + "statuses_count": 165, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "Verification", + "value": "https://whatever.scalzi.com/2022/11/22/this-is-a-post-to-set-up-verification-on-mastodon-you-can-totally-ignore-it/", + "verified_at": "2022-12-12T19:41:03.318+00:00" + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + "application": null, + "account": { + "id": "109279987650139218", + "username": "sepdroid", + "acct": "sepdroid", + "display_name": "Sepideh", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-03T00:00:00.000Z", + "note": "

Android Dev with an interest in attending local meetings about K-12 education

", + "url": "https://androiddev.social/@sepdroid", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 144, + "following_count": 305, + "statuses_count": 355, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "pronouns", + "value": "she/her", + "verified_at": null + }, + { + "name": "pronounced", + "value": "Sa-pita", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518363900583915", + "created_at": "2022-12-15T15:20:13.230Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://androiddev.social/users/sepdroid/statuses/109518363900583915/activity", + "url": "https://androiddev.social/users/sepdroid/statuses/109518363900583915/activity", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109518295192756833", + "created_at": "2022-12-15T15:02:44.834Z", + "in_reply_to_id": "109518285473387967", + "in_reply_to_account_id": "109384856559579254", + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://androiddev.social/users/sepdroid/statuses/109518295192756833", + "url": "https://androiddev.social/@sepdroid/109518295192756833", + "replies_count": 0, + "reblogs_count": 3, + "favourites_count": 4, + "edited_at": "2022-12-15T15:03:00.982Z", + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

@BenCisco I grew up very close to Louisiana listening to a whole lot of Aaron Neville and zydeco, and I had never heard that Christmas song.

https://www.youtube.com/watch?v=WjuIcvQy8i4

", + "filtered": [], + "reblog": null, + "application": { + "name": "Web", + "website": null + }, + "account": { + "id": "109279987650139218", + "username": "sepdroid", + "acct": "sepdroid", + "display_name": "Sepideh", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-03T00:00:00.000Z", + "note": "

Android Dev with an interest in attending local meetings about K-12 education

", + "url": "https://androiddev.social/@sepdroid", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 144, + "following_count": 305, + "statuses_count": 355, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "pronouns", + "value": "she/her", + "verified_at": null + }, + { + "name": "pronounced", + "value": "Sa-pita", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [ + { + "id": "109384856559579254", + "username": "BenCisco", + "url": "https://mastodon.online/@BenCisco", + "acct": "BenCisco@mastodon.online" + } + ], + "tags": [], + "emojis": [], + "card": { + "url": "https://m.youtube.com/watch?v=WjuIcvQy8i4", + "title": "The Neville Brothers - Everybody Plays The Fool - 10/31/1991 - Municipal Aud. N.O. (Official)", + "description": "", + "type": "video", + "author_name": "Neville Brothers on MV", + "author_url": "https://www.youtube.com/@NevilleBrothersOnMV", + "provider_name": "YouTube", + "provider_url": "https://www.youtube.com/", + "html": "", + "width": 200, + "height": 150, + "image": "https://cdn.masto.host/androiddevsocial/cache/preview_cards/images/000/211/271/original/b47ff33cac3bbe18.jpg", + "embed_url": "", + "blurhash": "U6DtVwReEkEe{m^85f]=9Xn,-A,]}bNZ-E9r" + }, + "poll": null + }, + "application": null, + "account": { + "id": "109279987650139218", + "username": "sepdroid", + "acct": "sepdroid", + "display_name": "Sepideh", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-03T00:00:00.000Z", + "note": "

Android Dev with an interest in attending local meetings about K-12 education

", + "url": "https://androiddev.social/@sepdroid", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 144, + "following_count": 305, + "statuses_count": 355, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "pronouns", + "value": "she/her", + "verified_at": null + }, + { + "name": "pronounced", + "value": "Sa-pita", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518252401798896", + "created_at": "2022-12-15T14:51:51.892Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://androiddev.social/users/alanevans/statuses/109518252401798896", + "url": "https://androiddev.social/@alanevans/109518252401798896", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 5, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

"I'm trying to sleep here! Kiki" #CatsOfMastodon

", + "filtered": [], + "reblog": null, + "application": { + "name": "Web", + "website": null + }, + "account": { + "id": "109277745798323945", + "username": "alanevans", + "acct": "alanevans", + "display_name": "Alan Evans", + "locked": false, + "bot": false, + "discoverable": false, + "group": false, + "created_at": "2022-11-03T00:00:00.000Z", + "note": "

Android Dev. Formerly Twitter (1.0), Signal.

", + "url": "https://androiddev.social/@alanevans", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/277/745/798/323/945/original/da167f8db31778e4.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/277/745/798/323/945/original/da167f8db31778e4.jpg", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 61, + "following_count": 93, + "statuses_count": 96, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [] + }, + "media_attachments": [ + { + "id": "109518241013907255", + "type": "image", + "url": "https://cdn.masto.host/androiddevsocial/media_attachments/files/109/518/241/013/907/255/original/68334b6dd3934e49.jpeg", + "preview_url": "https://cdn.masto.host/androiddevsocial/media_attachments/files/109/518/241/013/907/255/small/68334b6dd3934e49.jpeg", + "remote_url": null, + "preview_remote_url": null, + "text_url": null, + "meta": { + "original": { + "width": 1659, + "height": 1249, + "size": "1659x1249", + "aspect": 1.3282626100880706 + }, + "small": { + "width": 553, + "height": 416, + "size": "553x416", + "aspect": 1.3293269230769231 + }, + "focus": { + "x": -0.13, + "y": 0.41 + } + }, + "description": "Kiki standing on Mickey's bed watching birbs", + "blurhash": "UYG+E#bI%3s:_NoKahkC%KR%NGoJaee:ofWB" + }, + { + "id": "109518241500933159", + "type": "image", + "url": "https://cdn.masto.host/androiddevsocial/media_attachments/files/109/518/241/500/933/159/original/413ca6949b75547c.jpeg", + "preview_url": "https://cdn.masto.host/androiddevsocial/media_attachments/files/109/518/241/500/933/159/small/413ca6949b75547c.jpeg", + "remote_url": null, + "preview_remote_url": null, + "text_url": null, + "meta": { + "original": { + "width": 1659, + "height": 1249, + "size": "1659x1249", + "aspect": 1.3282626100880706 + }, + "small": { + "width": 553, + "height": 416, + "size": "553x416", + "aspect": 1.3293269230769231 + }, + "focus": { + "x": -0.18, + "y": 0.14 + } + }, + "description": "Close up of Mickey's face. He's curled up with one eye open.", + "blurhash": "UQECta~q-p%MDhs;tSt7xvS3aeWA_2%KWnWo" + } + ], + "mentions": [], + "tags": [ + { + "name": "CatsOfMastodon", + "url": "https://androiddev.social/tags/CatsOfMastodon" + } + ], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518240079643348", + "created_at": "2022-12-15T14:48:43.872Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://androiddev.social/users/matt/statuses/109518240079643348/activity", + "url": "https://androiddev.social/users/matt/statuses/109518240079643348/activity", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109517964341845464", + "created_at": "2022-12-15T13:38:36.453Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://androiddev.social/users/amanda/statuses/109517964341845464", + "url": "https://androiddev.social/@amanda/109517964341845464", + "replies_count": 0, + "reblogs_count": 4, + "favourites_count": 3, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

“A Not-so-scary Introduction to DP Mechanisms in Kotlin: Maximum Subarray” is out now🤠 #Kotlin

https://medium.com/@hinchman-amanda/a-not-so-scary-introduction-to-dp-mechanisms-in-kotlin-maximum-subarray-767591f9ceeb

", + "filtered": [], + "reblog": null, + "application": { + "name": "Web", + "website": null + }, + "account": { + "id": "109287686557350102", + "username": "amanda", + "acct": "amanda", + "display_name": "Amanda Hinchman-Dominguez", + "locked": false, + "bot": false, + "discoverable": false, + "group": false, + "created_at": "2022-11-04T00:00:00.000Z", + "note": "

android engineer, Kotlin GDE, humble
@ChicagoKotlin
organizer, co-author of Programming Kotlin w/ Android: http://bit.ly/3G9qfN8, she/hers

", + "url": "https://androiddev.social/@amanda", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/287/686/557/350/102/original/609e7d27dad734e4.png", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/287/686/557/350/102/original/609e7d27dad734e4.png", + "header": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/287/686/557/350/102/original/fe8e21b9b06eabab.png", + "header_static": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/287/686/557/350/102/original/fe8e21b9b06eabab.png", + "followers_count": 176, + "following_count": 116, + "statuses_count": 51, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "#kotlin #android #tech", + "value": "", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [ + { + "name": "kotlin", + "url": "https://androiddev.social/tags/kotlin" + } + ], + "emojis": [], + "card": { + "url": "https://hinchman-amanda.medium.com/a-not-so-scary-introduction-to-dp-mechanisms-in-kotlin-maximum-subarray-767591f9ceeb", + "title": "A Not-so-scary Introduction to DP Mechanisms in Kotlin: Maximum Subarray", + "description": "Dynamic programming (DP) can be an intimidating topic to learn. Give or take, I’ve arranged notes on the topic from previous job changes in the form of a digestible guide for quicker ramp-up. Less…", + "type": "link", + "author_name": "mvndy", + "author_url": "https://hinchman-amanda.medium.com", + "provider_name": "Medium", + "provider_url": "", + "html": "", + "width": 400, + "height": 267, + "image": "https://cdn.masto.host/androiddevsocial/cache/preview_cards/images/000/210/801/original/de83a023c0cedd9c.jpg", + "embed_url": "", + "blurhash": "UCMP~#-@^w9GWNR8M_={-rSx%gJA%f-oD%NO" + }, + "poll": null + }, + "application": null, + "account": { + "id": "109274112091517286", + "username": "matt", + "acct": "matt", + "display_name": "Matt McKenna :androidEyes:", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-02T00:00:00.000Z", + "note": "

he \\ him

#Android @ Square • Public Speaker • Community Enthusiast

I mostly post about #AndroidDev and #Kotlin.

Let's talk about: #Coffee, #Esports, #Podcasts, #Reading, #Fitness.

androiddev.social admin

#BlackLivesMatter #StopAsianHate

", + "url": "https://androiddev.social/@matt", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/274/112/091/517/286/original/ddfbe4fa528b3288.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/274/112/091/517/286/original/ddfbe4fa528b3288.jpg", + "header": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/274/112/091/517/286/original/2d5c8d906cfa288a.png", + "header_static": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/274/112/091/517/286/original/2d5c8d906cfa288a.png", + "followers_count": 1006, + "following_count": 295, + "statuses_count": 313, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [ + { + "shortcode": "androidEyes", + "url": "https://cdn.masto.host/androiddevsocial/custom_emojis/images/000/008/402/original/4d88e0b923ec4775.gif", + "static_url": "https://cdn.masto.host/androiddevsocial/custom_emojis/images/000/008/402/static/4d88e0b923ec4775.png", + "visible_in_picker": true + } + ], + "fields": [ + { + "name": "✍️ blog", + "value": "https://blog.mmckenna.me", + "verified_at": "2022-11-30T15:27:42.651+00:00" + }, + { + "name": "🎙️ live discussions", + "value": "https://t.me/androiddevhang", + "verified_at": null + }, + { + "name": "🎧 podcast", + "value": "https://androiddevdiscussions.substack.com", + "verified_at": null + }, + { + "name": "📸 photos", + "value": "https://unsplash.com/mmckenna", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518224455955101", + "created_at": "2022-12-15T14:44:44.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://mastodon.xyz/users/anthonycr/statuses/109518224409509568", + "url": "https://mastodon.xyz/@anthonycr/109518224409509568", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

thankfully there's a way to customize what syncs

", + "filtered": [], + "reblog": null, + "account": { + "id": "109274342909461515", + "username": "anthonycr", + "acct": "anthonycr@mastodon.xyz", + "display_name": "anthony restaino", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2017-04-04T00:00:00.000Z", + "note": "

android dev @ zillow | ex vimeo | pro socialism | 🥕 food security | 🍳 cooking | 🦆 birding | 📍 brooklyn | he/they

", + "url": "https://mastodon.xyz/@anthonycr", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/342/909/461/515/original/9cb3472081718e2f.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/342/909/461/515/original/9cb3472081718e2f.jpg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/342/909/461/515/original/8b3baed8a39be136.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/342/909/461/515/original/8b3baed8a39be136.jpeg", + "followers_count": 49, + "following_count": 102, + "statuses_count": 156, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518218443663652", + "created_at": "2022-12-15T14:43:12.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://mastodon.xyz/users/anthonycr/statuses/109518218332858107", + "url": "https://mastodon.xyz/@anthonycr/109518218332858107", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

also have found the cross device session syncing pretty horrible for use at the food pantry, which is signed into multiple devices with the same account. filling out the same form simultaneously on separate devices results in your input being overwritten while you input it.

", + "filtered": [], + "reblog": null, + "account": { + "id": "109274342909461515", + "username": "anthonycr", + "acct": "anthonycr@mastodon.xyz", + "display_name": "anthony restaino", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2017-04-04T00:00:00.000Z", + "note": "

android dev @ zillow | ex vimeo | pro socialism | 🥕 food security | 🍳 cooking | 🦆 birding | 📍 brooklyn | he/they

", + "url": "https://mastodon.xyz/@anthonycr", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/342/909/461/515/original/9cb3472081718e2f.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/342/909/461/515/original/9cb3472081718e2f.jpg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/342/909/461/515/original/8b3baed8a39be136.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/342/909/461/515/original/8b3baed8a39be136.jpeg", + "followers_count": 49, + "following_count": 102, + "statuses_count": 156, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518208846173703", + "created_at": "2022-12-15T14:40:46.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://mastodon.xyz/users/anthonycr/statuses/109518208796570539", + "url": "https://mastodon.xyz/@anthonycr/109518208796570539", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 1, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

chrome state syncing across devices is a great feature, but lately it's been pretty inconvenient. It's now syncing full screen state across devices, which has confused me multiple times when watching something full screen on one device while working on another.

", + "filtered": [], + "reblog": null, + "account": { + "id": "109274342909461515", + "username": "anthonycr", + "acct": "anthonycr@mastodon.xyz", + "display_name": "anthony restaino", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2017-04-04T00:00:00.000Z", + "note": "

android dev @ zillow | ex vimeo | pro socialism | 🥕 food security | 🍳 cooking | 🦆 birding | 📍 brooklyn | he/they

", + "url": "https://mastodon.xyz/@anthonycr", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/342/909/461/515/original/9cb3472081718e2f.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/342/909/461/515/original/9cb3472081718e2f.jpg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/342/909/461/515/original/8b3baed8a39be136.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/342/909/461/515/original/8b3baed8a39be136.jpeg", + "followers_count": 49, + "following_count": 102, + "statuses_count": 156, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518108854648598", + "created_at": "2022-12-15T14:15:21.538Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://androiddev.social/users/sepdroid/statuses/109518108854648598/activity", + "url": "https://androiddev.social/users/sepdroid/statuses/109518108854648598/activity", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109490318507587844", + "created_at": "2022-12-10T16:27:54.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://sauropods.win/users/futurebird/statuses/109490318505938606", + "url": "https://sauropods.win/@futurebird/109490318505938606", + "replies_count": 12, + "reblogs_count": 43, + "favourites_count": 1, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

When I was active at the wikipedia there were a group of other writers who would follow my around and mark my articles on women and black people for deletion. They marked my edits CN even when there was a citation. They reworded everything I wrote to minimize the contributions of minorities to history, and hint at theories of racial inferiority. I'd just go to the library and bury them in more citations. It was kind of violent TBH.

", + "filtered": [], + "reblog": null, + "account": { + "id": "109301253200663189", + "username": "futurebird", + "acct": "futurebird@sauropods.win", + "display_name": "myrmepropagandist", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-07T00:00:00.000Z", + "note": "

pro-ant propaganda, building electronics, writing sci-fi teaching mathematics & CS. I live in NYC.

", + "url": "https://sauropods.win/@futurebird", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/301/253/200/663/189/original/6e04b975b62c53cc.png", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/301/253/200/663/189/original/6e04b975b62c53cc.png", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/301/253/200/663/189/original/20174b79c2849673.png", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/301/253/200/663/189/original/20174b79c2849673.png", + "followers_count": 2478, + "following_count": 1171, + "statuses_count": 2529, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "tumblr", + "value": "https://futurebird.tumblr.com/", + "verified_at": "2022-12-15T12:59:28.451+00:00" + }, + { + "name": "twitter", + "value": "https://twitter.com/futurebird", + "verified_at": null + }, + { + "name": "pronouns", + "value": "she/her/lady/ma'am", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + "application": null, + "account": { + "id": "109279987650139218", + "username": "sepdroid", + "acct": "sepdroid", + "display_name": "Sepideh", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-03T00:00:00.000Z", + "note": "

Android Dev with an interest in attending local meetings about K-12 education

", + "url": "https://androiddev.social/@sepdroid", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 144, + "following_count": 305, + "statuses_count": 355, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "pronouns", + "value": "she/her", + "verified_at": null + }, + { + "name": "pronounced", + "value": "Sa-pita", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518085821937371", + "created_at": "2022-12-15T14:09:30.087Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://androiddev.social/users/sepdroid/statuses/109518085821937371/activity", + "url": "https://androiddev.social/users/sepdroid/statuses/109518085821937371/activity", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109514962581691447", + "created_at": "2022-12-15T00:55:13.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://econtwitter.net/users/m_clem/statuses/109514962575202965", + "url": "https://econtwitter.net/@m_clem/109514962575202965", + "replies_count": 0, + "reblogs_count": 38, + "favourites_count": 1, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

The Boston University School of Public Health is withdrawing from Twitter.

The dean’s thoughtful, nuanced explanation raises the question: Why are other institutions still there?

https://www.bu.edu/sph/news/articles/2022/reconsidering-our-engagement-with-twitter/

", + "filtered": [], + "reblog": null, + "account": { + "id": "109320824540850157", + "username": "m_clem", + "acct": "m_clem@econtwitter.net", + "display_name": "Michael Clemens", + "locked": false, + "bot": false, + "discoverable": false, + "group": false, + "created_at": "2022-11-05T00:00:00.000Z", + "note": "

I'm an economist who studies the causes and effects of international migration. Fellow at Center for Global Development, IZA Bonn, and CReAM~UCL. Personal views only.

", + "url": "https://econtwitter.net/@m_clem", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/320/824/540/850/157/original/bbc98d52d7a4951b.png", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/320/824/540/850/157/original/bbc98d52d7a4951b.png", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/320/824/540/850/157/original/e5bccf4a36d097b8.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/320/824/540/850/157/original/e5bccf4a36d097b8.jpeg", + "followers_count": 1665, + "following_count": 737, + "statuses_count": 548, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "Web", + "value": "http://mclem.org", + "verified_at": null + }, + { + "name": "Verification", + "value": "https://ideas.repec.org/e/pcl20.html", + "verified_at": "2022-12-15T04:02:04.295+00:00" + } + ] + }, + "media_attachments": [ + { + "id": "109515697286984687", + "type": "image", + "url": "https://cdn.masto.host/androiddevsocial/cache/media_attachments/files/109/515/697/286/984/687/original/ff690f7bff2fede6.jpeg", + "preview_url": "https://cdn.masto.host/androiddevsocial/cache/media_attachments/files/109/515/697/286/984/687/small/ff690f7bff2fede6.jpeg", + "remote_url": "https://cdn.masto.host/econtwitternet/media_attachments/files/109/514/961/521/135/485/original/816a885271e3335a.jpeg", + "preview_remote_url": null, + "text_url": null, + "meta": { + "original": { + "width": 1280, + "height": 570, + "size": "1280x570", + "aspect": 2.245614035087719 + }, + "small": { + "width": 719, + "height": 320, + "size": "719x320", + "aspect": 2.246875 + } + }, + "description": null, + "blurhash": "UKRfkBj[_3WB-;ayM{kB~qj[NFofRjofayWB" + } + ], + "mentions": [], + "tags": [], + "emojis": [], + "card": { + "url": "https://www.bu.edu/sph/news/articles/2022/reconsidering-our-engagement-with-twitter/", + "title": "Reconsidering our School's Engagement with Twitter", + "description": "", + "type": "link", + "author_name": "", + "author_url": "", + "provider_name": "", + "provider_url": "", + "html": "", + "width": 400, + "height": 241, + "image": "https://cdn.masto.host/androiddevsocial/cache/preview_cards/images/000/206/663/original/c221c2ef4fbafb11.jpg", + "embed_url": "", + "blurhash": "UwI}V9Rjx^R+~qRkkXWC%NWXf+ayo}WCjaay" + }, + "poll": null + }, + "application": null, + "account": { + "id": "109279987650139218", + "username": "sepdroid", + "acct": "sepdroid", + "display_name": "Sepideh", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-03T00:00:00.000Z", + "note": "

Android Dev with an interest in attending local meetings about K-12 education

", + "url": "https://androiddev.social/@sepdroid", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 144, + "following_count": 305, + "statuses_count": 355, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "pronouns", + "value": "she/her", + "verified_at": null + }, + { + "name": "pronounced", + "value": "Sa-pita", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518029301323901", + "created_at": "2022-12-15T13:54:40.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://toot.thoughtworks.com/users/mfowler/statuses/109518027501299025/activity", + "url": null, + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109517740964906428", + "created_at": "2022-12-15T12:41:47.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://mastodon.social/users/kevlin/statuses/109517740925744193", + "url": "https://mastodon.social/@kevlin/109517740925744193", + "replies_count": 6, + "reblogs_count": 5, + "favourites_count": 1, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

I have found myself teaching and encouraging teams to use ADRs more and more in recent years.

I am, however, constantly surprised by the failure modes that people find themselves in, typically through no fault of their own.

", + "filtered": [], + "reblog": null, + "account": { + "id": "109293525135786218", + "username": "kevlin", + "acct": "kevlin@mastodon.social", + "display_name": "Kevlin Henney", + "locked": false, + "bot": false, + "discoverable": false, + "group": false, + "created_at": "2016-11-01T00:00:00.000Z", + "note": "

consultant · father · he/him · human (very) · husband · itinerant · programmer · keynote speaker · technologist · trainer · writer

", + "url": "https://mastodon.social/@kevlin", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/293/525/135/786/218/original/08666369be735602.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/293/525/135/786/218/original/08666369be735602.jpg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/293/525/135/786/218/original/3ca14b8be7718a8b.jpg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/293/525/135/786/218/original/3ca14b8be7718a8b.jpg", + "followers_count": 489, + "following_count": 51, + "statuses_count": 195, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "Location", + "value": "☉+~1au", + "verified_at": null + }, + { + "name": "Blog", + "value": "https://kevlinhenney.medium.com", + "verified_at": null + }, + { + "name": "About", + "value": "https://about.me/kevlin", + "verified_at": "2022-11-20T11:56:07.993+00:00" + }, + { + "name": "Contact", + "value": "http://kevlin.tel", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + "account": { + "id": "109274267286908118", + "username": "mfowler", + "acct": "mfowler@toot.thoughtworks.com", + "display_name": "Martin Fowler", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-04-25T00:00:00.000Z", + "note": "

Author and loudmouth on software development. Works at Thoughtworks. Also hikes, watches theater, and plays modern board games.

", + "url": "https://toot.thoughtworks.com/@mfowler", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/267/286/908/118/original/da3fd245793af66e.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/267/286/908/118/original/da3fd245793af66e.jpg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/267/286/908/118/original/40758799f029b7eb.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/267/286/908/118/original/40758799f029b7eb.jpeg", + "followers_count": 10106, + "following_count": 79, + "statuses_count": 151, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "Website", + "value": "https://martinfowler.com", + "verified_at": "2022-12-06T20:47:24.044+00:00" + }, + { + "name": "Pronouns", + "value": "he/him", + "verified_at": null + }, + { + "name": "email", + "value": "martin@martinfowler.com", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518014665598096", + "created_at": "2022-12-15T13:51:24.329Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "unlisted", + "language": null, + "uri": "https://androiddev.social/users/marcin/statuses/109518014665598096/activity", + "url": "https://androiddev.social/users/marcin/statuses/109518014665598096/activity", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109516507746082589", + "created_at": "2022-12-15T07:28:03.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://botsin.space/users/APoD/statuses/109516507295806068", + "url": "https://botsin.space/@APoD/109516507295806068", + "replies_count": 4, + "reblogs_count": 2, + "favourites_count": 1, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

Full Moon, Full Mars

Image Credit & Copyright: Tomas Slovinsky

https://apod.nasa.gov/apod/ap221215.html #APOD

", + "filtered": [], + "reblog": null, + "account": { + "id": "109274516021668243", + "username": "APoD", + "acct": "APoD@botsin.space", + "display_name": "Astronomy Picture of the Day", + "locked": false, + "bot": true, + "discoverable": true, + "group": false, + "created_at": "2018-01-12T00:00:00.000Z", + "note": "

Discover the cosmos! A different image of our fascinating universe every day.

🌌 https://apod.nasa.gov/

(Automated mirror, supervised by @codl, who is not an astronomer)

", + "url": "https://botsin.space/@APoD", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/516/021/668/243/original/971cc825721fd263.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/274/516/021/668/243/original/971cc825721fd263.jpg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/516/021/668/243/original/545129759d5dbf16.jpg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/274/516/021/668/243/original/545129759d5dbf16.jpg", + "followers_count": 53512, + "following_count": 1, + "statuses_count": 1809, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "Banner", + "value": "https://apod.nasa.gov/apod/ap131231.html", + "verified_at": null + }, + { + "name": "Avatar", + "value": "https://apod.nasa.gov/apod/ap220206.html", + "verified_at": null + } + ] + }, + "media_attachments": [ + { + "id": "109516507668319090", + "type": "image", + "url": "https://cdn.masto.host/androiddevsocial/cache/media_attachments/files/109/516/507/668/319/090/original/b4a088d70fddd91c.jpg", + "preview_url": "https://cdn.masto.host/androiddevsocial/cache/media_attachments/files/109/516/507/668/319/090/small/b4a088d70fddd91c.jpg", + "remote_url": "https://files.botsin.space/media_attachments/files/109/516/507/282/596/167/original/bfa8cfcd97bb8116.jpg", + "preview_remote_url": null, + "text_url": null, + "meta": { + "original": { + "width": 1080, + "height": 873, + "size": "1080x873", + "aspect": 1.2371134020618557 + }, + "small": { + "width": 534, + "height": 432, + "size": "534x432", + "aspect": 1.2361111111111112 + } + }, + "description": null, + "blurhash": "UTBo:PWU0gaJSNazn[0gjF?Fg3r=j@S" + } + ], + "mentions": [], + "tags": [ + { + "name": "apod", + "url": "https://androiddev.social/tags/apod" + } + ], + "emojis": [], + "card": { + "url": "https://apod.nasa.gov/apod/ap221215.html", + "title": " APOD: 2022 December 15 - Full Moon, Full Mars\n", + "description": "A different astronomy and space science\nrelated image is featured each day, along with a brief explanation.", + "type": "link", + "author_name": "", + "author_url": "", + "provider_name": "", + "provider_url": "", + "html": "", + "width": 0, + "height": 0, + "image": null, + "embed_url": "", + "blurhash": null + }, + "poll": null + }, + "application": null, + "account": { + "id": "109279028731807625", + "username": "marcin", + "acct": "marcin", + "display_name": "Marcin ✌️", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-03T00:00:00.000Z", + "note": "

I like to create things. Often the things are Android apps. Currently working at Pocket/Mozilla. Białystok, Poland 🇪🇺

One of my pronouns is half a giggle. (h/t @stella)

", + "url": "https://androiddev.social/@marcin", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/028/731/807/625/original/c91f0af67ed61e9e.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/028/731/807/625/original/c91f0af67ed61e9e.jpg", + "header": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/279/028/731/807/625/original/58deb9050048d26e.jpg", + "header_static": "https://cdn.masto.host/androiddevsocial/accounts/headers/109/279/028/731/807/625/original/58deb9050048d26e.jpg", + "followers_count": 116, + "following_count": 124, + "statuses_count": 158, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "pronouns", + "value": "he/him", + "verified_at": null + }, + { + "name": "pronounced", + "value": "marchin'", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109518003607484488", + "created_at": "2022-12-15T13:48:27.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://macaw.social/users/andypiper/statuses/109518003092739386/activity", + "url": null, + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109517747119542890", + "created_at": "2022-12-15T12:43:21.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://chaos.social/users/attie/statuses/109517747098278057", + "url": "https://chaos.social/@attie/109517747098278057", + "replies_count": 0, + "reblogs_count": 1, + "favourites_count": 0, + "edited_at": "2022-12-15T12:43:58.000Z", + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

My session with @andypiper got a mention in @Anneb's post on the Adafruit Blog (yay, thank you! 🥳)

https://blog.adafruit.com/2022/12/14/icymi-python-on-microcontrollers-newsletter-circuitpython-8-beta-5-released-more-raspberry-pis-are-coming-more-circuitpython-icymi-micropython-raspberry_pi/

https://www.youtube.com/watch?v=jktFR0vpHgU

", + "filtered": [], + "reblog": null, + "account": { + "id": "109282499086047503", + "username": "attie", + "acct": "attie@chaos.social", + "display_name": "Attie Grande", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-10-29T00:00:00.000Z", + "note": "

computer booper / electronics wizard.
things are never as simple as they first appear.
ally 🌈🦕🦄

", + "url": "https://chaos.social/@attie", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/282/499/086/047/503/original/786719017480ebc0.png", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/282/499/086/047/503/original/786719017480ebc0.png", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/282/499/086/047/503/original/d01833cc31e1ea03.jpeg", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/282/499/086/047/503/original/d01833cc31e1ea03.jpeg", + "followers_count": 406, + "following_count": 544, + "statuses_count": 229, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "Pronouns", + "value": "he/him/they", + "verified_at": null + }, + { + "name": "Website", + "value": "https://attie.co.uk", + "verified_at": "2022-12-15T13:48:26.666+00:00" + }, + { + "name": "Email", + "value": "attie@attie.co.uk", + "verified_at": null + }, + { + "name": "Twitter", + "value": "https://twitter.com/AttieGrande", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [ + { + "id": "109349440230215508", + "username": "andypiper", + "url": "https://macaw.social/@andypiper", + "acct": "andypiper@macaw.social" + }, + { + "id": "109367220441922940", + "username": "Anneb", + "url": "https://octodon.social/@Anneb", + "acct": "Anneb@octodon.social" + } + ], + "tags": [], + "emojis": [], + "card": { + "url": "https://blog.adafruit.com/2022/12/14/icymi-python-on-microcontrollers-newsletter-circuitpython-8-beta-5-released-more-raspberry-pis-are-coming-more-circuitpython-icymi-micropython-raspberry_pi/", + "title": "ICYMI Python on Microcontrollers Newsletter: CircuitPython 8 beta 5 Released, More Raspberry Pis are Coming & More! #CircuitPython #ICYMI @micropython @Raspberry_Pi", + "description": "If you missed Tuesday’s Python on Microcontrollers Newsletter, here is the ICYMI (in case you missed it) version. To never miss another issue, subscribe now! – You’ll get one terrific newsletter ea…", + "type": "link", + "author_name": "", + "author_url": "", + "provider_name": "Adafruit Industries - Makers, hackers, artists, designers and engineers!", + "provider_url": "", + "html": "", + "width": 400, + "height": 129, + "image": "https://cdn.masto.host/androiddevsocial/cache/preview_cards/images/000/210/856/original/7111fe2f13388f70.jpg", + "embed_url": "", + "blurhash": "UXR3NFV[~qtQ-=t6MxM}kDNGae%2InoMxtR%" + }, + "poll": null + }, + "account": { + "id": "109349440230215508", + "username": "andypiper", + "acct": "andypiper@macaw.social", + "display_name": "Andy Piper", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-12T00:00:00.000Z", + "note": "

Tech speaker, developer advocate #DevRel, supporter, communities person 💗💜💙 I do things with code, and tinker with gadgets #MicroPython #MQTT #LEGO #BoardGames #DoctorWho … I am one-third of a weekly #podcast 🎧@gamesatwork_biz and help with a #hackspace meetup @makeroni

", + "url": "https://macaw.social/@andypiper", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/349/440/230/215/508/original/9a56ec2d87360520.jpeg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/349/440/230/215/508/original/9a56ec2d87360520.jpeg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/349/440/230/215/508/original/3cb6c7c7b9655877.png", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/349/440/230/215/508/original/3cb6c7c7b9655877.png", + "followers_count": 2243, + "following_count": 1609, + "statuses_count": 741, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "🗺️ links", + "value": "https://andypiper.me", + "verified_at": "2022-12-14T08:17:09.034+00:00" + }, + { + "name": "🎧 podcast", + "value": "https://gamesatwork.biz", + "verified_at": "2022-12-14T08:17:10.859+00:00" + }, + { + "name": "🧑🏼‍💻 code", + "value": "https://github.com/andypiper", + "verified_at": "2022-12-14T08:17:11.853+00:00" + }, + { + "name": "👤 pronouns", + "value": "he/they/them", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109517998820557382", + "created_at": "2022-12-15T13:47:14.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://macaw.social/users/andypiper/statuses/109517998271758059/activity", + "url": null, + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109513081884274482", + "created_at": "2022-12-14T16:56:56.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://wandering.shop/users/indeed_distract/statuses/109513081910999351", + "url": "https://wandering.shop/@indeed_distract/109513081910999351", + "replies_count": 1, + "reblogs_count": 6, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

Gyre *and* gimble? In this wabe?

", + "filtered": [], + "reblog": null, + "account": { + "id": "109304702260258553", + "username": "indeed_distract", + "acct": "indeed_distract@wandering.shop", + "display_name": "Andy H.", + "locked": false, + "bot": false, + "discoverable": false, + "group": false, + "created_at": "2017-10-13T00:00:00.000Z", + "note": "

Precision-seeking, but often ridiculous.

", + "url": "https://wandering.shop/@indeed_distract", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/304/702/260/258/553/original/a45c891c4fa356cf.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/304/702/260/258/553/original/a45c891c4fa356cf.jpg", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 32, + "following_count": 96, + "statuses_count": 41, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "pronouns", + "value": "he/him", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + "account": { + "id": "109349440230215508", + "username": "andypiper", + "acct": "andypiper@macaw.social", + "display_name": "Andy Piper", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-12T00:00:00.000Z", + "note": "

Tech speaker, developer advocate #DevRel, supporter, communities person 💗💜💙 I do things with code, and tinker with gadgets #MicroPython #MQTT #LEGO #BoardGames #DoctorWho … I am one-third of a weekly #podcast 🎧@gamesatwork_biz and help with a #hackspace meetup @makeroni

", + "url": "https://macaw.social/@andypiper", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/349/440/230/215/508/original/9a56ec2d87360520.jpeg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/349/440/230/215/508/original/9a56ec2d87360520.jpeg", + "header": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/349/440/230/215/508/original/3cb6c7c7b9655877.png", + "header_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/headers/109/349/440/230/215/508/original/3cb6c7c7b9655877.png", + "followers_count": 2243, + "following_count": 1609, + "statuses_count": 741, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [ + { + "name": "🗺️ links", + "value": "https://andypiper.me", + "verified_at": "2022-12-14T08:17:09.034+00:00" + }, + { + "name": "🎧 podcast", + "value": "https://gamesatwork.biz", + "verified_at": "2022-12-14T08:17:10.859+00:00" + }, + { + "name": "🧑🏼‍💻 code", + "value": "https://github.com/andypiper", + "verified_at": "2022-12-14T08:17:11.853+00:00" + }, + { + "name": "👤 pronouns", + "value": "he/they/them", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + }, + { + "id": "109517947023389164", + "created_at": "2022-12-15T13:34:12.190Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://androiddev.social/users/sepdroid/statuses/109517947023389164/activity", + "url": "https://androiddev.social/users/sepdroid/statuses/109517947023389164/activity", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "", + "filtered": [], + "reblog": { + "id": "109515422931555103", + "created_at": "2022-12-15T02:52:16.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": "en", + "uri": "https://hachyderm.io/users/juliet/statuses/109515422878602532", + "url": "https://hachyderm.io/@juliet/109515422878602532", + "replies_count": 1, + "reblogs_count": 1, + "favourites_count": 1, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "

I'm polishing and practicing my talk for #normconf tomorrow! I'm talking about The Physics of Data aka \"omg you mean I need to know how computers work?\"

Catch me live at 6 PM Pacific time!
https://normconf.com/

", + "filtered": [], + "reblog": null, + "account": { + "id": "109388149421346346", + "username": "juliet", + "acct": "juliet@hachyderm.io", + "display_name": "Juliet Hougland", + "locked": false, + "bot": false, + "discoverable": false, + "group": false, + "created_at": "2022-11-22T00:00:00.000Z", + "note": "

Sicker than your average. I like plants, weightlifting, and making computers do math for me.

", + "url": "https://hachyderm.io/@juliet", + "avatar": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/388/149/421/346/346/original/09a8f53c72a8bad8.jpeg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/cache/accounts/avatars/109/388/149/421/346/346/original/09a8f53c72a8bad8.jpeg", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 0, + "following_count": 10, + "statuses_count": 20, + "last_status_at": "2022-12-15", + "emojis": [], + "fields": [] + }, + "media_attachments": [], + "mentions": [], + "tags": [ + { + "name": "NormConf", + "url": "https://androiddev.social/tags/NormConf" + } + ], + "emojis": [], + "card": { + "url": "https://normconf.com", + "title": "Normconf", + "description": "Normconf is the tech conference about all the stuff that matters in data and machine learning, but doesn't get the spotlight", + "type": "link", + "author_name": "", + "author_url": "", + "provider_name": "", + "provider_url": "", + "html": "", + "width": 400, + "height": 200, + "image": "https://cdn.masto.host/androiddevsocial/cache/preview_cards/images/000/031/200/original/e553e28424ebf3f0.png", + "embed_url": "", + "blurhash": "UMRW9%7X?H[wJ#Ns~V;9M{Fs~C-CELEL" + }, + "poll": null + }, + "application": null, + "account": { + "id": "109279987650139218", + "username": "sepdroid", + "acct": "sepdroid", + "display_name": "Sepideh", + "locked": false, + "bot": false, + "discoverable": true, + "group": false, + "created_at": "2022-11-03T00:00:00.000Z", + "note": "

Android Dev with an interest in attending local meetings about K-12 education

", + "url": "https://androiddev.social/@sepdroid", + "avatar": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "avatar_static": "https://cdn.masto.host/androiddevsocial/accounts/avatars/109/279/987/650/139/218/original/47bf288270807ee4.jpg", + "header": "https://androiddev.social/headers/original/missing.png", + "header_static": "https://androiddev.social/headers/original/missing.png", + "followers_count": 144, + "following_count": 305, + "statuses_count": 355, + "last_status_at": "2022-12-15", + "noindex": false, + "emojis": [], + "fields": [ + { + "name": "pronouns", + "value": "she/her", + "verified_at": null + }, + { + "name": "pronounced", + "value": "Sa-pita", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + } +] + """.trimIndent() \ No newline at end of file diff --git a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/TimelineFetcherKtTest.kt b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/TimelineFetcherKtTest.kt new file mode 100644 index 00000000..9a391658 --- /dev/null +++ b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/TimelineFetcherKtTest.kt @@ -0,0 +1,41 @@ +package social.androiddev.common.repository.timeline + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store.store5.FetcherResult +import social.androiddev.common.network.model.Privacy +import social.androiddev.common.network.model.Status +import social.androiddev.common.repository.timeline.fixtures.fakeApi +import social.androiddev.common.repository.timeline.fixtures.fakeStorage +import social.androiddev.common.timeline.StatusDB +import social.androiddev.domain.timeline.FeedType +import kotlin.test.Test +import kotlin.test.assertTrue + + +class TimelineFetcherKtTest { + @Test + fun timelineFetcher() = runTest { + val fetcher = fakeApi.timelineFetcher(fakeStorage) + val result = fetcher.invoke(FeedType.Home) + val value: FetcherResult> = result.first() + assertTrue { value is FetcherResult.Data<*> } + + val expected = Status( + "", + "", + "", + null, + "", + Privacy.direct, + false, + "", + ) + + val list: List = (value as FetcherResult.Data).value + assertTrue { list.size == 1 } + assertTrue { list[0] == expected.statusDB() } + } +} + + diff --git a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt new file mode 100644 index 00000000..d745b4d7 --- /dev/null +++ b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt @@ -0,0 +1,81 @@ +package social.androiddev.common.repository.timeline.fixtures + +import social.androiddev.common.network.MastodonApi +import social.androiddev.common.network.model.Application +import social.androiddev.common.network.model.AvailableInstance +import social.androiddev.common.network.model.Instance +import social.androiddev.common.network.model.NewOauthApplication +import social.androiddev.common.network.model.Privacy +import social.androiddev.common.network.model.Status +import social.androiddev.common.network.model.Token +import social.androiddev.common.persistence.localstorage.DodoAuthStorage + + +val fakeStorage = object : DodoAuthStorage { + override var currentDomain: String? = "androiddev.social" + + override suspend fun saveAccessToken(server: String, token: String) { + TODO("Not yet implemented") + } + + override fun getAccessToken(server: String): String = "FakeToken" +} + +val fakeStatus = Status( + "", + "", + "", + null, + "", + Privacy.direct, + false, + "", +) + +val fakeApi = object : MastodonApi { + override suspend fun listInstances(): Result> { + TODO("Not yet implemented") + } + + override suspend fun createApplication( + domain: String, + clientName: String, + redirectUris: String, + scopes: String, + website: String? + ): Result { + TODO("Not yet implemented") + } + + override suspend fun createAccessToken( + domain: String, + clientId: String, + clientSecret: String, + redirectUri: String, + grantType: String, + code: String, + scope: String + ): Result { + TODO("Not yet implemented") + } + + override suspend fun verifyApplication(): Result { + TODO("Not yet implemented") + } + + override suspend fun getInstance(domain: String?): Result { + TODO("Not yet implemented") + } + + override suspend fun getHomeFeed( + domain: String, + accessToken: String + ): Result> { + + return Result.success( + listOf( + fakeStatus + ) + ) + } +} \ No newline at end of file diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt index 8dc636b2..c7ab6d93 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -25,7 +24,6 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.common.theme.DodoTheme -import social.androiddev.domain.timeline.model.StatusLocal import social.androiddev.timeline.navigation.TimelineComponent /** @@ -44,6 +42,7 @@ fun TimelineContent( modifier = modifier, ) } + else -> { //handle error/loading } From 5476f656b98beb7eb8039b4870a5e468fc0f0601 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Tue, 20 Dec 2022 09:31:54 -0500 Subject: [PATCH 17/28] spotless --- .../common/network/MastodonApiKtor.kt | 2 +- .../common/network/model/Application.kt | 2 +- .../androiddev/common/network/model/Status.kt | 2 +- .../androiddev/common/network/model/Tag.kt | 2 +- .../common/network/MastodonApiTests.kt | 6 +-- .../common/network/fixtures/TestFixtures.kt | 13 +++++- .../common/repository/di/RepositoryModule.kt | 1 - .../repository/timeline/ModelMappers.kt | 24 +++++++---- ...itory.kt => RealHomeTimelineRepository.kt} | 18 +++++--- .../repository/timeline/TimelineFetcher.kt | 9 ++++ .../repository/timeline/TimelineRepoModule.kt | 10 ++--- .../timeline/TimelineSourceOfTruth.kt | 13 +++++- .../timeline/TimelineFetcherKtTest.kt | 12 ++++-- .../timeline/fixtures/TestFixtures.kt | 12 +++++- .../domain/timeline/HomeTimelineRepository.kt | 22 +++++++--- .../domain/timeline/model/StatusLocal.kt | 16 ++++---- .../composables/SignedInRootContent.kt | 1 - .../DefaultSignedInRootComponent.kt | 2 +- .../androiddev/timeline/TimelineContent.kt | 2 +- .../social/androiddev/timeline/TootContent.kt | 5 --- .../navigation/DefaultTimelineComponent.kt | 4 -- .../timeline/navigation/TimelineComponent.kt | 11 ++++- .../timeline/navigation/TimelineViewModel.kt | 41 ++++++++++++------- 23 files changed, 151 insertions(+), 79 deletions(-) rename data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/{HomeTimelineRepository.kt => RealHomeTimelineRepository.kt} (61%) diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt index 81c53387..3860d966 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/MastodonApiKtor.kt @@ -128,7 +128,7 @@ internal class MastodonApiKtor( override suspend fun getHomeFeed(domain: String, accessToken: String): Result> { return runCatchingIgnoreCancelled { - httpClient.get{ + httpClient.get { url { host = domain path("/api/v1/timelines/home") diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt index 9d9e9a39..3b9a0541 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Application.kt @@ -18,7 +18,7 @@ import kotlinx.serialization.Serializable @Serializable data class Application( val name: String, - @SerialName("vapid_key") val vapidKey: String?=null, + @SerialName("vapid_key") val vapidKey: String? = null, // optional attributes val website: String? = null, diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt index 90a207db..43614ef8 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Status.kt @@ -27,7 +27,7 @@ data class Status( @SerialName("sensitive") val sensitive: Boolean, @SerialName("spoiler_text") val spoilerText: String, @SerialName("media_attachments") val mediaAttachments: List? = emptyList(), - @SerialName("application") val application: Application?=null, + @SerialName("application") val application: Application? = null, // rendering attributes @SerialName("mentions") val mentions: List? = null, diff --git a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Tag.kt b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Tag.kt index 0746da5c..359bb2bf 100644 --- a/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Tag.kt +++ b/data/network/src/commonMain/kotlin/social/androiddev/common/network/model/Tag.kt @@ -22,5 +22,5 @@ data class Tag( @SerialName("url") val url: String, // optional attributes - @SerialName("history") val history: List?= emptyList(), + @SerialName("history") val history: List? = emptyList(), ) diff --git a/data/network/src/commonTest/kotlin/social/androiddev/common/network/MastodonApiTests.kt b/data/network/src/commonTest/kotlin/social/androiddev/common/network/MastodonApiTests.kt index af63a3df..fecb2f10 100644 --- a/data/network/src/commonTest/kotlin/social/androiddev/common/network/MastodonApiTests.kt +++ b/data/network/src/commonTest/kotlin/social/androiddev/common/network/MastodonApiTests.kt @@ -30,7 +30,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) - class MastodonApiTests { +class MastodonApiTests { @Test fun `instance list should be parsed correctly`() = runTest { @@ -215,7 +215,7 @@ import kotlin.test.assertTrue // then assertTrue(actual = result.isSuccess) } - //TODO MIKE: Harden tests to validate inputs + // TODO MIKE: Harden tests to validate inputs @Test fun `view home feed should succeed`() = runTest { val mastodonApi = MastodonApiKtor( @@ -225,7 +225,7 @@ import kotlin.test.assertTrue ) // when - val result = mastodonApi.getHomeFeed("","") + val result = mastodonApi.getHomeFeed("", "") // then assertTrue(actual = result.isSuccess) diff --git a/data/network/src/commonTest/kotlin/social/androiddev/common/network/fixtures/TestFixtures.kt b/data/network/src/commonTest/kotlin/social/androiddev/common/network/fixtures/TestFixtures.kt index 984f05a2..3b78dcf0 100644 --- a/data/network/src/commonTest/kotlin/social/androiddev/common/network/fixtures/TestFixtures.kt +++ b/data/network/src/commonTest/kotlin/social/androiddev/common/network/fixtures/TestFixtures.kt @@ -1,6 +1,15 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.common.network.fixtures - val homeFeed = """ +val homeFeed = """ [ { "id": "109518540195637455", @@ -2400,4 +2409,4 @@ package social.androiddev.common.network.fixtures "poll": null } ] - """.trimIndent() \ No newline at end of file +""".trimIndent() diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt index 87bb8826..4355d407 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/di/RepositoryModule.kt @@ -31,4 +31,3 @@ val repositoryModule: Module = module { ) } } - diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt index 3eaa7967..ed39d3d1 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/ModelMappers.kt @@ -1,3 +1,12 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.common.repository.timeline import social.androiddev.common.network.model.Status @@ -11,9 +20,9 @@ fun StatusDB.toLocal( remoteId = remoteId, feedType = key, createdAt = createdAt, - repliesCount = repliesCount?:0, - reblogsCount = favouritesCount?:0, - favoritesCount = favouritesCount?:0, + repliesCount = repliesCount ?: 0, + reblogsCount = favouritesCount ?: 0, + favoritesCount = favouritesCount ?: 0, content = content, sensitive = sensitive ?: false, spoilerText = spoilerText, @@ -23,7 +32,6 @@ fun StatusDB.toLocal( userName = userName ) - fun Status.statusDB() = StatusDB( type = FeedType.Home.type, @@ -39,7 +47,7 @@ fun Status.statusDB() = repliesCount = repliesCount?.toLong(), reblogsCount = reblogsCount?.toLong(), favouritesCount = favouritesCount?.toLong(), - avatarUrl = account?.avatar?:"", - accountAddress = account?.acct?:"", - userName = account?.username?:" " - ) \ No newline at end of file + avatarUrl = account?.avatar ?: "", + accountAddress = account?.acct ?: "", + userName = account?.username ?: " " + ) diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepository.kt similarity index 61% rename from data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt rename to data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepository.kt index 445e0371..714ba017 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/HomeTimelineRepository.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepository.kt @@ -1,3 +1,12 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.common.repository.timeline import kotlinx.coroutines.flow.Flow @@ -9,7 +18,6 @@ import social.androiddev.domain.timeline.FeedType import social.androiddev.domain.timeline.HomeTimelineRepository import social.androiddev.domain.timeline.model.StatusLocal - class RealHomeTimelineRepository( private val store: Store> ) : HomeTimelineRepository { @@ -24,8 +32,6 @@ class RealHomeTimelineRepository( refresh: Boolean ): Flow>> { return store.stream(StoreRequest.cached(key = feedType, refresh = true)) - .distinctUntilChanged() } - - -} - + .distinctUntilChanged() + } +} \ No newline at end of file diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineFetcher.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineFetcher.kt index f97e94a9..990925ad 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineFetcher.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineFetcher.kt @@ -1,3 +1,12 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.common.repository.timeline import org.mobilenativefoundation.store.store5.Fetcher diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt index 49e1fa66..d161410d 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineRepoModule.kt @@ -33,17 +33,13 @@ val timelineRepoModule: Module = module { factory { get().timelineFetcher(authStorage = get()) } - factory { val fetcher = get>>() val sourceOfTruth = get, List>>() StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = sourceOfTruth - ) + fetcher = fetcher, + sourceOfTruth = sourceOfTruth + ) .build() } } - - - diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt index 88cb2b9d..b39ab7a4 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt @@ -1,3 +1,12 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.common.repository.timeline import com.squareup.sqldelight.runtime.coroutines.asFlow @@ -29,13 +38,13 @@ private fun TimelineQueries.homeItemsAsLocal(key: FeedType) = selectHomeItems() .asFlow() .mapToList() .map { - it.ifEmpty { return@map null } //treat empty list as no result otherwise + it.ifEmpty { return@map null } // treat empty list as no result otherwise it.map { item -> item.toLocal(key) } } fun TimelineDatabase.tryWriteItem(it: StatusDB, type: FeedType): Boolean = try { timelineQueries.insertFeedItem( - it.copy(type = type.type) + it.copy(type = type.type) ) true } catch (t: Throwable) { diff --git a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/TimelineFetcherKtTest.kt b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/TimelineFetcherKtTest.kt index 9a391658..fea32084 100644 --- a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/TimelineFetcherKtTest.kt +++ b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/TimelineFetcherKtTest.kt @@ -1,3 +1,12 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.common.repository.timeline import kotlinx.coroutines.flow.first @@ -12,7 +21,6 @@ import social.androiddev.domain.timeline.FeedType import kotlin.test.Test import kotlin.test.assertTrue - class TimelineFetcherKtTest { @Test fun timelineFetcher() = runTest { @@ -37,5 +45,3 @@ class TimelineFetcherKtTest { assertTrue { list[0] == expected.statusDB() } } } - - diff --git a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt index d745b4d7..2fd7fbb6 100644 --- a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt +++ b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt @@ -1,3 +1,12 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.common.repository.timeline.fixtures import social.androiddev.common.network.MastodonApi @@ -10,7 +19,6 @@ import social.androiddev.common.network.model.Status import social.androiddev.common.network.model.Token import social.androiddev.common.persistence.localstorage.DodoAuthStorage - val fakeStorage = object : DodoAuthStorage { override var currentDomain: String? = "androiddev.social" @@ -78,4 +86,4 @@ val fakeApi = object : MastodonApi { ) ) } -} \ No newline at end of file +} diff --git a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt index df55a497..88f0ba1a 100644 --- a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt +++ b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt @@ -1,13 +1,25 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.domain.timeline import kotlinx.coroutines.flow.Flow - import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.domain.timeline.model.StatusLocal interface HomeTimelineRepository { - suspend fun read(feedType: FeedType, refresh: Boolean = false): Flow>> + suspend fun read( + feedType: FeedType, + refresh: Boolean = false + ): Flow>> +} + +sealed class FeedType(val type: String) { + object Home : FeedType("HOME") } -sealed class FeedType(val type:String){ - object Home: FeedType("HOME") -} \ No newline at end of file diff --git a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt index 292f44d5..b99722ca 100644 --- a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt +++ b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/model/StatusLocal.kt @@ -12,18 +12,18 @@ package social.androiddev.domain.timeline.model import social.androiddev.domain.timeline.FeedType data class StatusLocal( - val remoteId:String, + val remoteId: String, val feedType: FeedType, val createdAt: String, - val repliesCount: Long=0, - val reblogsCount: Long=0, - val favoritesCount: Long=0, + val repliesCount: Long = 0, + val reblogsCount: Long = 0, + val favoritesCount: Long = 0, val content: String, - val account: Account?=null, + val account: Account? = null, val sensitive: Boolean = false, val spoilerText: String? = null, val visibility: String = "Public", - val avatarUrl:String="", - val accountAddress:String="", - val userName:String + val avatarUrl: String = "", + val accountAddress: String = "", + val userName: String ) diff --git a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt index 4f48e424..42ead4fc 100644 --- a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt +++ b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/composables/SignedInRootContent.kt @@ -20,7 +20,6 @@ import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import kotlinx.coroutines.flow.StateFlow import org.mobilenativefoundation.store.store5.StoreResponse -import social.androiddev.domain.timeline.model.StatusLocal import social.androiddev.signedin.navigation.SignedInRootComponent import social.androiddev.timeline.FeedItemState import social.androiddev.timeline.TimelineContent diff --git a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/navigation/DefaultSignedInRootComponent.kt b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/navigation/DefaultSignedInRootComponent.kt index 98d10551..d8b3a2a0 100644 --- a/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/navigation/DefaultSignedInRootComponent.kt +++ b/ui/signed-in/src/commonMain/kotlin/social/androiddev/signedin/navigation/DefaultSignedInRootComponent.kt @@ -28,7 +28,7 @@ import kotlin.coroutines.CoroutineContext class DefaultSignedInRootComponent( private val componentContext: ComponentContext, private val mainContext: CoroutineContext, - ) : SignedInRootComponent, ComponentContext by componentContext { +) : SignedInRootComponent, ComponentContext by componentContext { // StackNavigation accepts navigation commands and forwards them to all subscribed observers. private val navigation = StackNavigation() diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt index c7ab6d93..219754ab 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineContent.kt @@ -44,7 +44,7 @@ fun TimelineContent( } else -> { - //handle error/loading + // handle error/loading } } } diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt index d481af7c..c8b8ce26 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt @@ -18,10 +18,7 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle import social.androiddev.common.theme.DodoTheme @Composable @@ -59,8 +56,6 @@ fun TootContent( } } - - // @Preview @Composable private fun PreviewTootContentLight() { diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt index 1ba97c97..47f1c7e4 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/DefaultTimelineComponent.kt @@ -20,8 +20,6 @@ import social.androiddev.domain.timeline.HomeTimelineRepository import social.androiddev.timeline.FeedItemState import kotlin.coroutines.CoroutineContext - - class DefaultTimelineComponent( mainContext: CoroutineContext, private val componentContext: ComponentContext, @@ -38,6 +36,4 @@ class DefaultTimelineComponent( } override val state: StateFlow>> = viewModel.state - } - diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt index 12798d18..837a0284 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineComponent.kt @@ -1,3 +1,12 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.timeline.navigation import kotlinx.coroutines.flow.StateFlow @@ -9,4 +18,4 @@ import social.androiddev.timeline.FeedItemState */ interface TimelineComponent { val state: StateFlow>> -} \ No newline at end of file +} diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt index 656d01eb..ce1744f3 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt @@ -1,3 +1,12 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.timeline.navigation import com.arkivanov.essenty.instancekeeper.InstanceKeeper @@ -31,23 +40,26 @@ class TimelineViewModel( homeTimelineRepository.read(refresh = true).collect { when (val response: StoreResponse> = it) { is StoreResponse.Data -> { - val result = StoreResponse.Data(response.value.map { - FeedItemState( - id = it.remoteId, - userAvatarUrl = it.avatarUrl, - date = it.createdAt, - username = it.userName, - acctAddress = it.accountAddress, - message = it.content, - images = emptyList(), - videoUrl = null, - ) - }, response.origin) + val result = StoreResponse.Data( + response.value.map { + FeedItemState( + id = it.remoteId, + userAvatarUrl = it.avatarUrl, + date = it.createdAt, + username = it.userName, + acctAddress = it.accountAddress, + message = it.content, + images = emptyList(), + videoUrl = null, + ) + }, + response.origin + ) _state.value = result } else -> { - //TODO display error/loading + // TODO display error/loading } } } @@ -57,5 +69,4 @@ class TimelineViewModel( override fun onDestroy() { scope.cancel() // Cancel the scope when the instance is destroyed } - -} \ No newline at end of file +} From 70ea9c600b7877a218a95d8002f84d34d8083f01 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Tue, 20 Dec 2022 10:29:27 -0500 Subject: [PATCH 18/28] spotless --- data/persistence/build.gradle.kts | 1 + data/repository/build.gradle.kts | 2 +- .../timeline/RealHomeTimelineRepository.kt | 2 +- .../timeline/TimelineSourceOfTruth.kt | 13 +-- .../RealHomeTimelineRepositoryTest.kt | 96 +++++++++++++++++++ .../timeline/fixtures/TestFixtures.kt | 24 +++++ .../domain/timeline/HomeTimelineRepository.kt | 2 +- gradle/libs.versions.toml | 2 + .../timeline/navigation/TimelineViewModel.kt | 57 +++++------ 9 files changed, 157 insertions(+), 42 deletions(-) create mode 100644 data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt diff --git a/data/persistence/build.gradle.kts b/data/persistence/build.gradle.kts index d9a147cd..5e62f63f 100644 --- a/data/persistence/build.gradle.kts +++ b/data/persistence/build.gradle.kts @@ -58,6 +58,7 @@ kotlin { implementation(libs.multiplatform.settings) implementation(libs.kotlinx.serialization.json) implementation(libs.io.insert.koin.core) + implementation(libs.store) } } diff --git a/data/repository/build.gradle.kts b/data/repository/build.gradle.kts index a044203a..36ffa9f6 100644 --- a/data/repository/build.gradle.kts +++ b/data/repository/build.gradle.kts @@ -60,7 +60,7 @@ kotlin { getByName("androidMain") { dependsOn(commonMain) dependencies { - api ("org.jetbrains.kotlinx:atomicfu:0.18.5") + api (libs.org.jetbrains.kotlinx.atomicfu) } } diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepository.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepository.kt index 714ba017..478dabea 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepository.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepository.kt @@ -27,7 +27,7 @@ class RealHomeTimelineRepository( * on first return will also call network fetcher to get * latest from network and update local storage with it] */ - override suspend fun read( + override fun read( feedType: FeedType, refresh: Boolean ): Flow>> { diff --git a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt index b39ab7a4..88cb2b9d 100644 --- a/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt +++ b/data/repository/src/commonMain/kotlin/social/androiddev/common/repository/timeline/TimelineSourceOfTruth.kt @@ -1,12 +1,3 @@ -/* - * This file is part of Dodo. - * - * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Dodo. If not, see . - */ package social.androiddev.common.repository.timeline import com.squareup.sqldelight.runtime.coroutines.asFlow @@ -38,13 +29,13 @@ private fun TimelineQueries.homeItemsAsLocal(key: FeedType) = selectHomeItems() .asFlow() .mapToList() .map { - it.ifEmpty { return@map null } // treat empty list as no result otherwise + it.ifEmpty { return@map null } //treat empty list as no result otherwise it.map { item -> item.toLocal(key) } } fun TimelineDatabase.tryWriteItem(it: StatusDB, type: FeedType): Boolean = try { timelineQueries.insertFeedItem( - it.copy(type = type.type) + it.copy(type = type.type) ) true } catch (t: Throwable) { diff --git a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt new file mode 100644 index 00000000..26151fb5 --- /dev/null +++ b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt @@ -0,0 +1,96 @@ +package social.androiddev.common.repository.timeline + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.ResponseOrigin +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.StoreRequest +import org.mobilenativefoundation.store.store5.StoreResponse +import social.androiddev.common.repository.timeline.fixtures.failureResponse +import social.androiddev.common.repository.timeline.fixtures.fakeLocalStatus +import social.androiddev.domain.timeline.FeedType +import social.androiddev.domain.timeline.model.StatusLocal +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + + +class RealHomeTimelineRepositoryTest{ + @Test fun sucessTest(): TestResult { + return runTest{ + val testRepo = RealHomeTimelineRepository(fakeSuccessStore) + val result = testRepo.read(FeedType.Home).first() + assertTrue { result is StoreResponse.Data } + assertTrue { result.requireData().first() == fakeLocalStatus } + + } + } + + @Test fun failureTest(): TestResult { + return runTest{ + val testRepo = RealHomeTimelineRepository(fakeFailureStore) + val result = testRepo.read(FeedType.Home).first() + assertTrue { result is StoreResponse.Error.Message } + assertTrue { result.errorMessageOrNull() == failureResponse.message} + + } + } +} +val fakeSuccessStore = object : Store> { + override suspend fun clear(key: FeedType) { + TODO("Not yet implemented") + } + + @ExperimentalStoreApi + override suspend fun clearAll() { + TODO("Not yet implemented") + } + + override fun stream(request: StoreRequest): Flow>> { + return when (request.key.type) { + FeedType.Home.type -> { + flow { + emit( + StoreResponse.Data( + listOf(fakeLocalStatus), + ResponseOrigin.Cache + ) + ) + } + } + + else -> { + fail("wrong response") + } + } + } +} + +val fakeFailureStore = object : Store> { + override suspend fun clear(key: FeedType) { + TODO("Not yet implemented") + } + + @ExperimentalStoreApi + override suspend fun clearAll() { + TODO("Not yet implemented") + } + + override fun stream(request: StoreRequest): Flow>> { + return when (request.key.type) { + FeedType.Home.type -> { + flow { + emit(failureResponse) + } + } + + else -> { + fail("wrong response") + } + } + } +} diff --git a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt index 2fd7fbb6..91376ddb 100644 --- a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt +++ b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/fixtures/TestFixtures.kt @@ -9,6 +9,8 @@ */ package social.androiddev.common.repository.timeline.fixtures +import org.mobilenativefoundation.store.store5.ResponseOrigin +import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.common.network.MastodonApi import social.androiddev.common.network.model.Application import social.androiddev.common.network.model.AvailableInstance @@ -18,6 +20,8 @@ import social.androiddev.common.network.model.Privacy import social.androiddev.common.network.model.Status import social.androiddev.common.network.model.Token import social.androiddev.common.persistence.localstorage.DodoAuthStorage +import social.androiddev.domain.timeline.FeedType +import social.androiddev.domain.timeline.model.StatusLocal val fakeStorage = object : DodoAuthStorage { override var currentDomain: String? = "androiddev.social" @@ -29,6 +33,26 @@ val fakeStorage = object : DodoAuthStorage { override fun getAccessToken(server: String): String = "FakeToken" } +val failureResponse = StoreResponse.Error.Message("We failed", ResponseOrigin.Cache) + + +val fakeLocalStatus = StatusLocal( + "", + FeedType.Home, + "", + 0, + 0, + 0, + "", + null, + false, + "", + "", + "", + "", + "" +) + val fakeStatus = Status( "", "", diff --git a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt index 88f0ba1a..40fbb5a3 100644 --- a/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt +++ b/domain/timeline/src/commonMain/kotlin/social/androiddev/domain/timeline/HomeTimelineRepository.kt @@ -14,7 +14,7 @@ import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.domain.timeline.model.StatusLocal interface HomeTimelineRepository { - suspend fun read( + fun read( feedType: FeedType, refresh: Boolean = false ): Flow>> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f258d22..e9066936 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ io-insert-koin = "3.2.0" com-russhwolf = "1.0.0-RC" kotlinx-serialization = "1.4.0" store = "5.0.0-SNAPSHOT" +atomic-fu="0.18.5" io-github-aakira = "2.6.1" [libraries] @@ -52,6 +53,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-javafx = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-javafx", version.ref = "org-jetbrains-kotlinx-coroutines" } org-jetbrains-kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "org-jetbrains-kotlinx-coroutines" } org-jetbrains-kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "org-jetbrains-kotlinx" } +org-jetbrains-kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomic-fu" } org-jetbrains-kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "org-jetbrains-kotlin" } org-jetbrains-kotlin-test-common = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "org-jetbrains-kotlin" } diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt index ce1744f3..e90dbfa1 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt @@ -14,9 +14,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn import org.mobilenativefoundation.store.store5.ResponseOrigin import org.mobilenativefoundation.store.store5.StoreResponse import social.androiddev.domain.timeline.FeedType @@ -33,35 +35,34 @@ class TimelineViewModel( private val scope = CoroutineScope(mainContext + SupervisorJob()) private val _state = MutableStateFlow>>(StoreResponse.Loading(ResponseOrigin.SourceOfTruth)) - val state: StateFlow>> = _state.asStateFlow() + val state: StateFlow>> = homeTimelineRepository + .read(FeedType.Home, refresh = true) + .mapLatest(::render) + .stateIn(scope, SharingStarted.Eagerly, StoreResponse.Loading(ResponseOrigin.Cache)) - init { - scope.launch { - homeTimelineRepository.read(refresh = true).collect { - when (val response: StoreResponse> = it) { - is StoreResponse.Data -> { - val result = StoreResponse.Data( - response.value.map { - FeedItemState( - id = it.remoteId, - userAvatarUrl = it.avatarUrl, - date = it.createdAt, - username = it.userName, - acctAddress = it.accountAddress, - message = it.content, - images = emptyList(), - videoUrl = null, - ) - }, - response.origin + private fun render(it: StoreResponse>): StoreResponse.Data> { + return when (val response: StoreResponse> = it) { + is StoreResponse.Data -> { + val result = StoreResponse.Data( + response.value.map { + FeedItemState( + id = it.remoteId, + userAvatarUrl = it.avatarUrl, + date = it.createdAt, + username = it.userName, + acctAddress = it.accountAddress, + message = it.content, + images = emptyList(), + videoUrl = null, ) - _state.value = result - } + }, + response.origin + ) + result + } - else -> { - // TODO display error/loading - } - } + else -> { + StoreResponse.Data(emptyList(), it.origin) } } } From d156b69cf49c961c0ef6580e35c46ebb2e4e4250 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Tue, 20 Dec 2022 18:40:02 -0500 Subject: [PATCH 19/28] image loading + wip parsing --- app-android/build.gradle.kts | 7 + ui/common/build.gradle.kts | 6 + .../common/composables/UserAvatar.kt | 6 +- .../androiddev/common/utils/AsyncImage.kt | 49 ++----- .../androiddev/common/utils/HtmlParser.kt | 122 ++++++++++++++++++ .../signedout/landing/LandingContent.kt | 16 +-- .../social/androiddev/timeline/TootContent.kt | 11 +- 7 files changed, 158 insertions(+), 59 deletions(-) create mode 100644 ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt diff --git a/app-android/build.gradle.kts b/app-android/build.gradle.kts index 501a582d..10649b74 100644 --- a/app-android/build.gradle.kts +++ b/app-android/build.gradle.kts @@ -26,6 +26,13 @@ android { targetCompatibility = JavaVersion.VERSION_11 } + packagingOptions { + exclude ("META-INF/DEPENDENCIES") + exclude ("META-INF/NOTICE") + exclude ("mozilla/public-suffix-list.txt") + + } + kotlinOptions { jvmTarget = "11" } diff --git a/ui/common/build.gradle.kts b/ui/common/build.gradle.kts index 22a1dc66..e9a8eb1d 100644 --- a/ui/common/build.gradle.kts +++ b/ui/common/build.gradle.kts @@ -18,6 +18,9 @@ android { minSdk = minSDKVersion targetSdk = targetSDKVersion } + packagingOptions { + exclude ("META-INF/DEPENDENCIES") + } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -45,6 +48,9 @@ kotlin { implementation(compose.material) api(libs.com.arkivanov.decompose) api(libs.com.arkivanov.decompose.extensions.compose.jetbrains) + implementation("com.alialbaali.kamel:kamel-image:0.4.0") + implementation("it.skrape:skrapeit:1.2.2") + } } diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/composables/UserAvatar.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/composables/UserAvatar.kt index d3511707..b006fc4a 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/composables/UserAvatar.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/composables/UserAvatar.kt @@ -9,6 +9,7 @@ */ package social.androiddev.common.composables +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -26,10 +27,9 @@ fun UserAvatar( url: String ) { AsyncImage( - load = { loadImageIntoPainter(url = url) }, - painterFor = { remember { it } }, + url = url, contentDescription = "User avatar", - modifier = modifier.width(48.dp).clip(RoundedCornerShape(5.dp)) + modifier = modifier.width(48.dp).height(48.dp).clip(RoundedCornerShape(5.dp)) ) } diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt index b6950d3a..708583fc 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt @@ -9,56 +9,29 @@ */ package social.androiddev.common.utils -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import social.androiddev.common.network.util.runCatchingIgnoreCancelled +import io.kamel.image.KamelImage +import io.kamel.image.lazyPainterResource /** * Use this helper until we switch to a image loading library which supports multiplatform */ @Composable -fun AsyncImage( +fun AsyncImage( contentDescription: String, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, - load: suspend () -> T, - painterFor: @Composable (T) -> Painter, + url:String ) { - val image: T? by produceState(null) { - value = withContext(Dispatchers.IO) { - runCatchingIgnoreCancelled { - load() - }.fold( - onSuccess = { it }, - onFailure = { t -> - t.printStackTrace() - null - } - ) - } - } - - if (image != null) { - Image( - painter = painterFor(image!!), - contentDescription = contentDescription, - contentScale = contentScale, - modifier = modifier, - alignment = alignment, - ) - } else { - Spacer( - modifier = modifier - ) - } + KamelImage( + resource = lazyPainterResource(data = url), + contentDescription =contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale + ) } diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt new file mode 100644 index 00000000..b34304ba --- /dev/null +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt @@ -0,0 +1,122 @@ +package social.androiddev.common.utils + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import io.ktor.util.escapeHTML +import it.skrape.core.htmlDocument +import it.skrape.selects.eachAttribute +import it.skrape.selects.eachHref +import it.skrape.selects.eachLink +import it.skrape.selects.eachText +import it.skrape.selects.html5.a +import it.skrape.selects.html5.p + + +/** + * The tags to interpret. Add tags here and in [tagToStyle]. + */ +private val tags = linkedMapOf( + "" to "", + "" to "", + "" to "" +) + +/** + * The main entry point. Call this on a String and use the result in a Text. + */ +@Composable +fun String.renderHtml(modifier: Modifier) { + val newlineReplace = this.replace("
", "\n") + if (newlineReplace.contains("

")) { + val content = htmlDocument(newlineReplace) + val paragraph = content.p { findAll { eachText } } + val links = try { + content.a { findAll { eachLink } } + } catch (exception: Exception) { + emptyMap() + } + val attribute = try { + content.a { findAll { eachAttribute } } + } catch (exception: Exception) { + emptyMap() + } + + val href = + try { + content.a { findAll { eachHref } } + } catch (exception: Exception) { + listOf("") + } + + paragraph.size + links?.size + + val groups = paragraph.first().split(if(links.isNotEmpty())links.keys.first() else "") + val before = groups[0] + val after = if(groups.size > 1) groups[1] else "" + + val annotatedText = buildAnnotatedString { + if(links.isNullOrEmpty()){ + append(before) + append(after) + } + else if(before.isNullOrEmpty() && after.isNotEmpty()){ + withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { + appendLink(links.keys.first(), links.values.first()) + append(after) + } + } + else if (before == after && before == "") { + withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { + appendLink(links.keys.first(), links.values.first()) + } + } else if (before != after && after.isNullOrEmpty()) { + withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { + appendLink(before, links.values.first()) + } + } else { + append(before) + withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { + appendLink(links.keys.first(), links.values.first()) + } + append(after) + } + } + + ClickableText( + text = annotatedText, + onClick = { offset -> + annotatedText.onLinkClick(offset) { link -> + println("Clicked URL: $link") + // Open link in WebView. + } + } + ) + } +} + + +fun AnnotatedString.Builder.appendLink(linkText: String, linkUrl: String) { + pushStringAnnotation(tag = linkUrl, annotation = linkUrl) + append(linkText) + pop() +} + +fun AnnotatedString.onLinkClick(offset: Int, onClick: (String) -> Unit) { + getStringAnnotations(start = offset, end = offset).firstOrNull()?.let { + onClick(it.item) + } +} \ No newline at end of file diff --git a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/landing/LandingContent.kt b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/landing/LandingContent.kt index 781dfbde..9c703bc6 100644 --- a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/landing/LandingContent.kt +++ b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/landing/LandingContent.kt @@ -17,7 +17,7 @@ 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.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.CircleShape @@ -25,7 +25,6 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.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 @@ -38,7 +37,6 @@ import social.androiddev.common.composables.buttons.DodoButton import social.androiddev.common.theme.Blue import social.androiddev.common.theme.DodoTheme import social.androiddev.common.utils.AsyncImage -import social.androiddev.common.utils.loadImageIntoPainter /** * Landing view that delegates business logic to [LandingContent] @@ -49,12 +47,12 @@ fun LandingContent( modifier: Modifier = Modifier, appIcon: @Composable () -> Unit = { AsyncImage( - load = { loadImageIntoPainter(url = "https://via.placeholder.com/200x200/6FA4DE/010041?text=Dodo") }, - painterFor = { remember { it } }, + url = "https://via.placeholder.com/200x200/6FA4DE/010041?text=Dodo", contentDescription = "App Logo", modifier = Modifier .padding(horizontal = 48.dp) - .size(240.dp) + .width(240.dp) + .height(240.dp) .clip(CircleShape), contentScale = ContentScale.Crop, ) @@ -73,12 +71,12 @@ fun LandingContent( onGetStartedClicked: () -> Unit, appIcon: @Composable () -> Unit = { AsyncImage( - load = { loadImageIntoPainter(url = "https://via.placeholder.com/200x200/6FA4DE/010041?text=Dodo") }, - painterFor = { remember { it } }, + url = "https://via.placeholder.com/200x200/6FA4DE/010041?text=Dodo", contentDescription = "App Logo", modifier = Modifier .padding(horizontal = 48.dp) - .size(240.dp) + .width(240.dp) + .height(240.dp) .clip(CircleShape), contentScale = ContentScale.Crop, ) diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt index c8b8ce26..5074d50d 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt @@ -15,11 +15,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.buildAnnotatedString import social.androiddev.common.theme.DodoTheme +import social.androiddev.common.utils.renderHtml @Composable fun TootContent( @@ -44,13 +43,7 @@ fun TootContent( // TODO Add support for video + multiple images rendering // for now just show message from toot if (message != null) { - Text( - modifier = Modifier.fillMaxWidth(), - text = buildAnnotatedString { - append(message) - }, - style = MaterialTheme.typography.caption - ) + message.renderHtml(modifier) VerticalSpacer() } } From 5d52624e2f8b18b1b41b877244eb003ded2948c1 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Wed, 21 Dec 2022 09:51:28 -0500 Subject: [PATCH 20/28] parse html into annotated strings --- ui/common/build.gradle.kts | 9 ++ .../androiddev/common/utils/HtmlParser.kt | 105 +++++------------- .../src/commonTest/kotlin/HtmlParserKtTest.kt | 17 +++ .../social/androiddev/timeline/TootContent.kt | 4 +- 4 files changed, 58 insertions(+), 77 deletions(-) create mode 100644 ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt diff --git a/ui/common/build.gradle.kts b/ui/common/build.gradle.kts index e9a8eb1d..b3b28272 100644 --- a/ui/common/build.gradle.kts +++ b/ui/common/build.gradle.kts @@ -66,6 +66,15 @@ kotlin { implementation(compose.desktop.common) } } + named("commonTest") { + dependencies { + implementation(kotlin("test")) + implementation(libs.org.jetbrains.kotlin.test.common) + implementation(libs.org.jetbrains.kotlin.test.annotations.common) + implementation(libs.org.jetbrains.kotlinx.coroutines.test) + } + } + } tasks.withType { diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt index b34304ba..ed0b7d59 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt @@ -1,111 +1,66 @@ package social.androiddev.common.utils -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.ClickableText -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import io.ktor.util.escapeHTML import it.skrape.core.htmlDocument -import it.skrape.selects.eachAttribute -import it.skrape.selects.eachHref import it.skrape.selects.eachLink import it.skrape.selects.eachText import it.skrape.selects.html5.a -import it.skrape.selects.html5.p +import it.skrape.selects.html5.body /** - * The tags to interpret. Add tags here and in [tagToStyle]. + * Takes a String reciever of html text + * converts it to an annotated string of text and links */ -private val tags = linkedMapOf( - "" to "", - "" to "", - "" to "" -) - -/** - * The main entry point. Call this on a String and use the result in a Text. - */ -@Composable -fun String.renderHtml(modifier: Modifier) { +fun String.renderHtml(): AnnotatedString { val newlineReplace = this.replace("
", "\n") if (newlineReplace.contains("

")) { val content = htmlDocument(newlineReplace) - val paragraph = content.p { findAll { eachText } } + + val body = content.body { + findAll { + eachText + } + } + val paragraph = body val links = try { content.a { findAll { eachLink } } } catch (exception: Exception) { emptyMap() } - val attribute = try { - content.a { findAll { eachAttribute } } - } catch (exception: Exception) { - emptyMap() - } - - val href = - try { - content.a { findAll { eachHref } } - } catch (exception: Exception) { - listOf("") - } - paragraph.size - links?.size - - val groups = paragraph.first().split(if(links.isNotEmpty())links.keys.first() else "") - val before = groups[0] - val after = if(groups.size > 1) groups[1] else "" + val plainText = paragraph.joinToString("\n") + if(links.isNullOrEmpty()) return buildAnnotatedString { + append(plainText) + } + return buildAnnotatedString { + //everything before first link + append(plainText.substringBefore(links.keys.first())) - val annotatedText = buildAnnotatedString { - if(links.isNullOrEmpty()){ - append(before) - append(after) + withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { + appendLink(links.keys.first(), links.values.first()) } - else if(before.isNullOrEmpty() && after.isNotEmpty()){ + val restOfLinks = links.entries.drop(1) + var restOfPlainText = plainText.substringAfter(links.keys.first()) + restOfLinks.forEach { + val (key, value) = it + val segment = restOfPlainText.substringBefore(key) + append(segment) withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { - appendLink(links.keys.first(), links.values.first()) - append(after) + appendLink(key, value) } + restOfPlainText = restOfPlainText.substringAfter(key) } - else if (before == after && before == "") { - withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { - appendLink(links.keys.first(), links.values.first()) - } - } else if (before != after && after.isNullOrEmpty()) { - withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { - appendLink(before, links.values.first()) - } - } else { - append(before) - withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { - appendLink(links.keys.first(), links.values.first()) - } - append(after) - } + append(restOfPlainText) } - ClickableText( - text = annotatedText, - onClick = { offset -> - annotatedText.onLinkClick(offset) { link -> - println("Clicked URL: $link") - // Open link in WebView. - } - } - ) } + return buildAnnotatedString { } } diff --git a/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt b/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt new file mode 100644 index 00000000..b919d59e --- /dev/null +++ b/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt @@ -0,0 +1,17 @@ +package social.androiddev.common.utils +import org.junit.Test +import kotlin.test.assertTrue + +class HtmlParserKtTest { + + @Test + fun renderHtml() { + val result = """ +

Been debating for a few days, and I think its time to open the #relay to anyone. If you're looking for a general Relay please feel free to add relay.eggy.app/inbox to your instances. More information can be found relay.eggy.app, but Its a general open relay for anybody to use. #mastoadmin Only thing I'm missing is a metrics tracking type page.. Hoping to have something soon.

+ """.renderHtml() + + assertTrue { result.spanStyles.size==4 } + assertTrue { result.paragraphStyles.isEmpty() } + assertTrue { result.length == 393 } + } +} \ No newline at end of file diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt index 5074d50d..0458fb27 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt @@ -13,8 +13,8 @@ package social.androiddev.timeline import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import social.androiddev.common.theme.DodoTheme @@ -43,7 +43,7 @@ fun TootContent( // TODO Add support for video + multiple images rendering // for now just show message from toot if (message != null) { - message.renderHtml(modifier) + Text(message.renderHtml()) VerticalSpacer() } } From 41e597f987a145061cd295deddcc72fe5e9cf548 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Wed, 21 Dec 2022 12:57:15 -0500 Subject: [PATCH 21/28] rebase --- .../RealHomeTimelineRepositoryTest.kt | 11 +-- gradle/libs.versions.toml | 1 - ui/common/build.gradle.kts | 3 + .../common/composables/UserAvatar.kt | 2 - .../androiddev/common/utils/AsyncImage.kt | 4 +- .../androiddev/common/utils/HtmlParser.kt | 83 ++++++++++--------- .../src/commonTest/kotlin/HtmlParserKtTest.kt | 21 +++-- .../signedout/landing/LandingContent.kt | 4 +- .../androiddev/timeline/TimelineCard.kt | 7 +- .../social/androiddev/timeline/TootContent.kt | 12 ++- .../timeline/navigation/TimelineViewModel.kt | 3 +- 11 files changed, 82 insertions(+), 69 deletions(-) diff --git a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt index 2e7fd269..2a476a10 100644 --- a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt +++ b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt @@ -27,25 +27,22 @@ import kotlin.test.Test import kotlin.test.assertTrue import kotlin.test.fail - -class RealHomeTimelineRepositoryTest{ +class RealHomeTimelineRepositoryTest { @Test fun sucessTest(): TestResult { - return runTest{ + return runTest { val testRepo = RealHomeTimelineRepository(fakeSuccessStore) val result = testRepo.read(FeedType.Home).first() assertTrue { result is StoreResponse.Data } assertTrue { result.requireData().first() == fakeLocalStatus } - } } @Test fun failureTest(): TestResult { - return runTest{ + return runTest { val testRepo = RealHomeTimelineRepository(fakeFailureStore) val result = testRepo.read(FeedType.Home).first() assertTrue { result is StoreResponse.Error.Message } - assertTrue { result.errorMessageOrNull() == failureResponse.message} - + assertTrue { result.errorMessageOrNull() == failureResponse.message } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 48be4b31..5f38b54e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,7 +56,6 @@ kotlinx-coroutines-javafx = { module = "org.jetbrains.kotlinx:kotlinx-coroutines org-jetbrains-kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "org-jetbrains-kotlinx-coroutines" } org-jetbrains-kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "org-jetbrains-kotlinx-serialization" } org-jetbrains-kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomic-fu" } -org-jetbrains-kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomic-fu" } org-jetbrains-kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "org-jetbrains-kotlin" } org-jetbrains-kotlin-test-common = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "org-jetbrains-kotlin" } diff --git a/ui/common/build.gradle.kts b/ui/common/build.gradle.kts index 8ce676e3..9fab6a52 100644 --- a/ui/common/build.gradle.kts +++ b/ui/common/build.gradle.kts @@ -17,6 +17,9 @@ kotlin { implementation(projects.data.network) api(libs.com.arkivanov.decompose) api(libs.com.arkivanov.decompose.extensions.compose.jetbrains) + implementation("com.alialbaali.kamel:kamel-image:0.4.0") + implementation("it.skrape:skrapeit:1.2.2") + } } } diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/composables/UserAvatar.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/composables/UserAvatar.kt index b006fc4a..55aff7f2 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/composables/UserAvatar.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/composables/UserAvatar.kt @@ -13,13 +13,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape 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.unit.dp import social.androiddev.common.theme.DodoTheme import social.androiddev.common.utils.AsyncImage -import social.androiddev.common.utils.loadImageIntoPainter @Composable fun UserAvatar( diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt index 708583fc..39aa22d8 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt @@ -25,11 +25,11 @@ fun AsyncImage( modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, - url:String + url: Any ) { KamelImage( resource = lazyPainterResource(data = url), - contentDescription =contentDescription, + contentDescription = contentDescription, modifier = modifier, alignment = alignment, contentScale = contentScale diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt index ed0b7d59..9e635bd9 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt @@ -1,3 +1,12 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.common.utils import androidx.compose.ui.graphics.Color @@ -12,66 +21,64 @@ import it.skrape.selects.eachText import it.skrape.selects.html5.a import it.skrape.selects.html5.body - /** * Takes a String reciever of html text * converts it to an annotated string of text and links */ fun String.renderHtml(): AnnotatedString { val newlineReplace = this.replace("
", "\n") - if (newlineReplace.contains("

")) { - val content = htmlDocument(newlineReplace) - val body = content.body { - findAll { - eachText - } - } - val paragraph = body - val links = try { - content.a { findAll { eachLink } } - } catch (exception: Exception) { - emptyMap() - } + if (newlineReplace.contains("

").not()) return buildAnnotatedString { } + + val content = htmlDocument(newlineReplace) - val plainText = paragraph.joinToString("\n") - if(links.isNullOrEmpty()) return buildAnnotatedString { - append(plainText) + val body = content.body { + findAll { + eachText } - return buildAnnotatedString { - //everything before first link - append(plainText.substringBefore(links.keys.first())) + } + val paragraph = body + val links = try { + content.a { findAll { eachLink } } + } catch (exception: Exception) { + emptyMap() + } + + val plainText = paragraph.joinToString("\n") + if (links.isNullOrEmpty()) return buildAnnotatedString { + append(plainText) + } + return buildAnnotatedString { + // everything before first link + append(plainText.substringBefore(links.keys.first())) + withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { + appendLink(links.keys.first(), links.values.first()) + } + val restOfLinks = links.entries.drop(1) + var restOfPlainText = plainText.substringAfter(links.keys.first()) + restOfLinks.forEach { + val (key, value) = it + val segment = restOfPlainText.substringBefore(key) + append(segment) withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { - appendLink(links.keys.first(), links.values.first()) + appendLink(key, value) } - val restOfLinks = links.entries.drop(1) - var restOfPlainText = plainText.substringAfter(links.keys.first()) - restOfLinks.forEach { - val (key, value) = it - val segment = restOfPlainText.substringBefore(key) - append(segment) - withStyle(style = SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { - appendLink(key, value) - } - restOfPlainText = restOfPlainText.substringAfter(key) - } - append(restOfPlainText) + restOfPlainText = restOfPlainText.substringAfter(key) } - + append(restOfPlainText) } - return buildAnnotatedString { } } -fun AnnotatedString.Builder.appendLink(linkText: String, linkUrl: String) { +private fun AnnotatedString.Builder.appendLink(linkText: String, linkUrl: String) { pushStringAnnotation(tag = linkUrl, annotation = linkUrl) append(linkText) pop() } -fun AnnotatedString.onLinkClick(offset: Int, onClick: (String) -> Unit) { +private fun AnnotatedString.onLinkClick(offset: Int, onClick: (String) -> Unit) { getStringAnnotations(start = offset, end = offset).firstOrNull()?.let { onClick(it.item) } -} \ No newline at end of file +} diff --git a/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt b/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt index b919d59e..8556977d 100644 --- a/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt +++ b/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt @@ -1,17 +1,26 @@ +/* + * This file is part of Dodo. + * + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + */ package social.androiddev.common.utils import org.junit.Test import kotlin.test.assertTrue class HtmlParserKtTest { - @Test + @Test fun renderHtml() { - val result = """ + val result = """

Been debating for a few days, and I think its time to open the #relay to anyone. If you're looking for a general Relay please feel free to add relay.eggy.app/inbox to your instances. More information can be found relay.eggy.app, but Its a general open relay for anybody to use. #mastoadmin Only thing I'm missing is a metrics tracking type page.. Hoping to have something soon.

""".renderHtml() - assertTrue { result.spanStyles.size==4 } - assertTrue { result.paragraphStyles.isEmpty() } - assertTrue { result.length == 393 } + assertTrue { result.spanStyles.size == 4 } + assertTrue { result.paragraphStyles.isEmpty() } + assertTrue { result.length == 393 } } -} \ No newline at end of file +} diff --git a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/landing/LandingContent.kt b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/landing/LandingContent.kt index 9c703bc6..a66466ef 100644 --- a/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/landing/LandingContent.kt +++ b/ui/signed-out/src/commonMain/kotlin/social/androiddev/signedout/landing/LandingContent.kt @@ -17,6 +17,7 @@ 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.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight @@ -75,8 +76,7 @@ fun LandingContent( contentDescription = "App Logo", modifier = Modifier .padding(horizontal = 48.dp) - .width(240.dp) - .height(240.dp) + .size(240.dp) .clip(CircleShape), contentScale = ContentScale.Crop, ) diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineCard.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineCard.kt index 5e71b122..e24a8630 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineCard.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineCard.kt @@ -27,6 +27,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.text.AnnotatedString import androidx.compose.ui.unit.dp import social.androiddev.common.composables.UserAvatar import social.androiddev.common.theme.DodoTheme @@ -66,7 +67,7 @@ fun TimelineCard( date: String, username: String, userAddress: String, - toot: String?, + toot: AnnotatedString?, videoUrl: String?, images: List, modifier: Modifier = Modifier, @@ -114,7 +115,7 @@ data class FeedItemState( val date: String, val username: String, val acctAddress: String, - val message: String?, + val message: AnnotatedString?, val videoUrl: String?, val images: List, ) @@ -125,7 +126,7 @@ val dummyFeedItem = FeedItemState( date = "1d", username = "Benjamin Stürmer", acctAddress = "@bino@mastodon.cloud", - message = "\uD83D\uDC4BHello #AndroidDev", + message = AnnotatedString("\uD83D\uDC4BHello #AndroidDev"), videoUrl = null, images = emptyList(), ) diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt index c8b8ce26..cb1c354a 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt @@ -18,7 +18,7 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.AnnotatedString import social.androiddev.common.theme.DodoTheme @Composable @@ -26,7 +26,7 @@ fun TootContent( modifier: Modifier, username: String, userAddress: String, - message: String?, + message: AnnotatedString?, date: String, videoUrl: String?, images: List, @@ -46,9 +46,7 @@ fun TootContent( if (message != null) { Text( modifier = Modifier.fillMaxWidth(), - text = buildAnnotatedString { - append(message) - }, + text = message, style = MaterialTheme.typography.caption ) VerticalSpacer() @@ -67,7 +65,7 @@ private fun PreviewTootContentLight() { .wrapContentHeight(), username = "@Omid", userAddress = "@omid@androiddev.social", - message = "\uD83D\uDC4BHello #AndroidDev", + message = AnnotatedString("\uD83D\uDC4BHello #AndroidDev"), date = "1d", images = emptyList(), videoUrl = null @@ -87,7 +85,7 @@ private fun PreviewTootContentDark() { .wrapContentHeight(), username = "@Omid", userAddress = "@omid@androiddev.social", - message = "\uD83D\uDC4BHello #AndroidDev", + message = AnnotatedString("\uD83D\uDC4BHello #AndroidDev"), date = "1d", images = emptyList(), videoUrl = null diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt index 369a07a9..41bd0a52 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import org.mobilenativefoundation.store.store5.ResponseOrigin import org.mobilenativefoundation.store.store5.StoreResponse +import social.androiddev.common.utils.renderHtml import social.androiddev.domain.timeline.FeedType import social.androiddev.domain.timeline.HomeTimelineRepository import social.androiddev.domain.timeline.model.StatusLocal @@ -51,7 +52,7 @@ class TimelineViewModel( date = it.createdAt, username = it.userName, acctAddress = it.accountAddress, - message = it.content, + message = it.content.renderHtml(), images = emptyList(), videoUrl = null, ) From f9a53d84ad93c9a76d1796d5011c187cda083198 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Wed, 21 Dec 2022 12:58:35 -0500 Subject: [PATCH 22/28] spotless --- .../kotlin/social/androiddev/common/utils/HtmlParser.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt index 9e635bd9..4b264141 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt @@ -70,7 +70,6 @@ fun String.renderHtml(): AnnotatedString { } } - private fun AnnotatedString.Builder.appendLink(linkText: String, linkUrl: String) { pushStringAnnotation(tag = linkUrl, annotation = linkUrl) append(linkText) From 41b5549bd2cd31edecc23d55b23c98d5b677dfc0 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Wed, 21 Dec 2022 18:06:46 -0500 Subject: [PATCH 23/28] add test dependency --- ui/common/build.gradle.kts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ui/common/build.gradle.kts b/ui/common/build.gradle.kts index 9fab6a52..171ae516 100644 --- a/ui/common/build.gradle.kts +++ b/ui/common/build.gradle.kts @@ -22,5 +22,18 @@ kotlin { } } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.org.jetbrains.kotlin.test.common) + implementation(libs.org.jetbrains.kotlin.test.annotations.common) + } + } + val androidTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.org.jetbrains.kotlin.test.junit) + } + } } } From a865d1437f0c5a62e9ee481e76bbe349390f2bcf Mon Sep 17 00:00:00 2001 From: Mike Nakhimovich Date: Fri, 23 Dec 2022 08:25:57 -0500 Subject: [PATCH 24/28] Update data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt Co-authored-by: Omid Ghenatnevi --- .../repository/timeline/RealHomeTimelineRepositoryTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt index 2a476a10..b034d978 100644 --- a/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt +++ b/data/repository/src/commonTest/kotlin/social/androiddev/common/repository/timeline/RealHomeTimelineRepositoryTest.kt @@ -28,7 +28,7 @@ import kotlin.test.assertTrue import kotlin.test.fail class RealHomeTimelineRepositoryTest { - @Test fun sucessTest(): TestResult { + @Test fun successTest(): TestResult { return runTest { val testRepo = RealHomeTimelineRepository(fakeSuccessStore) val result = testRepo.read(FeedType.Home).first() From 3c9a5f440f28a6f64124a186ff5c29bb30ea6ddd Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Fri, 23 Dec 2022 08:29:23 -0500 Subject: [PATCH 25/28] pr comments --- ui/common/build.gradle.kts | 2 +- .../kotlin/social/androiddev/common/utils/HtmlParser.kt | 2 +- ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt | 2 +- ui/signed-in/build.gradle.kts | 1 - ui/timeline/build.gradle.kts | 1 - .../androiddev/timeline/navigation/TimelineViewModel.kt | 5 ++--- 6 files changed, 5 insertions(+), 8 deletions(-) diff --git a/ui/common/build.gradle.kts b/ui/common/build.gradle.kts index 171ae516..6261bbb3 100644 --- a/ui/common/build.gradle.kts +++ b/ui/common/build.gradle.kts @@ -6,7 +6,7 @@ plugins { android { namespace = "social.androiddev.common" packagingOptions { - exclude ("META-INF/DEPENDENCIES") + exclude("META-INF/DEPENDENCIES") } } diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt index 4b264141..ec8b7b21 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt @@ -25,7 +25,7 @@ import it.skrape.selects.html5.body * Takes a String reciever of html text * converts it to an annotated string of text and links */ -fun String.renderHtml(): AnnotatedString { +fun String.extractContentFromMicroFormat(): AnnotatedString { val newlineReplace = this.replace("
", "\n") if (newlineReplace.contains("

").not()) return buildAnnotatedString { } diff --git a/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt b/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt index 8556977d..356c74e3 100644 --- a/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt +++ b/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt @@ -17,7 +17,7 @@ class HtmlParserKtTest { fun renderHtml() { val result = """

Been debating for a few days, and I think its time to open the #relay to anyone. If you're looking for a general Relay please feel free to add relay.eggy.app/inbox to your instances. More information can be found relay.eggy.app, but Its a general open relay for anybody to use. #mastoadmin Only thing I'm missing is a metrics tracking type page.. Hoping to have something soon.

- """.renderHtml() + """.extractContentFromMicroFormat() assertTrue { result.spanStyles.size == 4 } assertTrue { result.paragraphStyles.isEmpty() } diff --git a/ui/signed-in/build.gradle.kts b/ui/signed-in/build.gradle.kts index 85830bb4..437daa5e 100644 --- a/ui/signed-in/build.gradle.kts +++ b/ui/signed-in/build.gradle.kts @@ -20,7 +20,6 @@ kotlin { implementation(projects.data.persistence) implementation(projects.data.repository) implementation(projects.domain.timeline) - implementation(libs.io.insert.koin.core) } } diff --git a/ui/timeline/build.gradle.kts b/ui/timeline/build.gradle.kts index cf4ad193..f2439a8a 100644 --- a/ui/timeline/build.gradle.kts +++ b/ui/timeline/build.gradle.kts @@ -13,7 +13,6 @@ kotlin { implementation(projects.domain.timeline) implementation(projects.ui.common) implementation(libs.io.insert.koin.core) - implementation(libs.io.insert.koin.core) } } diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt index 41bd0a52..03b7ba07 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/navigation/TimelineViewModel.kt @@ -16,12 +16,11 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import org.mobilenativefoundation.store.store5.ResponseOrigin import org.mobilenativefoundation.store.store5.StoreResponse -import social.androiddev.common.utils.renderHtml +import social.androiddev.common.utils.extractContentFromMicroFormat import social.androiddev.domain.timeline.FeedType import social.androiddev.domain.timeline.HomeTimelineRepository import social.androiddev.domain.timeline.model.StatusLocal @@ -52,7 +51,7 @@ class TimelineViewModel( date = it.createdAt, username = it.userName, acctAddress = it.accountAddress, - message = it.content.renderHtml(), + message = it.content.extractContentFromMicroFormat(), images = emptyList(), videoUrl = null, ) From 2e582d5ecc0064914a1ba94794286d9f48d14c65 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Fri, 23 Dec 2022 08:51:13 -0500 Subject: [PATCH 26/28] rebase on detekt changes --- .../kotlin/social/androiddev/common/utils/HtmlParser.kt | 9 ++++++--- ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt | 9 ++++++--- .../kotlin/social/androiddev/timeline/TootContent.kt | 1 - 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt index ec8b7b21..bf4b0515 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt @@ -1,11 +1,14 @@ /* * This file is part of Dodo. * - * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * - * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + * You should have received a copy of the GNU General Public License along with Dodo. + * If not, see . */ package social.androiddev.common.utils diff --git a/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt b/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt index 356c74e3..2ee68b60 100644 --- a/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt +++ b/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt @@ -1,11 +1,14 @@ /* * This file is part of Dodo. * - * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * Dodo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. * - * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * Dodo is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * - * You should have received a copy of the GNU General Public License along with Dodo. If not, see . + * You should have received a copy of the GNU General Public License along with Dodo. + * If not, see . */ package social.androiddev.common.utils import org.junit.Test diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt index 4d5829f4..22ab9fe9 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt @@ -22,7 +22,6 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.buildAnnotatedString import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import social.androiddev.common.theme.DodoTheme From e96e7b62cef279041b885b2008af93721cb9c7e9 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Fri, 23 Dec 2022 09:08:51 -0500 Subject: [PATCH 27/28] detekt fixes --- .../androiddev/common/utils/AsyncImage.kt | 4 ++-- .../androiddev/common/utils/HtmlParser.kt | 19 ++++++++++++------- .../common/utils}/HtmlParserKtTest.kt | 0 ui/signed-in/build.gradle.kts | 1 + .../androiddev/timeline/TimelineCard.kt | 1 + 5 files changed, 16 insertions(+), 9 deletions(-) rename ui/common/src/commonTest/kotlin/{ => social/androiddev/common/utils}/HtmlParserKtTest.kt (100%) diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt index 3a84433e..2793b7a3 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt @@ -25,10 +25,10 @@ import io.kamel.image.lazyPainterResource @Composable fun AsyncImage( contentDescription: String, + url: Any, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, - contentScale: ContentScale = ContentScale.Fit, - url: Any + contentScale: ContentScale = ContentScale.Fit ) { KamelImage( resource = lazyPainterResource(data = url), diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt index bf4b0515..eed22760 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt @@ -28,6 +28,7 @@ import it.skrape.selects.html5.body * Takes a String reciever of html text * converts it to an annotated string of text and links */ +@Suppress("ReturnCount") fun String.extractContentFromMicroFormat(): AnnotatedString { val newlineReplace = this.replace("
", "\n") @@ -41,6 +42,8 @@ fun String.extractContentFromMicroFormat(): AnnotatedString { } } val paragraph = body + + @Suppress("SwallowedException") val links = try { content.a { findAll { eachLink } } } catch (exception: Exception) { @@ -48,8 +51,10 @@ fun String.extractContentFromMicroFormat(): AnnotatedString { } val plainText = paragraph.joinToString("\n") - if (links.isNullOrEmpty()) return buildAnnotatedString { - append(plainText) + if (links.isNullOrEmpty()) { + buildAnnotatedString { + append(plainText) + } } return buildAnnotatedString { // everything before first link @@ -79,8 +84,8 @@ private fun AnnotatedString.Builder.appendLink(linkText: String, linkUrl: String pop() } -private fun AnnotatedString.onLinkClick(offset: Int, onClick: (String) -> Unit) { - getStringAnnotations(start = offset, end = offset).firstOrNull()?.let { - onClick(it.item) - } -} +// private fun AnnotatedString.onLinkClick(offset: Int, onClick: (String) -> Unit) { +// getStringAnnotations(start = offset, end = offset).firstOrNull()?.let { +// onClick(it.item) +// } +// } diff --git a/ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt b/ui/common/src/commonTest/kotlin/social/androiddev/common/utils/HtmlParserKtTest.kt similarity index 100% rename from ui/common/src/commonTest/kotlin/HtmlParserKtTest.kt rename to ui/common/src/commonTest/kotlin/social/androiddev/common/utils/HtmlParserKtTest.kt diff --git a/ui/signed-in/build.gradle.kts b/ui/signed-in/build.gradle.kts index 437daa5e..de0450b1 100644 --- a/ui/signed-in/build.gradle.kts +++ b/ui/signed-in/build.gradle.kts @@ -20,6 +20,7 @@ kotlin { implementation(projects.data.persistence) implementation(projects.data.repository) implementation(projects.domain.timeline) + implementation(libs.kotlinx.collections.immutable) } } diff --git a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineCard.kt b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineCard.kt index bf108749..05021188 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineCard.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineCard.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import social.androiddev.common.composables.UserAvatar import social.androiddev.common.theme.DodoTheme From 47f69621a82623bab571b6eadd4b12bfdd84a0f5 Mon Sep 17 00:00:00 2001 From: mnakhimovich Date: Mon, 16 Jan 2023 17:22:45 -0500 Subject: [PATCH 28/28] rebase --- gradle.properties | 6 +++++- .../kotlin/social/androiddev/common/utils/AsyncImage.kt | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index d4b3dc48..8c73b356 100644 --- a/gradle.properties +++ b/gradle.properties @@ -40,4 +40,8 @@ android.suppressUnsupportedCompileSdk=33 kotlin.mpp.stability.nowarn=true # suppress warning about not building iOS targets on linux -kotlin.native.ignoreDisabledTargets=true \ No newline at end of file +kotlin.native.ignoreDisabledTargets=true + +# to get around OOM on github actions +org.gradle.daemon=false + diff --git a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt index 3d1cd4f6..2793b7a3 100644 --- a/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/AsyncImage.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.layout.ContentScale import io.kamel.image.KamelImage import io.kamel.image.lazyPainterResource - /** * Use this helper until we switch to a image loading library which supports multiplatform */