diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 690e24463..0dbe5bc9d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -40,6 +40,7 @@ Grant Hide content Show results + Insert inline imagee Insert link Insert list Logout @@ -192,6 +193,7 @@ Gallery Help me choose an instance highest score + Alternate text Always On demand In WiFi diff --git a/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/resources/SharedStrings.kt b/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/resources/SharedStrings.kt index 27669b2c4..230de40f0 100644 --- a/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/resources/SharedStrings.kt +++ b/composeApp/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/resources/SharedStrings.kt @@ -49,6 +49,7 @@ import raccoonforfriendica.composeapp.generated.resources.action_go_back import raccoonforfriendica.composeapp.generated.resources.action_grant_permission import raccoonforfriendica.composeapp.generated.resources.action_hide_content import raccoonforfriendica.composeapp.generated.resources.action_hide_results +import raccoonforfriendica.composeapp.generated.resources.action_insert_inline_image import raccoonforfriendica.composeapp.generated.resources.action_insert_link import raccoonforfriendica.composeapp.generated.resources.action_insert_list import raccoonforfriendica.composeapp.generated.resources.action_logout @@ -209,6 +210,7 @@ import raccoonforfriendica.composeapp.generated.resources.gallery_title import raccoonforfriendica.composeapp.generated.resources.hashtag_people_using import raccoonforfriendica.composeapp.generated.resources.help_me_choose_an_instance import raccoonforfriendica.composeapp.generated.resources.highest_score +import raccoonforfriendica.composeapp.generated.resources.image_field_alt_text import raccoonforfriendica.composeapp.generated.resources.image_loading_mode_always import raccoonforfriendica.composeapp.generated.resources.image_loading_mode_on_demand import raccoonforfriendica.composeapp.generated.resources.image_loading_mode_on_wi_fi @@ -539,6 +541,8 @@ class SharedStrings : Strings { @Composable get() = stringResource(Res.string.action_hide_content) override val actionHideResults: String @Composable get() = stringResource(Res.string.action_hide_results) + override val actionInsertInlineImage: String + @Composable get() = stringResource(Res.string.action_insert_inline_image) override val actionInsertLink: String @Composable get() = stringResource(Res.string.action_insert_link) override val actionInsertList: String @@ -845,6 +849,8 @@ class SharedStrings : Strings { @Composable get() = stringResource(Res.string.help_me_choose_an_instance) override val highestScore: String @Composable get() = stringResource(Res.string.highest_score) + override val imageFieldAltText: String + @Composable get() = stringResource(Res.string.image_field_alt_text) override val imageLoadingModeAlways: String @Composable get() = stringResource(Res.string.image_loading_mode_always) override val imageLoadingModeOnDemand: String diff --git a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/CardTimelineItem.kt b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/CardTimelineItem.kt index 18eba8c17..68d74223c 100644 --- a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/CardTimelineItem.kt +++ b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/CardTimelineItem.kt @@ -42,7 +42,6 @@ import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.Custom import com.livefast.eattrash.raccoonforfriendica.domain.content.data.MediaType import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.UserModel -import com.livefast.eattrash.raccoonforfriendica.domain.content.data.attachmentsToDisplay import com.livefast.eattrash.raccoonforfriendica.domain.content.data.contentToDisplay import com.livefast.eattrash.raccoonforfriendica.domain.content.data.pollToDisplay import com.livefast.eattrash.raccoonforfriendica.domain.content.data.spoilerToDisplay @@ -249,8 +248,7 @@ internal fun CardTimelineItem( } // attachments - val attachments = - entry.attachmentsToDisplay.filter { it.url !in entry.embeddedImageUrls } + val attachments = entry.attachmentsToDisplayWithoutInlineImages val visualAttachments = attachments.filter { it.type == MediaType.Image || it.type == MediaType.Video } val audioAttachments = attachments.filter { it.type == MediaType.Audio } diff --git a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/CompactTimelineItem.kt b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/CompactTimelineItem.kt index cce4495bc..756f5662b 100644 --- a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/CompactTimelineItem.kt +++ b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/CompactTimelineItem.kt @@ -37,7 +37,6 @@ import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.Custom import com.livefast.eattrash.raccoonforfriendica.domain.content.data.MediaType import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.UserModel -import com.livefast.eattrash.raccoonforfriendica.domain.content.data.attachmentsToDisplay import com.livefast.eattrash.raccoonforfriendica.domain.content.data.contentToDisplay import com.livefast.eattrash.raccoonforfriendica.domain.content.data.pollToDisplay import com.livefast.eattrash.raccoonforfriendica.domain.content.data.spoilerToDisplay @@ -190,8 +189,7 @@ internal fun CompactTimelineItem( modifier = Modifier.padding(horizontal = contentHorizontalPadding), visible = spoilerActive || spoiler.isEmpty(), ) { - val attachments = - entry.attachmentsToDisplay.filter { it.url !in entry.embeddedImageUrls } + val attachments = entry.attachmentsToDisplayWithoutInlineImages Column( verticalArrangement = Arrangement.spacedBy(Spacing.xs), ) { diff --git a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/ContentBody.kt b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/ContentBody.kt index a32b5a5f3..a38dcbcc3 100644 --- a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/ContentBody.kt +++ b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/ContentBody.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -12,13 +13,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import com.livefast.eattrash.raccoonforfriendica.core.appearance.theme.CornerSize +import com.livefast.eattrash.raccoonforfriendica.core.appearance.theme.Spacing import com.livefast.eattrash.raccoonforfriendica.core.appearance.theme.ancillaryTextAlpha import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.CustomImage import com.livefast.eattrash.raccoonforfriendica.core.htmlparse.parseHtml import com.livefast.eattrash.raccoonforfriendica.domain.content.data.EmojiModel // lazy wildcard matcher after element name, optional closing "/" -internal val IMAGE_REGEX = Regex("") +internal val IMAGE_REGEX = Regex("(img.*?/?)|()") @Composable fun ContentBody( @@ -42,6 +44,7 @@ fun ContentBody( modifier = Modifier .fillMaxSize() + .padding(vertical = Spacing.s) .clip(RoundedCornerShape(CornerSize.xl)) .clickable { onOpenImage?.invoke(data.url) @@ -92,7 +95,13 @@ private fun String.splitTextAndImages(): List = var index = 0 for (match in matches) { val range = match.range - add(original.substring(index, range.first).trim()) + val chunkBefore = + original + .substring(index, range.first) + .trim() + // remove empty paragraph at the end + .replace(Regex("\$"), "") + add(chunkBefore) val htmlImage = original.substring(range) val data = extractImageData(htmlImage) if (data != null && data.description?.looksLikeAnEmoji != true) { @@ -101,6 +110,7 @@ private fun String.splitTextAndImages(): List = index = range.last + 1 } if (index < original.length) { - add(original.substring(index, original.length).trim()) + val chunkAfter = original.substring(index, original.length).trim() + add(chunkAfter) } } diff --git a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/EditAttachmentDescriptionDialog.kt b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/EditAttachmentDescriptionDialog.kt new file mode 100644 index 000000000..44cb3d91e --- /dev/null +++ b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/EditAttachmentDescriptionDialog.kt @@ -0,0 +1,115 @@ +package com.livefast.eattrash.raccoonforfriendica.core.commonui.content + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.livefast.eattrash.raccoonforfriendica.core.appearance.theme.CornerSize +import com.livefast.eattrash.raccoonforfriendica.core.appearance.theme.Spacing +import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.CustomImage +import com.livefast.eattrash.raccoonforfriendica.core.l10n.LocalStrings +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.AttachmentModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditAttachmentDescriptionDialog( + attachment: AttachmentModel, + onClose: ((String?) -> Unit)? = null, +) { + var textFieldValue by remember { + mutableStateOf(TextFieldValue(text = attachment.description.orEmpty())) + } + + BasicAlertDialog( + modifier = Modifier.clip(RoundedCornerShape(CornerSize.xxl)), + onDismissRequest = { + onClose?.invoke(null) + }, + ) { + Column( + modifier = + Modifier + .background(color = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)) + .padding(Spacing.m), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + Text( + text = LocalStrings.current.pictureDescriptionPlaceholder, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(modifier = Modifier.height(Spacing.s)) + + val url = attachment.url + if (url.isNotEmpty()) { + CustomImage( + modifier = Modifier.height(200.dp), + url = url, + contentScale = ContentScale.Crop, + ) + } + + TextField( + modifier = Modifier.fillMaxWidth(), + maxLines = 4, + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ), + label = { + Text( + text = LocalStrings.current.imageFieldAltText, + style = MaterialTheme.typography.bodyMedium, + ) + }, + textStyle = MaterialTheme.typography.bodyMedium, + value = textFieldValue, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Text, + ), + onValueChange = { value -> + textFieldValue = value + }, + ) + + Spacer(modifier = Modifier.height(Spacing.xs)) + Button( + onClick = { + onClose?.invoke(textFieldValue.text) + }, + ) { + Text(text = LocalStrings.current.buttonConfirm) + } + } + } +} diff --git a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/FullTimelineItem.kt b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/FullTimelineItem.kt index 442bfda4c..1cccf450d 100644 --- a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/FullTimelineItem.kt +++ b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/FullTimelineItem.kt @@ -36,7 +36,6 @@ import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.Custom import com.livefast.eattrash.raccoonforfriendica.domain.content.data.MediaType import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.UserModel -import com.livefast.eattrash.raccoonforfriendica.domain.content.data.attachmentsToDisplay import com.livefast.eattrash.raccoonforfriendica.domain.content.data.cardToDisplay import com.livefast.eattrash.raccoonforfriendica.domain.content.data.contentToDisplay import com.livefast.eattrash.raccoonforfriendica.domain.content.data.pollToDisplay @@ -232,8 +231,7 @@ internal fun FullTimelineItem( } // attachments - val attachments = - entry.attachmentsToDisplay.filter { it.url !in entry.embeddedImageUrls } + val attachments = entry.attachmentsToDisplayWithoutInlineImages val visualAttachments = attachments.filter { it.type == MediaType.Image || it.type == MediaType.Video } val audioAttachments = attachments.filter { it.type == MediaType.Audio } diff --git a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/ImageData.kt b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/ImageData.kt index 3d56acea6..c23651255 100644 --- a/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/ImageData.kt +++ b/core/commonui/content/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/commonui/content/ImageData.kt @@ -1,6 +1,8 @@ package com.livefast.eattrash.raccoonforfriendica.core.commonui.content +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.AttachmentModel import com.livefast.eattrash.raccoonforfriendica.domain.content.data.TimelineEntryModel +import com.livefast.eattrash.raccoonforfriendica.domain.content.data.attachmentsToDisplay import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlHandler import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlParser @@ -33,11 +35,12 @@ internal fun extractImageData(html: String): ImageData? { return url?.let { ImageData(url = it, description = description) } } -internal val TimelineEntryModel.embeddedImageUrls: List - get() { - val data = extractImagesData(content) - return data.map { it.url } - } +internal val TimelineEntryModel.attachmentsToDisplayWithoutInlineImages: List + get() = + run { + val inlineImagesData = extractImagesData(content) + attachmentsToDisplay.filter { attachment -> inlineImagesData.none { it.description == attachment.description } } + } private fun extractImagesData(html: String): List { var url: String? = null diff --git a/core/htmlparse/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/htmlparse/ParseHtml.kt b/core/htmlparse/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/htmlparse/ParseHtml.kt index b3c2ba962..a4e391141 100644 --- a/core/htmlparse/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/htmlparse/ParseHtml.kt +++ b/core/htmlparse/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/htmlparse/ParseHtml.kt @@ -30,8 +30,7 @@ fun String.parseHtml( "p" -> if (builder.length != 0) { // separate paragraphs with a blank line - builder.appendLine() - builder.appendLine() + builder.appendLine().appendLine() } "span" -> Unit diff --git a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/Strings.kt b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/Strings.kt index cb4866efb..f4a1b2ac1 100644 --- a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/Strings.kt +++ b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/Strings.kt @@ -43,6 +43,7 @@ interface Strings { val actionGrantPermission: String @Composable get val actionHideContent: String @Composable get val actionHideResults: String @Composable get + val actionInsertInlineImage: String @Composable get val actionInsertLink: String @Composable get val actionInsertList: String @Composable get val actionLogout: String @Composable get @@ -196,6 +197,7 @@ interface Strings { val galleryTitle: String @Composable get val helpMeChooseAnInstance: String @Composable get val highestScore: String @Composable get + val imageFieldAltText: String @Composable get val imageLoadingModeAlways: String @Composable get val imageLoadingModeOnDemand: String @Composable get val imageLoadingModeOnWiFi: String @Composable get diff --git a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/testutils/MockStrings.kt b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/testutils/MockStrings.kt index 782e6f07d..9b5f5a58a 100644 --- a/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/testutils/MockStrings.kt +++ b/core/l10n/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/core/l10n/testutils/MockStrings.kt @@ -93,6 +93,8 @@ class MockStrings : Strings { @Composable get() = retrieve("actionHideContent") override val actionHideResults: String @Composable get() = retrieve("actionHideResults") + override val actionInsertInlineImage: String + @Composable get() = retrieve("actionInsertInlineImage") override val actionInsertLink: String @Composable get() = retrieve("actionInsertLink") override val actionInsertList: String @@ -399,6 +401,8 @@ class MockStrings : Strings { @Composable get() = retrieve("helpMeChooseAnInstance") override val highestScore: String @Composable get() = retrieve("highestScore") + override val imageFieldAltText: String + @Composable get() = retrieve("imageFieldAltText") override val imageLoadingModeAlways: String @Composable get() = retrieve("imageLoadingModeAlways") override val imageLoadingModeOnDemand: String diff --git a/domain/content/data/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/data/NodeFeatures.kt b/domain/content/data/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/data/NodeFeatures.kt index 92708db18..9640d0b5e 100644 --- a/domain/content/data/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/data/NodeFeatures.kt +++ b/domain/content/data/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/data/NodeFeatures.kt @@ -14,4 +14,5 @@ data class NodeFeatures( val supportsAnnouncements: Boolean = false, val supportsDislike: Boolean = false, val supportsTranslation: Boolean = false, + val supportsInlineImages: Boolean = false, ) diff --git a/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/DefaultSupportedFeatureRepository.kt b/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/DefaultSupportedFeatureRepository.kt index 756f47aea..9d5dcb52c 100644 --- a/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/DefaultSupportedFeatureRepository.kt +++ b/domain/content/repository/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/domain/content/repository/DefaultSupportedFeatureRepository.kt @@ -27,6 +27,7 @@ internal class DefaultSupportedFeatureRepository( supportsAnnouncements = info.isMastodon, supportsDislike = info.isFriendica, supportsTranslation = info.isMastodon, + supportsInlineImages = info.isFriendica, ) } } diff --git a/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerMviModel.kt b/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerMviModel.kt index b906c7fd3..98a6835b5 100644 --- a/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerMviModel.kt +++ b/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerMviModel.kt @@ -80,6 +80,21 @@ interface ComposerMviModel : override fun hashCode(): Int = byteArray.contentHashCode() } + data class AddInlineImageStep1( + val byteArray: ByteArray, + ) : Intent { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as AddAttachment + + return byteArray.contentEquals(other.byteArray) + } + + override fun hashCode(): Int = byteArray.contentHashCode() + } + data class AddAttachmentsFromGallery( val attachments: List, ) : Intent @@ -89,6 +104,11 @@ interface ComposerMviModel : val description: String, ) : Intent + data class AddInlineImageStep2( + val attachment: AttachmentModel, + val description: String, + ) : Intent + data class RemoveAttachment( val attachment: AttachmentModel, ) : Intent @@ -246,6 +266,7 @@ interface ComposerMviModel : val shouldShowHashtagSuggestions: Boolean = false, val hashtagSuggestionsLoading: Boolean = false, val hashtagSuggestions: List = emptyList(), + val inlineImagesSupported: Boolean = false, ) sealed interface Effect { @@ -273,7 +294,15 @@ interface ComposerMviModel : data class OpenPreview( val entry: TimelineEntryModel, - ) : ValidationError + ) : Effect + + data class TriggerAttachmentEdit( + val attachment: AttachmentModel, + ) : Effect + + data class TriggerInlineImageEdit( + val attachment: AttachmentModel, + ) : Effect } } diff --git a/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerScreen.kt b/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerScreen.kt index 42d585cc9..191cae0c2 100644 --- a/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerScreen.kt +++ b/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerScreen.kt @@ -65,9 +65,9 @@ import com.livefast.eattrash.raccoonforfriendica.core.appearance.theme.toWindowI import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.CustomDropDown import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.CustomModalBottomSheet import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.CustomModalBottomSheetItem -import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.EditTextualInfoDialog import com.livefast.eattrash.raccoonforfriendica.core.commonui.components.ProgressHud import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.CustomConfirmDialog +import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.EditAttachmentDescriptionDialog import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.InsertEmojiBottomSheet import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.OptionId import com.livefast.eattrash.raccoonforfriendica.core.commonui.content.SettingsSwitchRow @@ -130,19 +130,29 @@ class ComposerScreen( val pastScheduleDateError = LocalStrings.current.messageScheduleDateInThePast val invalidPollError = LocalStrings.current.messageInvalidPollError val genericError = LocalStrings.current.messageGenericError - var openImagePicker by remember { mutableStateOf(false) } - if (openImagePicker) { + var imagePickerRequest by remember { mutableStateOf(null) } + if (imagePickerRequest != null) { galleryHelper.getImageFromGallery { bytes -> - openImagePicker = false + val originalRequest = imagePickerRequest + imagePickerRequest = null if (bytes.isNotEmpty()) { - model.reduce(ComposerMviModel.Intent.AddAttachment(bytes)) + when (originalRequest) { + ImagePickerRequest.Attachment -> + model.reduce(ComposerMviModel.Intent.AddAttachment(bytes)) + + ImagePickerRequest.InlineImage -> + model.reduce(ComposerMviModel.Intent.AddInlineImageStep1(bytes)) + + else -> Unit + } } } } var photoGalleryPickerOpen by remember { mutableStateOf(false) } var linkDialogOpen by remember { mutableStateOf(false) } var selectCircleDialogOpen by remember { mutableStateOf(false) } - var attachmentWithDescriptionBeingEdited by remember { mutableStateOf(null) } + var attachmentBeingEdited by remember { mutableStateOf(null) } + var inlineImageBeingEdited by remember { mutableStateOf(null) } var hasSpoilerFieldFocus by remember { mutableStateOf(false) } var hasTitleFocus by remember { mutableStateOf(false) } val isBeingEdited = remember { scheduledPostId != null || editedPostId != null } @@ -236,6 +246,11 @@ class ComposerScreen( ComposerMviModel.Effect.Success -> navigationCoordinator.pop() is ComposerMviModel.Effect.OpenPreview -> previewEntry = event.entry + is ComposerMviModel.Effect.TriggerAttachmentEdit -> + attachmentBeingEdited = event.attachment + + is ComposerMviModel.Effect.TriggerInlineImageEdit -> + inlineImageBeingEdited = event.attachment } }.launchIn(this) } @@ -366,13 +381,6 @@ class ComposerScreen( ) } - if (uiState.galleryFeatureSupported && uiState.poll == null) { - this += - CustomOptions.SelectFromGallery.toOption( - label = LocalStrings.current.actionAddImageFromGallery, - ) - } - if (uiState.pollFeatureSupported && uiState.attachments.isEmpty()) { this += CustomOptions.TogglePoll.toOption( @@ -463,22 +471,6 @@ class ComposerScreen( ), ) - CustomOptions.SelectAttachment -> { - val limit = - uiState.attachmentLimit ?: Int.MAX_VALUE - if (uiState.attachments.size < limit) { - openImagePicker = true - } - } - - CustomOptions.SelectFromGallery -> { - val limit = - uiState.attachmentLimit ?: Int.MAX_VALUE - if (uiState.attachments.size < limit) { - photoGalleryPickerOpen = true - } - } - CustomOptions.TogglePoll -> if (uiState.poll == null) { model.reduce(ComposerMviModel.Intent.AddPoll) @@ -559,18 +551,22 @@ class ComposerScreen( } UtilsBar( modifier = Modifier.fillMaxWidth(), - onAttachmentClicked = { - val limit = uiState.attachmentLimit ?: Int.MAX_VALUE - if (uiState.attachments.size < limit) { - openImagePicker = true - } - }, supportsRichEditing = uiState.supportsRichEditing, + supportsInlineImages = uiState.inlineImagesSupported, hasPoll = uiState.poll != null, publicationType = uiState.publicationType, onLinkClicked = { linkDialogOpen = true }, + onAttachmentClicked = { + val limit = uiState.attachmentLimit ?: Int.MAX_VALUE + if (uiState.attachments.size < limit) { + imagePickerRequest = ImagePickerRequest.Attachment + } + }, + onInlineImageClicked = { + imagePickerRequest = ImagePickerRequest.InlineImage + }, onBoldClicked = { model.reduce( ComposerMviModel.Intent.AddBoldFormat( @@ -827,7 +823,7 @@ class ComposerScreen( model.reduce(ComposerMviModel.Intent.RemoveAttachment(attachment)) }, onEditDescription = { attachment -> - attachmentWithDescriptionBeingEdited = attachment + attachmentBeingEdited = attachment }, ) } @@ -883,22 +879,36 @@ class ComposerScreen( ) } - if (attachmentWithDescriptionBeingEdited != null) { - EditTextualInfoDialog( - label = LocalStrings.current.pictureDescriptionPlaceholder, - value = attachmentWithDescriptionBeingEdited?.description.orEmpty(), - minLines = 3, + val editedAttachment = attachmentBeingEdited + val editedInlineImage = inlineImageBeingEdited + if (editedAttachment != null) { + EditAttachmentDescriptionDialog( + attachment = editedAttachment, onClose = { newValue -> - val attachment = attachmentWithDescriptionBeingEdited - if (attachment != null && newValue != null) { + if (newValue != null) { model.reduce( ComposerMviModel.Intent.EditAttachmentDescription( - attachment = attachment, + attachment = editedAttachment, description = newValue, ), ) } - attachmentWithDescriptionBeingEdited = null + attachmentBeingEdited = null + }, + ) + } else if (editedInlineImage != null) { + EditAttachmentDescriptionDialog( + attachment = editedInlineImage, + onClose = { newValue -> + if (newValue != null) { + model.reduce( + ComposerMviModel.Intent.AddInlineImageStep2( + attachment = editedInlineImage, + description = newValue, + ), + ) + } + inlineImageBeingEdited = null }, ) } @@ -1104,10 +1114,6 @@ class ComposerScreen( } private sealed interface CustomOptions : OptionId.Custom { - data object SelectAttachment : CustomOptions - - data object SelectFromGallery : CustomOptions - data object TogglePoll : CustomOptions data object ToggleTitle : CustomOptions @@ -1130,3 +1136,9 @@ private sealed interface CustomOptions : OptionId.Custom { data object ChangeMarkupMode : CustomOptions } + +private sealed interface ImagePickerRequest { + data object Attachment : ImagePickerRequest + + data object InlineImage : ImagePickerRequest +} diff --git a/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerViewModel.kt b/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerViewModel.kt index 3db17e299..7e46ad5f1 100644 --- a/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerViewModel.kt +++ b/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/ComposerViewModel.kt @@ -128,6 +128,7 @@ class ComposerViewModel( titleFeatureSupported = features.supportsEntryTitles, galleryFeatureSupported = features.supportsPhotoGallery, pollFeatureSupported = features.supportsPolls, + inlineImagesSupported = features.supportsInlineImages, availableVisibilities = buildList { this += Visibility.Public @@ -275,7 +276,11 @@ class ComposerViewModel( } } - is ComposerMviModel.Intent.AddAttachment -> uploadAttachment(intent.byteArray) + is ComposerMviModel.Intent.AddAttachment -> + uploadAttachment( + byteArray = intent.byteArray, + isInlineImage = false, + ) is ComposerMviModel.Intent.EditAttachmentDescription -> updateAttachmentDescription(intent.attachment, intent.description) @@ -509,6 +514,17 @@ class ComposerViewModel( ) is ComposerMviModel.Intent.ChangeMarkupMode -> changeMarkupMode(intent.mode) + is ComposerMviModel.Intent.AddInlineImageStep1 -> + uploadAttachment( + byteArray = intent.byteArray, + isInlineImage = true, + ) + + is ComposerMviModel.Intent.AddInlineImageStep2 -> + insertInlineImage( + intent.attachment, + intent.description, + ) } } @@ -1209,18 +1225,25 @@ class ComposerViewModel( } } - private fun uploadAttachment(byteArray: ByteArray) { + private fun uploadAttachment( + byteArray: ByteArray, + isInlineImage: Boolean, + ) { screenModelScope.launch { - updateState { - it.copy( - attachments = - it.attachments + - AttachmentModel( - id = PLACEHOLDER_ID, - url = "", - loading = true, - ), - ) + if (isInlineImage) { + updateState { it.copy(loading = true) } + } else { + updateState { + it.copy( + attachments = + it.attachments + + AttachmentModel( + id = PLACEHOLDER_ID, + url = "", + loading = true, + ), + ) + } } val attachment = if (shouldUserPhotoRepository) { @@ -1229,12 +1252,18 @@ class ComposerViewModel( mediaRepository.create(byteArray) } if (attachment != null) { - newAttachmentIds += attachment.id - updateState { - it.copy( - attachments = it.attachments.filter { a -> a.id != PLACEHOLDER_ID } + attachment, - hasUnsavedChanges = true, - ) + if (isInlineImage) { + updateState { it.copy(loading = false) } + emitEffect(ComposerMviModel.Effect.TriggerInlineImageEdit(attachment)) + } else { + newAttachmentIds += attachment.id + updateState { + it.copy( + attachments = it.attachments.filter { a -> a.id != PLACEHOLDER_ID } + attachment, + hasUnsavedChanges = true, + ) + } + emitEffect(ComposerMviModel.Effect.TriggerAttachmentEdit(attachment)) } } else { emitEffect(ComposerMviModel.Effect.Failure()) @@ -1584,6 +1613,38 @@ class ComposerViewModel( } } + private fun insertInlineImage( + attachment: AttachmentModel, + description: String, + ) { + screenModelScope.launch { + val primaryUrl = attachment.url + val secondaryUrl = attachment.thumbnail ?: attachment.previewUrl ?: primaryUrl + val additionalPart = + buildString { + append("\n") + append("[url=$primaryUrl]") + append("[img=$secondaryUrl]") + append(description) + append("[/img]") + append("[/url]") + append("\n") + } + val newValue = + getNewTextFieldValue( + value = uiState.value.bodyValue, + additionalPart = additionalPart, + offsetAfter = additionalPart.length, + ) + updateState { + it.copy( + bodyValue = newValue, + hasUnsavedChanges = true, + ) + } + } + } + private fun submit( enableAltTextCheck: Boolean, enableParentVisibilityCheck: Boolean, diff --git a/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/components/UtilsBar.kt b/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/components/UtilsBar.kt index 8c14a40e5..830d39805 100644 --- a/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/components/UtilsBar.kt +++ b/feature/composer/src/commonMain/kotlin/com/livefast/eattrash/raccoonforfriendica/feature/composer/components/UtilsBar.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ScheduleSend import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.FormatBold import androidx.compose.material.icons.filled.FormatItalic @@ -31,6 +32,7 @@ internal fun UtilsBar( modifier: Modifier = Modifier, hasPoll: Boolean = false, supportsRichEditing: Boolean = true, + supportsInlineImages: Boolean = false, publicationType: PublicationType, onLinkClicked: (() -> Unit)? = null, onAttachmentClicked: (() -> Unit)? = null, @@ -40,6 +42,7 @@ internal fun UtilsBar( onStrikethroughClicked: (() -> Unit)? = null, onCodeClicked: (() -> Unit)? = null, onSubmitClicked: (() -> Unit)? = null, + onInlineImageClicked: (() -> Unit)? = null, ) { Row( modifier = modifier.padding(horizontal = Spacing.xxs), @@ -63,6 +66,20 @@ internal fun UtilsBar( ) } + if (supportsInlineImages) { + IconButton( + onClick = { + onInlineImageClicked?.invoke() + }, + ) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = LocalStrings.current.actionInsertInlineImage, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (supportsRichEditing) { // insert link button IconButton(