diff --git a/app-android/build.gradle.kts b/app-android/build.gradle.kts index c60b6e8e..3b895fbe 100644 --- a/app-android/build.gradle.kts +++ b/app-android/build.gradle.kts @@ -8,7 +8,12 @@ plugins { android { namespace = "social.androiddev.dodo" + packagingOptions { + exclude ("META-INF/DEPENDENCIES") + exclude ("META-INF/NOTICE") + exclude ("mozilla/public-suffix-list.txt") + } defaultConfig { versionCode = 1 versionName = "1.0" 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 d924bd04..269fbb09 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 @@ -31,8 +31,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() @@ -41,8 +40,7 @@ class RealHomeTimelineRepositoryTest { } } - @Test - fun failureTest(): TestResult { + @Test fun failureTest(): TestResult { return runTest { val testRepo = RealHomeTimelineRepository(fakeFailureStore) val result = testRepo.read(FeedType.Home).first() @@ -51,7 +49,6 @@ class RealHomeTimelineRepositoryTest { } } } - val fakeSuccessStore = object : Store> { override suspend fun clear(key: FeedType) { TODO("Not yet implemented") 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/build.gradle.kts b/ui/common/build.gradle.kts index fa096807..9290c27c 100644 --- a/ui/common/build.gradle.kts +++ b/ui/common/build.gradle.kts @@ -5,6 +5,9 @@ plugins { android { namespace = "social.androiddev.common" + packagingOptions { + exclude("META-INF/DEPENDENCIES") + } } kotlin { @@ -15,7 +18,23 @@ kotlin { implementation(projects.logging) api(libs.com.arkivanov.decompose) api(libs.com.arkivanov.decompose.extensions.compose.jetbrains) - api(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.collections.immutable) + implementation("com.alialbaali.kamel:kamel-image:0.4.0") + implementation("it.skrape:skrapeit:1.2.2") + + } + } + 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) } } } 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 082de6d3..b281fbe6 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 @@ -12,16 +12,15 @@ */ 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 -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( @@ -29,10 +28,9 @@ fun UserAvatar( modifier: Modifier = Modifier ) { 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 ae395d5d..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 @@ -12,59 +12,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.logging.DodoLogger -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, - load: suspend () -> T, - painterFor: @Composable (T) -> Painter, + url: Any, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, - contentScale: ContentScale = ContentScale.Fit, + contentScale: ContentScale = ContentScale.Fit ) { - val image: T? by produceState(null) { - value = withContext(Dispatchers.IO) { - runCatchingIgnoreCancelled { - load() - }.fold( - onSuccess = { it }, - onFailure = { t -> - DodoLogger.w(throwable = t) { - "Error when loading image." - } - 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..eed22760 --- /dev/null +++ b/ui/common/src/commonMain/kotlin/social/androiddev/common/utils/HtmlParser.kt @@ -0,0 +1,91 @@ +/* + * 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 +import androidx.compose.ui.text.AnnotatedString +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 it.skrape.core.htmlDocument +import it.skrape.selects.eachLink +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 + */ +@Suppress("ReturnCount") +fun String.extractContentFromMicroFormat(): AnnotatedString { + val newlineReplace = this.replace("
", "\n") + + if (newlineReplace.contains("

").not()) return buildAnnotatedString { } + + val content = htmlDocument(newlineReplace) + + val body = content.body { + findAll { + eachText + } + } + val paragraph = body + + @Suppress("SwallowedException") + val links = try { + content.a { findAll { eachLink } } + } catch (exception: Exception) { + emptyMap() + } + + val plainText = paragraph.joinToString("\n") + if (links.isNullOrEmpty()) { + 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(key, value) + } + restOfPlainText = restOfPlainText.substringAfter(key) + } + append(restOfPlainText) + } +} + +private fun AnnotatedString.Builder.appendLink(linkText: String, linkUrl: String) { + pushStringAnnotation(tag = linkUrl, annotation = linkUrl) + append(linkText) + pop() +} + +// 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/social/androiddev/common/utils/HtmlParserKtTest.kt b/ui/common/src/commonTest/kotlin/social/androiddev/common/utils/HtmlParserKtTest.kt new file mode 100644 index 00000000..2ee68b60 --- /dev/null +++ b/ui/common/src/commonTest/kotlin/social/androiddev/common/utils/HtmlParserKtTest.kt @@ -0,0 +1,29 @@ +/* + * 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 + 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.

+ """.extractContentFromMicroFormat() + + assertTrue { result.spanStyles.size == 4 } + assertTrue { result.paragraphStyles.isEmpty() } + assertTrue { result.length == 393 } + } +} diff --git a/ui/signed-in/build.gradle.kts b/ui/signed-in/build.gradle.kts index ed393165..de0450b1 100644 --- a/ui/signed-in/build.gradle.kts +++ b/ui/signed-in/build.gradle.kts @@ -17,6 +17,10 @@ kotlin { implementation(projects.data.repository) implementation(projects.domain.timeline) implementation(libs.io.insert.koin.core) + implementation(projects.data.persistence) + implementation(projects.data.repository) + implementation(projects.domain.timeline) + implementation(libs.kotlinx.collections.immutable) } } 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 d08e63c3..84b4cae6 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 @@ -21,6 +21,7 @@ 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 @@ -28,7 +29,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 @@ -41,7 +41,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] @@ -52,12 +51,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, ) @@ -76,8 +75,7 @@ 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) diff --git a/ui/timeline/build.gradle.kts b/ui/timeline/build.gradle.kts index f2439a8a..b9f12108 100644 --- a/ui/timeline/build.gradle.kts +++ b/ui/timeline/build.gradle.kts @@ -13,6 +13,7 @@ kotlin { implementation(projects.domain.timeline) implementation(projects.ui.common) implementation(libs.io.insert.koin.core) + 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 52dff3b0..05021188 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineCard.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TimelineCard.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -71,7 +72,7 @@ fun TimelineCard( date: String, username: String, userAddress: String, - toot: String?, + toot: AnnotatedString?, videoUrl: String?, images: ImmutableList, modifier: Modifier = Modifier, @@ -119,7 +120,7 @@ data class FeedItemState( val date: String, val username: String, val acctAddress: String, - val message: String?, + val message: AnnotatedString?, val videoUrl: String?, val images: ImmutableList, ) @@ -130,7 +131,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 = persistentListOf(), ) 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 321d84f6..22ab9fe9 100644 --- a/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt +++ b/ui/timeline/src/commonMain/kotlin/social/androiddev/timeline/TootContent.kt @@ -21,7 +21,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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import social.androiddev.common.theme.DodoTheme @@ -30,7 +30,7 @@ import social.androiddev.common.theme.DodoTheme fun TootContent( username: String, userAddress: String, - message: String?, + message: AnnotatedString?, date: String, videoUrl: String?, images: ImmutableList, @@ -51,9 +51,7 @@ fun TootContent( if (message != null) { Text( modifier = Modifier.fillMaxWidth(), - text = buildAnnotatedString { - append(message) - }, + text = message, style = MaterialTheme.typography.caption ) VerticalSpacer() @@ -72,7 +70,7 @@ private fun PreviewTootContentLight() { .wrapContentHeight(), username = "@Omid", userAddress = "@omid@androiddev.social", - message = "\uD83D\uDC4BHello #AndroidDev", + message = AnnotatedString("\uD83D\uDC4BHello #AndroidDev"), date = "1d", images = persistentListOf(), videoUrl = null @@ -92,7 +90,7 @@ private fun PreviewTootContentDark() { .wrapContentHeight(), username = "@Omid", userAddress = "@omid@androiddev.social", - message = "\uD83D\uDC4BHello #AndroidDev", + message = AnnotatedString("\uD83D\uDC4BHello #AndroidDev"), date = "1d", images = persistentListOf(), 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 0086f312..b522d08c 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 @@ -25,6 +25,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.extractContentFromMicroFormat import social.androiddev.domain.timeline.FeedType import social.androiddev.domain.timeline.HomeTimelineRepository import social.androiddev.domain.timeline.model.StatusLocal @@ -57,7 +58,7 @@ class TimelineViewModel( date = it.createdAt, username = it.userName, acctAddress = it.accountAddress, - message = it.content, + message = it.content.extractContentFromMicroFormat(), images = persistentListOf(), videoUrl = null, )