1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-3044 Chats | Enhancement | Naive implementation for fetching and displaying attachments (#1846)

This commit is contained in:
Evgenii Kozlov 2024-11-27 23:44:23 +01:00 committed by GitHub
parent 3032c4125a
commit e0c5137036
Signed by: github
GPG key ID: B5690EEEBB952194
7 changed files with 256 additions and 81 deletions

View file

@ -17,6 +17,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -58,11 +59,13 @@ import com.anytypeio.anytype.other.DefaultDeepLinkResolver
import com.anytypeio.anytype.presentation.home.Command
import com.anytypeio.anytype.presentation.home.HomeScreenViewModel
import com.anytypeio.anytype.presentation.home.HomeScreenViewModel.Navigation
import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
import com.anytypeio.anytype.presentation.search.GlobalSearchViewModel
import com.anytypeio.anytype.presentation.spaces.SpaceIconView
import com.anytypeio.anytype.presentation.widgets.DropDownMenuAction
import com.anytypeio.anytype.presentation.widgets.WidgetView
import com.anytypeio.anytype.ui.base.navigation
import com.anytypeio.anytype.ui.editor.EditorFragment
import com.anytypeio.anytype.ui.gallery.GalleryInstallationFragment
import com.anytypeio.anytype.ui.multiplayer.RequestJoinSpaceFragment
import com.anytypeio.anytype.ui.multiplayer.ShareSpaceFragment
@ -217,6 +220,27 @@ class HomeScreenFragment : BaseComposeFragment(),
)
}
}
LaunchedEffect(Unit) {
// TODO refact navigation here. or reuse nav command from home screen view model
spaceLevelChatViewModel.navigation.collect { nav ->
when(nav) {
is OpenObjectNavigation.OpenEditor -> {
runCatching {
findNavController().navigate(
R.id.objectNavigation,
EditorFragment.args(
ctx = nav.target,
space = nav.space
)
)
}.onFailure {
Timber.w("Error while opening editor from chat.")
}
}
else -> toast("TODO")
}
}
}
} else {
PageWithWidgets()
}

View file

