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

DROID-3217 Space-level chat | Enhancement | Screen with message reaction and corresponding members (#1970)

This commit is contained in:
Evgenii Kozlov 2025-01-06 12:47:28 +01:00 committed by GitHub
parent 9be32dff13
commit 1bfaf2085e
Signed by: github
GPG key ID: B5690EEEBB952194
16 changed files with 829 additions and 212 deletions

View file

@ -49,8 +49,9 @@ import com.anytypeio.anytype.di.feature.ViewerFilterModule
import com.anytypeio.anytype.di.feature.ViewerSortModule
import com.anytypeio.anytype.di.feature.auth.DaggerDeletedAccountComponent
import com.anytypeio.anytype.di.feature.cover.UnsplashModule
import com.anytypeio.anytype.di.feature.discussions.DaggerChatReactionPickerComponent
import com.anytypeio.anytype.di.feature.discussions.DaggerChatReactionComponent
import com.anytypeio.anytype.di.feature.discussions.DaggerDiscussionComponent
import com.anytypeio.anytype.di.feature.discussions.DaggerSelectChatReactionComponent
import com.anytypeio.anytype.di.feature.discussions.DaggerSpaceLevelChatComponent
import com.anytypeio.anytype.di.feature.gallery.DaggerGalleryInstallationComponent
import com.anytypeio.anytype.di.feature.home.DaggerHomeScreenComponent
@ -105,6 +106,7 @@ import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModel
import com.anytypeio.anytype.feature_date.viewmodel.DateObjectVmParams
import com.anytypeio.anytype.feature_discussions.presentation.ChatReactionViewModel
import com.anytypeio.anytype.feature_discussions.presentation.DiscussionViewModel
import com.anytypeio.anytype.feature_discussions.presentation.SelectChatReactionViewModel
import com.anytypeio.anytype.gallery_experience.viewmodel.GalleryInstallationViewModel
import com.anytypeio.anytype.presentation.editor.EditorViewModel
import com.anytypeio.anytype.presentation.history.VersionHistoryViewModel
@ -1081,8 +1083,16 @@ class ComponentManager(
.build()
}
val chatReactionPickerComponent = ComponentMapWithParam { params: ChatReactionViewModel.Params ->
DaggerChatReactionPickerComponent
val selectChatReactionComponent = ComponentMapWithParam { params: SelectChatReactionViewModel.Params ->
DaggerSelectChatReactionComponent
.builder()
.withDependencies(findComponentDependencies())
.withParams(params)
.build()
}
val chatReactionComponent = ComponentMapWithParam { params: ChatReactionViewModel.Params ->
DaggerChatReactionComponent
.builder()
.withDependencies(findComponentDependencies())
.withParams(params)

View file

@ -2,50 +2,40 @@ package com.anytypeio.anytype.di.feature.discussions
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.core_utils.di.scope.PerScreen
import com.anytypeio.anytype.data.auth.repo.block.BlockRemote
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.domain.auth.repo.AuthRepository
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.config.UserSettingsRepository
import com.anytypeio.anytype.emojifier.data.Emoji
import com.anytypeio.anytype.emojifier.data.EmojiProvider
import com.anytypeio.anytype.emojifier.suggest.EmojiSuggester
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionContainer
import com.anytypeio.anytype.feature_discussions.presentation.ChatReactionViewModel
import com.anytypeio.anytype.ui.chats.ChatReactionFragment
import dagger.Binds
import dagger.BindsInstance
import dagger.Component
import dagger.Module
import dagger.Provides
@Component(
dependencies = [ChatReactionPickerDependencies::class],
dependencies = [ChatReactionDependencies::class],
modules = [
ChatReactionPickerModule::class,
ChatReactionPickerModule.Declarations::class
ChatReactionModule::class,
ChatReactionModule.Declarations::class
]
)
@PerScreen
interface ChatReactionPickerComponent {
interface ChatReactionComponent {
@Component.Builder
interface Builder {
@BindsInstance
fun withParams(params: ChatReactionViewModel.Params): Builder
fun withDependencies(dependencies: ChatReactionPickerDependencies): Builder
fun build(): ChatReactionPickerComponent
fun withDependencies(dependencies: ChatReactionDependencies): Builder
fun build(): ChatReactionComponent
}
fun getViewModel(): ChatReactionViewModel
fun inject(fragment: ChatReactionFragment)
}
@Module
object ChatReactionPickerModule {
@Provides
@PerScreen
@JvmStatic
fun provideEmojiProvider(): EmojiProvider = Emoji
object ChatReactionModule {
@Module
interface Declarations {
@ -57,10 +47,9 @@ object ChatReactionPickerModule {
}
}
interface ChatReactionPickerDependencies : ComponentDependencies {
interface ChatReactionDependencies : ComponentDependencies {
fun dispatchers(): AppCoroutineDispatchers
fun suggester(): EmojiSuggester
fun repo(): BlockRepository
fun auth(): AuthRepository
fun prefs(): UserSettingsRepository
fun urlBuilder(): UrlBuilder
fun members(): ActiveSpaceMemberSubscriptionContainer
}

View file

@ -84,9 +84,10 @@ class DiscussionFragment : BaseComposeFragment() {
onRequestOpenFullScreenImage = {
// TODO
},
onChatReaction = {
onSelectChatReaction = {
// TODO
}
},
onViewChatReaction = { a, b -> }
)
if (showBottomSheet) {

View file

@ -0,0 +1,65 @@
package com.anytypeio.anytype.di.feature.discussions
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.core_utils.di.scope.PerScreen
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.domain.auth.repo.AuthRepository
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.config.UserSettingsRepository
import com.anytypeio.anytype.emojifier.data.Emoji
import com.anytypeio.anytype.emojifier.data.EmojiProvider
import com.anytypeio.anytype.emojifier.suggest.EmojiSuggester
import com.anytypeio.anytype.feature_discussions.presentation.SelectChatReactionViewModel
import com.anytypeio.anytype.ui.chats.SelectChatReactionFragment
import dagger.Binds
import dagger.BindsInstance
import dagger.Component
import dagger.Module
import dagger.Provides
@Component(
dependencies = [SelectChatReactionDependencies::class],
modules = [
SelectChatReactionModule::class,
SelectChatReactionModule.Declarations::class
]
)
@PerScreen
interface SelectChatReactionComponent {
@Component.Builder
interface Builder {
@BindsInstance
fun withParams(params: SelectChatReactionViewModel.Params): Builder
fun withDependencies(dependencies: SelectChatReactionDependencies): Builder
fun build(): SelectChatReactionComponent
}
fun getViewModel(): SelectChatReactionViewModel
fun inject(fragment: SelectChatReactionFragment)
}
@Module
object SelectChatReactionModule {
@Provides
@PerScreen
@JvmStatic
fun provideEmojiProvider(): EmojiProvider = Emoji
@Module
interface Declarations {
@PerScreen
@Binds
fun bindViewModelFactory(
factory: SelectChatReactionViewModel.Factory
): ViewModelProvider.Factory
}
}
interface SelectChatReactionDependencies : ComponentDependencies {
fun dispatchers(): AppCoroutineDispatchers
fun suggester(): EmojiSuggester
fun repo(): BlockRepository
fun auth(): AuthRepository
fun prefs(): UserSettingsRepository
}

View file

@ -20,7 +20,8 @@ import com.anytypeio.anytype.di.feature.ObjectTypeChangeSubComponent
import com.anytypeio.anytype.di.feature.PersonalizationSettingsSubComponent
import com.anytypeio.anytype.di.feature.SplashDependencies
import com.anytypeio.anytype.di.feature.auth.DeletedAccountDependencies
import com.anytypeio.anytype.di.feature.discussions.ChatReactionPickerDependencies
import com.anytypeio.anytype.di.feature.discussions.ChatReactionDependencies
import com.anytypeio.anytype.di.feature.discussions.SelectChatReactionDependencies
import com.anytypeio.anytype.di.feature.discussions.DiscussionComponentDependencies
import com.anytypeio.anytype.di.feature.gallery.GalleryInstallationComponentDependencies
import com.anytypeio.anytype.di.feature.home.HomeScreenDependencies
@ -137,7 +138,8 @@ interface MainComponent :
LinkToObjectDependencies,
MoveToDependencies,
DateObjectDependencies,
ChatReactionPickerDependencies
SelectChatReactionDependencies,
ChatReactionDependencies
{
fun inject(app: AndroidApplication)
@ -391,6 +393,11 @@ abstract class ComponentDependenciesModule {
@Binds
@IntoMap
@ComponentDependenciesKey(ChatReactionPickerDependencies::class)
@ComponentDependenciesKey(SelectChatReactionDependencies::class)
abstract fun provideChatReactionPickerDependencies(component: MainComponent): ComponentDependencies
@Binds
@IntoMap
@ComponentDependenciesKey(ChatReactionDependencies::class)
abstract fun provideChatReactionDependencies(component: MainComponent): ComponentDependencies
}

View file

@ -5,7 +5,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.os.bundleOf
@ -17,7 +16,7 @@ import com.anytypeio.anytype.core_utils.ext.arg
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.feature_discussions.presentation.ChatReactionViewModel
import com.anytypeio.anytype.feature_discussions.ui.ChatReactionPicker
import com.anytypeio.anytype.feature_discussions.ui.ChatReactionScreen
import com.anytypeio.anytype.ui.settings.typography
import javax.inject.Inject
import kotlin.getValue
@ -26,6 +25,7 @@ class ChatReactionFragment : BaseBottomSheetComposeFragment() {
private val chat: Id get() = arg<Id>(CHAT_ID_KEY)
private val msg: Id get() = arg<Id>(MSG_ID_KEY)
private val emoji: String get() = arg<String>(EMOJI_KEY)
@Inject
lateinit var factory: ChatReactionViewModel.Factory
@ -42,49 +42,51 @@ class ChatReactionFragment : BaseBottomSheetComposeFragment() {
MaterialTheme(
typography = typography
) {
ChatReactionPicker(
views = vm.views.collectAsStateWithLifecycle(emptyList()).value,
onEmojiClicked = vm::onEmojiClicked
ChatReactionScreen(
viewState = vm.viewState.collectAsStateWithLifecycle().value
)
LaunchedEffect(Unit) {
vm.isDismissed.collect { isDismissed ->
if (isDismissed) dismiss()
}
}
}
}
}
override fun injectDependencies() {
componentManager().chatReactionPickerComponent
componentManager().chatReactionComponent
.get(
key = chat,
key = getComponentKey(),
param = ChatReactionViewModel.Params(
chat = chat,
msg = msg
msg = msg,
emoji = emoji
)
)
.inject(this)
}
override fun releaseDependencies() {
// TODO
componentManager().chatReactionComponent.release(id = getComponentKey())
}
private fun getComponentKey(): String = "$COMPONENT_PREFIX-$chat"
companion object {
private const val SPACE_ID_KEY = "chat.reaction-picker.space"
private const val CHAT_ID_KEY = "chat.reaction-picker.chat"
private const val MSG_ID_KEY = "chat.reaction-picker.msg"
private const val COMPONENT_PREFIX = "chat-reaction"
private const val SPACE_ID_KEY = "chat.reaction.space"
private const val CHAT_ID_KEY = "chat.reaction.chat"
private const val MSG_ID_KEY = "chat.reaction.msg"
private const val EMOJI_KEY = "chat.reaction.emoji"
fun args(
space: SpaceId,
chat: String,
msg: String
msg: String,
emoji: String
): Bundle = bundleOf(
SPACE_ID_KEY to space.id,
CHAT_ID_KEY to chat,
MSG_ID_KEY to msg
MSG_ID_KEY to msg,
EMOJI_KEY to emoji
)
}
}

View file

@ -0,0 +1,92 @@
package com.anytypeio.anytype.ui.chats
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_utils.ext.arg
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.feature_discussions.presentation.SelectChatReactionViewModel
import com.anytypeio.anytype.feature_discussions.ui.SelectChatReactionScreen
import com.anytypeio.anytype.ui.settings.typography
import javax.inject.Inject
import kotlin.getValue
class SelectChatReactionFragment : BaseBottomSheetComposeFragment() {
private val chat: Id get() = arg<Id>(CHAT_ID_KEY)
private val msg: Id get() = arg<Id>(MSG_ID_KEY)
@Inject
lateinit var factory: SelectChatReactionViewModel.Factory
private val vm by viewModels<SelectChatReactionViewModel> { factory }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme(
typography = typography
) {
SelectChatReactionScreen(
views = vm.views.collectAsStateWithLifecycle(initialValue = emptyList()).value,
onEmojiClicked = vm::onEmojiClicked
)
LaunchedEffect(Unit) {
vm.isDismissed.collect { isDismissed ->
if (isDismissed) dismiss()
}
}
}
}
}
override fun injectDependencies() {
componentManager().selectChatReactionComponent
.get(
key = getComponentKey(),
param = SelectChatReactionViewModel.Params(
chat = chat,
msg = msg
)
)
.inject(this)
}
override fun releaseDependencies() {
componentManager().selectChatReactionComponent.release(id = getComponentKey())
}
private fun getComponentKey(): String = "$COMPONENT_PREFIX-$chat"
companion object {
private const val COMPONENT_PREFIX = "select-chat-reaction"
private const val SPACE_ID_KEY = "select-chat-reaction.space"
private const val CHAT_ID_KEY = "select-chat-reaction.chat"
private const val MSG_ID_KEY = "select-chat-reaction.msg"
fun args(
space: SpaceId,
chat: String,
msg: String
): Bundle = bundleOf(
SPACE_ID_KEY to space.id,
CHAT_ID_KEY to chat,
MSG_ID_KEY to msg
)
}
}

View file

@ -66,6 +66,7 @@ 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.chats.ChatReactionFragment
import com.anytypeio.anytype.ui.chats.SelectChatReactionFragment
import com.anytypeio.anytype.ui.editor.EditorFragment
import com.anytypeio.anytype.ui.editor.gallery.FullScreenPictureFragment
import com.anytypeio.anytype.ui.gallery.GalleryInstallationFragment
@ -197,15 +198,34 @@ class HomeScreenFragment : BaseComposeFragment(),
)
)
},
onChatReaction = {
findNavController().navigate(
R.id.chatReactionScreen,
ChatReactionFragment.args(
space = Space(space),
chat = spaceLevelChatViewModel.chat,
msg = it
onSelectChatReaction = {
runCatching {
findNavController().navigate(
R.id.selectChatReactionScreen,
SelectChatReactionFragment.args(
space = Space(space),
chat = spaceLevelChatViewModel.chat,
msg = it
)
)
)
}.onFailure {
Timber.e(it, "Error while opening chat-reaction picker")
}
},
onViewChatReaction = { msg, emoji ->
runCatching {
findNavController().navigate(
R.id.chatReactionScreen,
ChatReactionFragment.args(
space = Space(space),
chat = spaceLevelChatViewModel.chat,
msg = msg,
emoji = emoji
)
)
}.onFailure {
Timber.e(it, "Error while opening a chat reaction")
}
}
)
}

View file

@ -162,6 +162,11 @@
android:name="com.anytypeio.anytype.di.feature.discussions.DiscussionFragment"
android:label="Discussion" />
<dialog
android:id="@+id/selectChatReactionScreen"
android:name="com.anytypeio.anytype.ui.chats.SelectChatReactionFragment"
android:label="Select chat reaction" />
<dialog
android:id="@+id/chatReactionScreen"
android:name="com.anytypeio.anytype.ui.chats.ChatReactionFragment"

View file

@ -5,165 +5,135 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.onFailure
import com.anytypeio.anytype.domain.chats.ObserveRecentlyUsedChatReactions
import com.anytypeio.anytype.domain.chats.SetRecentlyUsedChatReactions
import com.anytypeio.anytype.domain.chats.ToggleChatMessageReaction
import com.anytypeio.anytype.emojifier.Emojifier
import com.anytypeio.anytype.emojifier.data.Emoji
import com.anytypeio.anytype.emojifier.data.EmojiProvider
import com.anytypeio.anytype.emojifier.suggest.EmojiSuggester
import com.anytypeio.anytype.domain.base.getOrDefault
import com.anytypeio.anytype.domain.chats.GetChatMessagesByIds
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.multiplayer.ActiveSpaceMemberSubscriptionContainer
import com.anytypeio.anytype.presentation.common.BaseViewModel
import com.anytypeio.anytype.presentation.objects.SpaceMemberIconView
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
class ChatReactionViewModel @Inject constructor(
private val vmParams: Params,
private val provider: EmojiProvider,
private val suggester: EmojiSuggester,
private val dispatchers: AppCoroutineDispatchers,
private val toggleChatMessageReaction: ToggleChatMessageReaction,
private val setRecentlyUsedChatReactions: SetRecentlyUsedChatReactions,
private val observeRecentlyUsedChatReactions: ObserveRecentlyUsedChatReactions
private val getChatMessagesByIds: GetChatMessagesByIds,
private val members: ActiveSpaceMemberSubscriptionContainer,
private val urlBuilder: UrlBuilder
) : BaseViewModel() {
val isDismissed = MutableSharedFlow<Boolean>(replay = 0)
/**
* Default emoji list, including categories.
*/
private val default = MutableStateFlow<List<ReactionPickerView>>(emptyList())
private val recentlyUsed = MutableStateFlow<List<String>>(emptyList())
val views = combine(default, recentlyUsed) { default, recentlyUsed ->
buildList<ReactionPickerView> {
if (recentlyUsed.isNotEmpty()) {
add(ReactionPickerView.RecentUsedSection)
addAll(
recentlyUsed.map { unicode ->
ReactionPickerView.Emoji(
unicode = unicode,
page = -1,
index = -1,
emojified = Emojifier.safeUri(unicode)
)
}
)
}
addAll(default)
}
}
val viewState = MutableStateFlow<ViewState>(ViewState.Init(vmParams.emoji))
init {
viewModelScope.launch {
observeRecentlyUsedChatReactions
.flow()
.collect {
recentlyUsed.value = it
}
}
viewModelScope.launch {
val loaded = loadEmojiWithCategories()
default.value = loaded
}
}
private suspend fun loadEmojiWithCategories() = withContext(dispatchers.io) {
val views = mutableListOf<ReactionPickerView>()
provider.emojis.forEachIndexed { categoryIndex, emojis ->
views.add(
ReactionPickerView.Category(
index = categoryIndex
)
)
emojis.forEachIndexed { emojiIndex, emoji ->
val skin = Emoji.COLORS.any { color -> emoji.contains(color) }
if (!skin)
views.add(
ReactionPickerView.Emoji(
unicode = emoji,
page = categoryIndex,
index = emojiIndex,
emojified = Emojifier.safeUri(emoji)
)
val result = getChatMessagesByIds
.async(
Command.ChatCommand.GetMessagesByIds(
chat = vmParams.chat,
messages = listOf(vmParams.msg)
)
}
}
views
}
fun onEmojiClicked(emoji: String) {
viewModelScope.launch {
setRecentlyUsedChatReactions.async(
params = (listOf(emoji) + recentlyUsed.value)
.toSet()
.take(MAX_RECENTLY_USED_COUNT)
.toSet()
).onFailure {
Timber.e(it, "Error while saving recently used reactions")
}
toggleChatMessageReaction.async(
params = Command.ChatCommand.ToggleMessageReaction(
msg = vmParams.msg,
chat = vmParams.chat,
emoji = emoji
)
).onFailure {
Timber.e(it, "Error while toggling chat message reaction")
val msg = result.getOrDefault(emptyList()).firstOrNull()
if (msg != null) {
val identities = msg.reactions.getOrDefault(
key = vmParams.emoji,
defaultValue = emptyList()
)
if (identities.isNotEmpty()) {
members.observe().map { store ->
when(store) {
is ActiveSpaceMemberSubscriptionContainer.Store.Data -> {
identities.mapNotNull { identity ->
val member = store.members.firstOrNull { it.identity == identity }
if (member != null) {
ViewState.Member(
icon = SpaceMemberIconView.icon(
obj = member,
urlBuilder = urlBuilder
),
name = member.name.orEmpty(),
isUser = false
)
} else {
null
}
}
}
is ActiveSpaceMemberSubscriptionContainer.Store.Empty -> {
emptyList<ViewState.Member>()
}
}
}.collect {
viewState.value = ViewState.Success(
emoji = vmParams.emoji,
members = it
)
}
} else {
viewState.value = ViewState.Empty(
emoji = vmParams.emoji
)
}
} else {
viewState.value = ViewState.Error.MessageNotFound(
emoji = vmParams.emoji
)
}
isDismissed.emit(true)
}
}
class Factory @Inject constructor(
private val params: Params,
private val emojiProvider: EmojiProvider,
private val emojiSuggester: EmojiSuggester,
private val dispatchers: AppCoroutineDispatchers,
private val toggleChatMessageReaction: ToggleChatMessageReaction,
private val setRecentlyUsedChatReactions: SetRecentlyUsedChatReactions,
private val observeRecentlyUsedChatReactions: ObserveRecentlyUsedChatReactions
private val vmParams: Params,
private val getChatMessagesByIds: GetChatMessagesByIds,
private val members: ActiveSpaceMemberSubscriptionContainer,
private val urlBuilder: UrlBuilder
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = ChatReactionViewModel(
vmParams = params,
provider = emojiProvider,
suggester = emojiSuggester,
dispatchers = dispatchers,
toggleChatMessageReaction = toggleChatMessageReaction,
setRecentlyUsedChatReactions = setRecentlyUsedChatReactions,
observeRecentlyUsedChatReactions = observeRecentlyUsedChatReactions
vmParams = vmParams,
getChatMessagesByIds = getChatMessagesByIds,
members = members,
urlBuilder = urlBuilder
) as T
}
data class Params @Inject constructor(
val chat: Id,
val msg: Id
val msg: Id,
val emoji: String
)
sealed class ReactionPickerView {
data object RecentUsedSection: ReactionPickerView()
data class Category(val index: Int) : ReactionPickerView()
data class Emoji(
val unicode: String,
val page: Int,
val index: Int,
val emojified: String = ""
) : ReactionPickerView()
}
sealed class ViewState {
abstract val emoji: String
companion object {
const val MAX_RECENTLY_USED_COUNT = 20
data class Init(
override val emoji: String
) : ViewState()
sealed class Error : ViewState() {
data class MessageNotFound(
override val emoji: String
) : Error()
}
data class Loading(
override val emoji: String
) : ViewState()
data class Empty(
override val emoji: String
): ViewState()
data class Success(
override val emoji: String,
val members: List<Member>
) : ViewState()
data class Member(
val name: String,
val icon: SpaceMemberIconView,
val isUser: Boolean
)
}
}

View file

@ -0,0 +1,169 @@
package com.anytypeio.anytype.feature_discussions.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.onFailure
import com.anytypeio.anytype.domain.chats.ObserveRecentlyUsedChatReactions
import com.anytypeio.anytype.domain.chats.SetRecentlyUsedChatReactions
import com.anytypeio.anytype.domain.chats.ToggleChatMessageReaction
import com.anytypeio.anytype.emojifier.Emojifier
import com.anytypeio.anytype.emojifier.data.Emoji
import com.anytypeio.anytype.emojifier.data.EmojiProvider
import com.anytypeio.anytype.emojifier.suggest.EmojiSuggester
import com.anytypeio.anytype.presentation.common.BaseViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
class SelectChatReactionViewModel @Inject constructor(
private val vmParams: Params,
private val provider: EmojiProvider,
private val suggester: EmojiSuggester,
private val dispatchers: AppCoroutineDispatchers,
private val toggleChatMessageReaction: ToggleChatMessageReaction,
private val setRecentlyUsedChatReactions: SetRecentlyUsedChatReactions,
private val observeRecentlyUsedChatReactions: ObserveRecentlyUsedChatReactions
) : BaseViewModel() {
val isDismissed = MutableSharedFlow<Boolean>(replay = 0)
/**
* Default emoji list, including categories.
*/
private val default = MutableStateFlow<List<ReactionPickerView>>(emptyList())
private val recentlyUsed = MutableStateFlow<List<String>>(emptyList())
val views = combine(default, recentlyUsed) { default, recentlyUsed ->
buildList<ReactionPickerView> {
if (recentlyUsed.isNotEmpty()) {
add(ReactionPickerView.RecentUsedSection)
addAll(
recentlyUsed.map { unicode ->
ReactionPickerView.Emoji(
unicode = unicode,
page = -1,
index = -1,
emojified = Emojifier.safeUri(unicode)
)
}
)
}
addAll(default)
}
}
init {
viewModelScope.launch {
observeRecentlyUsedChatReactions
.flow()
.collect {
recentlyUsed.value = it
}
}
viewModelScope.launch {
val loaded = loadEmojiWithCategories()
default.value = loaded
}
}
private suspend fun loadEmojiWithCategories() = withContext(dispatchers.io) {
val views = mutableListOf<ReactionPickerView>()
provider.emojis.forEachIndexed { categoryIndex, emojis ->
views.add(
ReactionPickerView.Category(
index = categoryIndex
)
)
emojis.forEachIndexed { emojiIndex, emoji ->
val skin = Emoji.COLORS.any { color -> emoji.contains(color) }
if (!skin)
views.add(
ReactionPickerView.Emoji(
unicode = emoji,
page = categoryIndex,
index = emojiIndex,
emojified = Emojifier.safeUri(emoji)
)
)
}
}
views
}
fun onEmojiClicked(emoji: String) {
viewModelScope.launch {
setRecentlyUsedChatReactions.async(
params = (listOf(emoji) + recentlyUsed.value)
.toSet()
.take(MAX_RECENTLY_USED_COUNT)
.toSet()
).onFailure {
Timber.e(it, "Error while saving recently used reactions")
}
toggleChatMessageReaction.async(
params = Command.ChatCommand.ToggleMessageReaction(
msg = vmParams.msg,
chat = vmParams.chat,
emoji = emoji
)
).onFailure {
Timber.e(it, "Error while toggling chat message reaction")
}
isDismissed.emit(true)
}
}
class Factory @Inject constructor(
private val params: Params,
private val emojiProvider: EmojiProvider,
private val emojiSuggester: EmojiSuggester,
private val dispatchers: AppCoroutineDispatchers,
private val toggleChatMessageReaction: ToggleChatMessageReaction,
private val setRecentlyUsedChatReactions: SetRecentlyUsedChatReactions,
private val observeRecentlyUsedChatReactions: ObserveRecentlyUsedChatReactions
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SelectChatReactionViewModel(
vmParams = params,
provider = emojiProvider,
suggester = emojiSuggester,
dispatchers = dispatchers,
toggleChatMessageReaction = toggleChatMessageReaction,
setRecentlyUsedChatReactions = setRecentlyUsedChatReactions,
observeRecentlyUsedChatReactions = observeRecentlyUsedChatReactions
) as T
}
data class Params @Inject constructor(
val chat: Id,
val msg: Id
)
sealed class ReactionPickerView {
data object RecentUsedSection: ReactionPickerView()
data class Category(val index: Int) : ReactionPickerView()
data class Emoji(
val unicode: String,
val page: Int,
val index: Int,
val emojified: String = ""
) : ReactionPickerView()
}
companion object {
const val MAX_RECENTLY_USED_COUNT = 20
}
}

View file

@ -1,6 +1,5 @@
package com.anytypeio.anytype.feature_discussions.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@ -8,7 +7,6 @@ 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.systemBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@ -28,13 +26,13 @@ import com.anytypeio.anytype.core_ui.foundation.noRippleClickable
import com.anytypeio.anytype.core_ui.views.Caption1Medium
import com.anytypeio.anytype.emojifier.data.Emoji
import com.anytypeio.anytype.feature_discussions.R
import com.anytypeio.anytype.feature_discussions.presentation.ChatReactionViewModel.ReactionPickerView
import com.anytypeio.anytype.feature_discussions.presentation.SelectChatReactionViewModel.ReactionPickerView
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun ChatReactionPicker(
fun SelectChatReactionScreen(
views: List<ReactionPickerView> = emptyList(),
onEmojiClicked: (String) -> Unit
) {
@ -133,7 +131,7 @@ fun ChatReactionPicker(
@DefaultPreviews
@Composable
fun PickerPreview() {
ChatReactionPicker(
SelectChatReactionScreen(
views = buildList {
add(
ReactionPickerView.Emoji(

View file

@ -0,0 +1,261 @@
package com.anytypeio.anytype.feature_discussions.ui
import com.anytypeio.anytype.feature_discussions.R
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.common.DefaultPreviews
import com.anytypeio.anytype.core_ui.features.multiplayer.SpaceMemberIcon
import com.anytypeio.anytype.core_ui.foundation.Dragger
import com.anytypeio.anytype.core_ui.views.BodyCallout
import com.anytypeio.anytype.core_ui.views.BodyRegular
import com.anytypeio.anytype.core_ui.views.Relations3
import com.anytypeio.anytype.feature_discussions.presentation.ChatReactionViewModel.ViewState
import com.anytypeio.anytype.presentation.objects.SpaceMemberIconView
@Composable
fun ChatReactionScreen(
viewState: ViewState
) {
Column(
modifier = Modifier.fillMaxSize()
) {
Dragger(
modifier = Modifier
.padding(vertical = 6.dp)
.align(Alignment.CenterHorizontally)
)
EmojiToolbar(viewState)
LazyColumn(
modifier = Modifier.fillMaxSize().weight(1f)
) {
when(viewState) {
is ViewState.Init -> {
// Do nothing.
}
is ViewState.Empty -> {
item {
Box(
modifier = Modifier.fillParentMaxSize()
) {
EmptyState(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
is ViewState.Success -> {
items(
count = viewState.members.size
) { idx ->
Member(
member = viewState.members[idx]
)
}
}
is ViewState.Error.MessageNotFound -> {
item {
Box(
modifier = Modifier.fillParentMaxSize()
) {
Text(
modifier = Modifier.align(Alignment.Center),
color = colorResource(R.color.palette_system_red),
text = "Message not found",
style = BodyCallout
)
}
}
}
is ViewState.Loading -> {
}
}
}
}
}
@Composable
private fun Member(
modifier: Modifier = Modifier,
member: ViewState.Member
) {
Box(
modifier = modifier
.height(72.dp)
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
SpaceMemberIcon(
icon = member.icon,
iconSize = 48.dp,
modifier = Modifier.align(
alignment = Alignment.CenterStart
)
)
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterStart)
.padding(start = 60.dp)
) {
Text(
text = member.name.ifEmpty {
stringResource(R.string.untitled)
}
)
Text(
text = stringResource(R.string.object_types_human),
style = Relations3
)
}
}
}
@Composable
private fun EmojiToolbar(
viewState: ViewState
) {
Box(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
) {
Row(
modifier = Modifier.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically
) {
Text(viewState.emoji)
Spacer(modifier = Modifier.width(8.dp))
when(viewState) {
is ViewState.Success -> {
Text(
text = viewState.members.size.toString(),
style = BodyRegular,
color = colorResource(R.color.text_primary)
)
}
is ViewState.Empty -> {
Text(
text = "0",
style = BodyRegular,
color = colorResource(R.color.text_primary)
)
}
else -> {
// Do nothing.
}
}
}
}
}
@Composable
private fun EmptyState(
modifier: Modifier
) {
Column(modifier = modifier.padding(horizontal = 20.dp)) {
Text(
stringResource(R.string.chat_message_reactions_no_reactions_yet),
style = BodyRegular,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
color = colorResource(R.color.text_primary)
)
Text(
stringResource(R.string.chat_message_reactions_no_reactions_message),
style = BodyRegular,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
color = colorResource(R.color.text_primary)
)
}
}
@DefaultPreviews
@Composable
private fun MemberPreview() {
Member(
member = ViewState.Member(
name = "Walter Benjamin",
icon = SpaceMemberIconView.Placeholder(
name = "Walter"
),
isUser = false
)
)
}
@DefaultPreviews
@Composable
private fun EmojiToolbarPreview() {
EmojiToolbar(
viewState = ViewState.Empty(
emoji = "😀"
)
)
}
@DefaultPreviews
@Composable
private fun EmptyStatePreview() {
EmptyState(modifier = Modifier)
}
@DefaultPreviews
@Composable
private fun ChatReactionEmptyStateScreenPreview() {
ChatReactionScreen(
viewState = ViewState.Empty(
emoji = "😀"
)
)
}
@DefaultPreviews
@Composable
private fun ChatReactionSuccessStateScreenPreview() {
ChatReactionScreen(
viewState = ViewState.Success(
emoji = "😀",
members = listOf(
ViewState.Member(
name = "Walter Benjamin",
icon = SpaceMemberIconView.Placeholder(
name = "Walter"
),
isUser = false
),
ViewState.Member(
name = "Walter Benjamin",
icon = SpaceMemberIconView.Placeholder(
name = "Walter"
),
isUser = false
),
ViewState.Member(
name = "Walter Benjamin",
icon = SpaceMemberIconView.Placeholder(
name = "Walter"
),
isUser = false
)
)
)
)
}

View file

@ -69,7 +69,8 @@ fun DiscussionPreview() {
onEditMessage = {},
onMarkupLinkClicked = {},
onReplyMessage = {},
onAddReactionClicked = {}
onAddReactionClicked = {},
onViewChatReaction = { a, b -> }
)
}
@ -125,7 +126,8 @@ fun DiscussionScreenPreview() {
onClearReplyClicked = {},
onChatBoxMediaPicked = {},
onChatBoxFilePicked = {},
onAddReactionClicked = {}
onAddReactionClicked = {},
onViewChatReaction = { a, b -> }
)
}
@ -152,7 +154,8 @@ fun BubblePreview() {
onMarkupLinkClicked = {},
onReply = {},
onScrollToReplyClicked = {},
onAddReactionClicked = {}
onAddReactionClicked = {},
onViewChatReaction = {}
)
}
@ -180,7 +183,8 @@ fun BubbleEditedPreview() {
onMarkupLinkClicked = {},
onReply = {},
onScrollToReplyClicked = {},
onAddReactionClicked = {}
onAddReactionClicked = {},
onViewChatReaction = {}
)
}
@ -216,6 +220,7 @@ fun BubbleWithAttachmentPreview() {
onMarkupLinkClicked = {},
onReply = {},
onScrollToReplyClicked = {},
onAddReactionClicked = {}
onAddReactionClicked = {},
onViewChatReaction = {}
)
}

View file

@ -13,10 +13,12 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -150,7 +152,8 @@ fun DiscussionScreenWrapper(
onBackButtonClicked: () -> Unit,
onMarkupLinkClicked: (String) -> Unit,
onRequestOpenFullScreenImage: (String) -> Unit,
onChatReaction: (String) -> Unit
onSelectChatReaction: (String) -> Unit,
onViewChatReaction: (Id, String) -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var showReactionSheet by remember { mutableStateOf(false) }
@ -238,7 +241,8 @@ fun DiscussionScreenWrapper(
}
vm.onChatBoxFilePicked(infos)
},
onAddReactionClicked = onChatReaction
onAddReactionClicked = onSelectChatReaction,
onViewChatReaction = onViewChatReaction
)
LaunchedEffect(Unit) {
vm.commands.collect { command ->
@ -268,7 +272,7 @@ fun DiscussionScreenWrapper(
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
dragHandle = null
) {
ChatReactionPicker(
SelectChatReactionScreen(
onEmojiClicked = {}
)
}
@ -307,7 +311,8 @@ fun DiscussionScreen(
onUploadAttachmentClicked: () -> Unit,
onChatBoxMediaPicked: (List<Uri>) -> Unit,
onChatBoxFilePicked: (List<Uri>) -> Unit,
onAddReactionClicked: (String) -> Unit
onAddReactionClicked: (String) -> Unit,
onViewChatReaction: (Id, String) -> Unit
) {
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(""))
@ -364,7 +369,8 @@ fun DiscussionScreen(
chatBoxFocusRequester.requestFocus()
},
onMarkupLinkClicked = onMarkupLinkClicked,
onAddReactionClicked = onAddReactionClicked
onAddReactionClicked = onAddReactionClicked,
onViewChatReaction = onViewChatReaction
)
// Jump to bottom button shows up when user scrolls past a threshold.
// Convert to pixels:
@ -981,7 +987,8 @@ fun Messages(
onEditMessage: (DiscussionView.Message) -> Unit,
onReplyMessage: (DiscussionView.Message) -> Unit,
onMarkupLinkClicked: (String) -> Unit,
onAddReactionClicked: (String) -> Unit
onAddReactionClicked: (String) -> Unit,
onViewChatReaction: (Id, String) -> Unit
) {
val scope = rememberCoroutineScope()
LazyColumn(
@ -1048,6 +1055,9 @@ fun Messages(
},
onAddReactionClicked = {
onAddReactionClicked(msg.id)
},
onViewChatReaction = { emoji ->
onViewChatReaction(msg.id, emoji)
}
)
if (msg.isUserAuthor) {
@ -1174,7 +1184,8 @@ fun Bubble(
onAttachmentClicked: (DiscussionView.Message.Attachment) -> Unit,
onMarkupLinkClicked: (String) -> Unit,
onScrollToReplyClicked: (DiscussionView.Message.Reply) -> Unit,
onAddReactionClicked: () -> Unit
onAddReactionClicked: () -> Unit,
onViewChatReaction: (String) -> Unit
) {
var showDropdownMenu by remember { mutableStateOf(false) }
Column(
@ -1322,7 +1333,8 @@ fun Bubble(
if (reactions.isNotEmpty()) {
ReactionList(
reactions = reactions,
onReacted = onReacted
onReacted = onReacted,
onViewReaction = onViewChatReaction
)
}
Spacer(modifier = Modifier.height(12.dp))
@ -1660,11 +1672,12 @@ fun GoToBottomButton(
}
}
@OptIn(ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@Composable
fun ReactionList(
reactions: List<DiscussionView.Message.Reaction>,
onReacted: (String) -> Unit
onReacted: (String) -> Unit,
onViewReaction: (String) -> Unit
) {
FlowRow(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
@ -1694,9 +1707,14 @@ fun ReactionList(
else
Modifier
)
.clickable {
onReacted(reaction.emoji)
}
.combinedClickable(
onClick = {
onReacted(reaction.emoji)
},
onLongClick = {
onViewReaction(reaction.emoji)
}
)
) {
Text(
text = reaction.emoji,
@ -1766,7 +1784,8 @@ fun ReactionListPreview() {
isSelected = false
)
),
onReacted = {}
onReacted = {},
onViewReaction = {}
)
}

View file

@ -1862,5 +1862,9 @@ Please provide specific details of your needs here.</string>
<string name="emoji_category_symbols">Symbols</string>
<string name="emoji_category_flags">Flags</string>
<string name="emoji_recently_used_section">Recently used</string>
<string name="chat_message_reactions_no_reactions_yet">No reactions yet</string>
<string name="chat_message_reactions_no_reactions_message">Probably someone has just removed the reaction or technical issue happened</string>
<string name="object_types_human">Human</string>
</resources>