@ -1,18 +1,28 @@
package com.anytypeio.anytype.domain.chats
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.DVFilter
import com.anytypeio.anytype.core_models.DVFilterCondition
import com.anytypeio.anytype.core_models.Event
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.primitives.Space
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.debugging.Logger
import javax.inject.Inject
import kotlin.collections.isNotEmpty
import kotlin.collections.toList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.scan
class ChatContainer @Inject constructor(
@ -20,9 +30,55 @@ class ChatContainer @Inject constructor(
private val channel: ChatEventChannel,
private val logger: Logger
) {
private val payloads = MutableSharedFlow<List<Event.Command.Chats>>()
private val attachments = MutableSharedFlow<Set<Id>>(replay = 0)
@Deprecated("Naive implementation. Add caching logic - maybe store for wrappers")
fun fetchAttachments(space: Space) : Flow<Map<Id, ObjectWrapper.Basic>> {
return attachments
.distinctUntilChanged()
.map { ids ->
if (ids.isNotEmpty()) {
repo.searchObjects(
sorts = emptyList(),
limit = 0,
filters = buildList {
DVFilter(
relation = Relations.ID,
value = ids.toList(),
condition = DVFilterCondition.IN
)
},
keys = emptyList(),
space = space
).mapNotNull {
val wrapper = ObjectWrapper.Basic(it)
if (wrapper.isValid) wrapper else null
}
} else {
emptyList()
}
}
.distinctUntilChanged()
.map { wrappers -> wrappers.associate { it.id to it } }
}
fun watchWhileTrackingAttachments(chat: Id): Flow<List<Chat.Message>> {
return watch(chat)
.onEach { messages ->
val ids = messages
.map { msg ->
msg.attachments.map {
it.target
}
}
.flatten()
.toSet()
attachments.emit(ids)
}
}
fun watch(chat: Id): Flow<List<Chat.Message>> = flow {
val initial = repo.subscribeLastChatMessages(
command = Command.ChatCommand.SubscribeLastMessages(

View file

@ -40,6 +40,8 @@ dependencies {
implementation libs.composeMaterial
implementation libs.coilCompose
annotationProcessor libs.glideCompiler
implementation libs.glideCompose
debugImplementation libs.composeTooling

View file

@ -2,26 +2,28 @@ package com.anytypeio.anytype.feature_discussions.presentation
import com.anytypeio.anytype.core_models.Block
import com.anytypeio.anytype.core_models.Hash
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.chats.Chat
sealed interface DiscussionView {
data class Message(
val id: String,
val content: List<Content.Part>,
val content: Content,
val author: String,
val timestamp: Long,
val attachments: List<Chat.Message.Attachment> = emptyList(),
val attachments: List<Attachment> = emptyList(),
val reactions: List<Reaction> = emptyList(),
val isUserAuthor: Boolean = false,
val isEdited: Boolean = false,
val avatar: Avatar = Avatar.Initials()
) : DiscussionView {
interface Content {
data class Content(val msg: String, val parts: List<Part>) {
data class Part(
val part: String,
val styles: List<Block.Content.Text.Mark> = emptyList()
) : Content {
) {
val isBold: Boolean = styles.any { it.type == Block.Content.Text.Mark.Type.BOLD }
val isItalic: Boolean = styles.any { it.type == Block.Content.Text.Mark.Type.ITALIC }
val isStrike = styles.any { it.type == Block.Content.Text.Mark.Type.STRIKETHROUGH }
@ -30,6 +32,18 @@ sealed interface DiscussionView {
}
}
sealed class Attachment {
data class Image(
val target: Id,
val url: String
): Attachment()
data class Link(
val target: Id,
val wrapper: ObjectWrapper.Basic?
): Attachment()
}
data class Reaction(
val emoji: String,
val count: Int,

View file

@ -3,11 +3,13 @@ package com.anytypeio.anytype.feature_discussions.presentation
import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectType
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.core_models.chats.Chat
import com.anytypeio.anytype.core_models.primitives.Space
import com.anytypeio.anytype.core_ui.text.splitByMarks
import com.anytypeio.anytype.core_utils.ext.withLatestFrom
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.domain.base.fold
import com.anytypeio.anytype.domain.base.onFailure
@ -25,12 +27,12 @@ import com.anytypeio.anytype.domain.`object`.OpenObject
import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.presentation.common.BaseViewModel
import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
import com.anytypeio.anytype.presentation.home.navigation
import com.anytypeio.anytype.presentation.search.GlobalSearchItemView
import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
@ -105,10 +107,9 @@ class DiscussionViewModel @Inject constructor(
chat: Id
) {
chatContainer
.watch(chat = chat)
.onEach { Timber.d("Got new update: $it") }
.collect {
messages.value = it.map { msg ->
.watchWhileTrackingAttachments(chat = chat)
.withLatestFrom(chatContainer.fetchAttachments(vmParams.space)) { result, dependencies ->
result.map { msg ->
val member = members.get().let { type ->
when (type) {
is Store.Data -> type.members.find { member ->
@ -123,15 +124,18 @@ class DiscussionViewModel @Inject constructor(
DiscussionView.Message(
id = msg.id,
timestamp = msg.createdAt * 1000,
content = content?.text
.orEmpty()
.splitByMarks(marks = content?.marks.orEmpty())
.map { (part, styles) ->
DiscussionView.Message.Content.Part(
part = part,
styles = styles
)
},
content = DiscussionView.Message.Content(
msg = content?.text.orEmpty(),
parts = content?.text
.orEmpty()
.splitByMarks(marks = content?.marks.orEmpty())
.map { (part, styles) ->
DiscussionView.Message.Content.Part(
part = part,
styles = styles
)
}
),
author = member?.name ?: msg.creator.takeLast(5),
isUserAuthor = msg.creator == account,
isEdited = msg.modifiedAt > msg.createdAt,
@ -142,7 +146,32 @@ class DiscussionViewModel @Inject constructor(
isSelected = ids.contains(account)
)
},
attachments = msg.attachments,
attachments = msg.attachments.map { attachment ->
when(attachment.type) {
Chat.Message.Attachment.Type.Image -> DiscussionView.Message.Attachment.Image(
target = attachment.target,
url = urlBuilder.medium(path = attachment.target)
)
else -> {
val wrapper = dependencies[attachment.target]
if (wrapper?.layout == ObjectType.Layout.IMAGE) {
DiscussionView.Message.Attachment.Image(
target = attachment.target,
url = urlBuilder.large(path = attachment.target)
)
} else {
DiscussionView.Message.Attachment.Link(
target = attachment.target,
wrapper = wrapper
)
}
}
}
}.also {
if (it.isNotEmpty()) {
Timber.d("Chat attachments: $it")
}
},
avatar = if (member != null && !member.iconImage.isNullOrEmpty()) {
DiscussionView.Message.Avatar.Image(
urlBuilder.thumbnail(member.iconImage!!)
@ -153,6 +182,9 @@ class DiscussionViewModel @Inject constructor(
)
}.reversed()
}
.collect { result ->
messages.value = result
}
}
fun onMessageSent(msg: String) {
@ -274,15 +306,22 @@ class DiscussionViewModel @Inject constructor(
}
}
fun onAttachmentClicked(attachment: Chat.Message.Attachment) {
fun onAttachmentClicked(attachment: DiscussionView.Message.Attachment) {
Timber.d("onAttachmentClicked")
viewModelScope.launch {
// TODO naive implementation. Currently used for debugging.
navigation.emit(
OpenObjectNavigation.OpenEditor(
target = attachment.target,
space = vmParams.space.id
)
)
when(attachment) {
is DiscussionView.Message.Attachment.Image -> {
// Do nothing.
}
is DiscussionView.Message.Attachment.Link -> {
val wrapper = attachment.wrapper
if (wrapper != null) {
navigation.emit(wrapper.navigation())
} else {
Timber.w("Wrapper is not found in attachment")
}
}
}
}
}

View file

@ -19,9 +19,12 @@ fun DiscussionPreview() {
messages = listOf(
DiscussionView.Message(
id = "1",
content = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
content = DiscussionView.Message.Content(
msg = stringResource(id = R.string.default_text_placeholder),
parts = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
)
),
author = "Walter",
@ -29,9 +32,12 @@ fun DiscussionPreview() {
),
DiscussionView.Message(
id = "2",
content = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
content = DiscussionView.Message.Content(
msg = stringResource(id = R.string.default_text_placeholder),
parts = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
)
),
author = "Leo",
@ -39,9 +45,12 @@ fun DiscussionPreview() {
),
DiscussionView.Message(
id = "3",
content = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
content = DiscussionView.Message.Content(
msg = stringResource(id = R.string.default_text_placeholder),
parts = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
)
),
author = "Gilbert",
@ -74,9 +83,12 @@ fun DiscussionScreenPreview() {
add(
DiscussionView.Message(
id = idx.toString(),
content = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
content = DiscussionView.Message.Content(
msg = stringResource(id = R.string.default_text_placeholder),
parts = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
)
),
author = "User ${idx.inc()}",
@ -116,9 +128,12 @@ fun DiscussionScreenPreview() {
fun BubblePreview() {
Bubble(
name = "Leo Marx",
msg = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
content = DiscussionView.Message.Content(
msg = stringResource(id = R.string.default_text_placeholder),
parts = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
)
),
timestamp = System.currentTimeMillis(),
@ -137,9 +152,12 @@ fun BubblePreview() {
fun BubbleEditedPreview() {
Bubble(
name = "Leo Marx",
msg = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
content = DiscussionView.Message.Content(
msg = stringResource(id = R.string.default_text_placeholder),
parts = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
)
),
isEdited = true,
@ -159,9 +177,12 @@ fun BubbleEditedPreview() {
fun BubbleWithAttachmentPreview() {
Bubble(
name = "Leo Marx",
msg = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
content = DiscussionView.Message.Content(
msg = stringResource(id = R.string.default_text_placeholder),
parts = listOf(
DiscussionView.Message.Content.Part(
part = stringResource(id = R.string.default_text_placeholder)
)
)
),
timestamp = System.currentTimeMillis(),
@ -170,9 +191,9 @@ fun BubbleWithAttachmentPreview() {
onCopyMessage = {},
attachments = buildList {
add(
Chat.Message.Attachment(
target = "Walter Benjamin",
type = Chat.Message.Attachment.Type.Image
DiscussionView.Message.Attachment.Link(
target = "ID",
wrapper = null
)
)
},

View file

@ -93,6 +93,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.chats.Chat
@ -120,6 +121,8 @@ import com.anytypeio.anytype.feature_discussions.presentation.DiscussionViewMode
import com.anytypeio.anytype.feature_discussions.presentation.DiscussionViewModel.UXCommand
import com.anytypeio.anytype.presentation.objects.ObjectIcon
import com.anytypeio.anytype.presentation.search.GlobalSearchItemView
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import kotlinx.coroutines.launch
@ -166,9 +169,7 @@ fun DiscussionScreenWrapper(
lazyListState = lazyListState,
onReacted = vm::onReacted,
onCopyMessage = { msg ->
clipboard.setText(
AnnotatedString(text = msg.content.joinToString())
)
clipboard.setText(AnnotatedString(text = msg.content.msg))
},
onDeleteMessage = vm::onDeleteMessage,
onEditMessage = vm::onRequestEditMessageClicked,
@ -225,7 +226,7 @@ fun DiscussionScreen(
onDeleteMessage: (DiscussionView.Message) -> Unit,
onCopyMessage: (DiscussionView.Message) -> Unit,
onEditMessage: (DiscussionView.Message) -> Unit,
onAttachmentClicked: (Chat.Message.Attachment) -> Unit,
onAttachmentClicked: (DiscussionView.Message.Attachment) -> Unit,
onExitEditMessageMode: () -> Unit,
onMarkupLinkClicked: (String) -> Unit,
onAttachObjectClicked: () -> Unit,
@ -277,8 +278,8 @@ fun DiscussionScreen(
onEditMessage = { msg ->
onEditMessage(msg).also {
textState = TextFieldValue(
msg.content.joinToString(),
selection = TextRange(msg.content.joinToString().length)
msg.content.msg,
selection = TextRange(msg.content.msg.length)
)
chatBoxFocusRequester.requestFocus()
}
@ -521,7 +522,7 @@ private fun ChatBox(
) {
attachments.forEach {
Box {
Attachment(
AttachedObject(
modifier = Modifier.padding(
top = 12.dp,
start = 16.dp,
@ -826,7 +827,7 @@ fun Messages(
onReacted: (Id, String) -> Unit,
onDeleteMessage: (DiscussionView.Message) -> Unit,
onCopyMessage: (DiscussionView.Message) -> Unit,
onAttachmentClicked: (Chat.Message.Attachment) -> Unit,
onAttachmentClicked: (DiscussionView.Message.Attachment) -> Unit,
onEditMessage: (DiscussionView.Message) -> Unit,
onMarkupLinkClicked: (String) -> Unit
) {
@ -859,7 +860,7 @@ fun Messages(
Bubble(
modifier = Modifier.weight(1.0f),
name = msg.author,
msg = msg.content,
content = msg.content,
timestamp = msg.timestamp,
attachments = msg.attachments,
isUserAuthor = msg.isUserAuthor,
@ -992,13 +993,14 @@ private fun ChatUserAvatar(
val defaultBubbleColor = Color(0x99FFFFFF)
val userMessageBubbleColor = Color(0x66000000)
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun Bubble(
modifier: Modifier = Modifier,
name: String,
msg: List<DiscussionView.Message.Content.Part>,
content: DiscussionView.Message.Content,
timestamp: Long,
attachments: List<Chat.Message.Attachment> = emptyList(),
attachments: List<DiscussionView.Message.Attachment> = emptyList(),
isUserAuthor: Boolean = false,
isEdited: Boolean = false,
reactions: List<DiscussionView.Message.Reaction> = emptyList(),
@ -1006,7 +1008,7 @@ fun Bubble(
onDeleteMessage: () -> Unit,
onCopyMessage: () -> Unit,
onEditMessage: () -> Unit,
onAttachmentClicked: (Chat.Message.Attachment) -> Unit,
onAttachmentClicked: (DiscussionView.Message.Attachment) -> Unit,
onMarkupLinkClicked: (String) -> Unit
) {
var showDropdownMenu by remember { mutableStateOf(false) }
@ -1062,7 +1064,7 @@ fun Bubble(
bottom = 0.dp
),
text = buildAnnotatedString {
msg.forEach { part ->
content.parts.forEach { part ->
if (part.link != null && part.link.param != null) {
withLink(
LinkAnnotation.Clickable(
@ -1119,19 +1121,33 @@ fun Bubble(
colorResource(id = R.color.text_primary),
)
attachments.forEach { attachment ->
Attachment(
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
top = 8.dp
),
title = attachment.target,
type = attachment.type.toString(),
icon = ObjectIcon.None,
onAttachmentClicked = {
onAttachmentClicked(attachment)
when(attachment) {
is DiscussionView.Message.Attachment.Image -> {
GlideImage(
model = attachment.url,
contentDescription = "Attachment image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(8.dp)
.clip(shape = RoundedCornerShape(16.dp))
)
}
)
is DiscussionView.Message.Attachment.Link -> {
AttachedObject(
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
top = 8.dp
),
title = attachment.wrapper?.name.orEmpty(),
type = attachment.wrapper?.type?.firstOrNull().orEmpty(),
icon = ObjectIcon.None,
onAttachmentClicked = {
onAttachmentClicked(attachment)
}
)
}
}
}
if (reactions.isNotEmpty()) {
ReactionList(
@ -1298,7 +1314,7 @@ fun TopDiscussionToolbar(
}
@Composable
fun Attachment(
fun AttachedObject(
modifier: Modifier,
title: String,
type: String,
@ -1426,7 +1442,7 @@ fun ReactionList(
color = if (reaction.isSelected)
colorResource(id = R.color.palette_very_light_orange)
else
colorResource(id = R.color.background_highlighted),
colorResource(id = R.color.shape_transparent_primary),
shape = RoundedCornerShape(100.dp)
)
.clip(RoundedCornerShape(100.dp))
@ -1465,7 +1481,10 @@ fun ReactionList(
.padding(
end = 12.dp
),
color = colorResource(id = R.color.text_primary)
color = if (reaction.isSelected)
colorResource(id = R.color.text_primary)
else
colorResource(id = R.color.text_white)
)
}
}
@ -1524,7 +1543,7 @@ fun TopDiscussionToolbarPreview() {
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Dark Mode")
@Composable
fun AttachmentPreview() {
Attachment(
AttachedObject(
modifier = Modifier,
icon = ObjectIcon.None,
type = "Project",