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

DROID-3016 Chats | Enhancement | Add space-level chat widget by default (#1784)

This commit is contained in:
Evgenii Kozlov 2024-11-08 14:49:18 +01:00 committed by GitHub
parent a67b03f1d1
commit 7bfab8bfe1
Signed by: github
GPG key ID: B5690EEEBB952194
19 changed files with 240 additions and 122 deletions

View file

@ -25,4 +25,7 @@ class DefaultFeatureToggles @Inject constructor(
override val isConciseLogging: Boolean = true
override val enableDiscussionDemo: Boolean = true
override val isSpaceLevelChatEnabled: Boolean
get() = true
}

View file

@ -63,6 +63,7 @@ import com.anytypeio.anytype.ui.widgets.types.GalleryWidgetCard
import com.anytypeio.anytype.ui.widgets.types.LibraryWidgetCard
import com.anytypeio.anytype.ui.widgets.types.LinkWidgetCard
import com.anytypeio.anytype.ui.widgets.types.ListWidgetCard
import com.anytypeio.anytype.ui.widgets.types.SpaceChatWidgetCard
import com.anytypeio.anytype.ui.widgets.types.SpaceWidgetCard
import com.anytypeio.anytype.ui.widgets.types.TreeWidgetCard
import org.burnoutcrew.reorderable.ReorderableItem
@ -73,7 +74,6 @@ import org.burnoutcrew.reorderable.reorderable
@Composable
fun HomeScreen(
profileIcon: ProfileIconView,
mode: InteractionMode,
widgets: List<WidgetView>,
onExpand: (TreePath) -> Unit,
@ -423,6 +423,12 @@ private fun WidgetList(
onWidgetClicked = { onBundledWidgetHeaderClicked(item.id) }
)
}
is WidgetView.SpaceChat -> {
SpaceChatWidgetCard(
mode = mode,
onWidgetClicked = { onBundledWidgetHeaderClicked(item.id) }
)
}
is WidgetView.Library -> {
LibraryWidgetCard(
onDropDownMenuAction = { action ->

View file

@ -88,7 +88,6 @@ class HomeScreenFragment : BaseComposeFragment(),
)
) {
HomeScreen(
profileIcon = vm.icon.collectAsState().value,
widgets = vm.views.collectAsState().value,
mode = vm.mode.collectAsState().value,
onExpand = { path -> vm.onExpand(path) },

View file

@ -0,0 +1,91 @@
package com.anytypeio.anytype.ui.widgets.types
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_ui.views.HeadlineSubheading
import com.anytypeio.anytype.presentation.home.InteractionMode
@Composable
fun SpaceChatWidgetCard(
mode: InteractionMode,
onWidgetClicked: () -> Unit = {}
) {
Box(
modifier = Modifier
.padding(start = 20.dp, end = 20.dp, top = 6.dp, bottom = 6.dp)
.fillMaxWidth()
.height(52.dp)
.background(
shape = RoundedCornerShape(16.dp),
color = colorResource(id = R.color.dashboard_card_background)
)
.clip(RoundedCornerShape(16.dp))
.then(
if (mode !is InteractionMode.Edit) {
Modifier.clickable {
onWidgetClicked()
}
} else {
Modifier
}
)
) {
Image(
painter = painterResource(id = R.drawable.ic_widget_all_content),
contentDescription = "All content icon",
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 16.dp)
)
Text(
// Temporary hard-coded name for the widget
text = "Space chat",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 44.dp, end = 16.dp),
style = HeadlineSubheading,
color = colorResource(id = R.color.text_primary),
)
}
}
@Preview(
name = "Dark Mode",
showBackground = true,
uiMode = UI_MODE_NIGHT_YES
)
@Preview(
name = "Light Mode",
showBackground = true,
uiMode = UI_MODE_NIGHT_NO
)
@Composable
fun SpaceChatWidgetPreview() {
SpaceChatWidgetCard(
onWidgetClicked = {},
mode = InteractionMode.Default
)
}

View file

@ -15,4 +15,6 @@ interface FeatureToggles {
val isLogEditorControlPanelMachine: Boolean
val enableDiscussionDemo: Boolean
val isSpaceLevelChatEnabled: Boolean
}

View file

@ -758,8 +758,9 @@ class BlockDataRepository(
remote.deleteSpace(space)
}
override suspend fun createWorkspace(details: Struct): Id = remote.createWorkspace(
details = details
override suspend fun createWorkspace(details: Struct, withChat: Boolean): Id = remote.createWorkspace(
details = details,
withChat = withChat
)
override suspend fun setSpaceDetails(space: SpaceId, details: Struct) {

View file

@ -336,7 +336,7 @@ interface BlockRemote {
suspend fun setSpaceDetails(space: SpaceId, details: Struct)
suspend fun deleteSpace(space: SpaceId)
suspend fun createWorkspace(details: Struct): Id
suspend fun createWorkspace(details: Struct, withChat: Boolean): Id
suspend fun getSpaceConfig(space: Id): Config

View file

@ -390,7 +390,7 @@ interface BlockRepository {
): Payload
suspend fun deleteSpace(space: SpaceId)
suspend fun createWorkspace(details: Struct): Id
suspend fun createWorkspace(details: Struct, withChat: Boolean): Id
suspend fun getSpaceConfig(space: Id): Config
suspend fun addObjectListToSpace(objects: List<Id>, space: Id) : List<Id>
suspend fun addObjectToSpace(command: Command.AddObjectToSpace) : Pair<Id, Struct?>

View file

@ -109,6 +109,7 @@ interface SpaceViewSubscriptionContainer {
keys = listOf(
Relations.ID,
Relations.TARGET_SPACE_ID,
Relations.CHAT_ID,
Relations.SPACE_ACCOUNT_STATUS,
Relations.SPACE_LOCAL_STATUS,
Relations.SPACE_ACCESS_TYPE,
@ -119,7 +120,7 @@ interface SpaceViewSubscriptionContainer {
Relations.CREATED_DATE,
Relations.CREATOR,
Relations.ICON_IMAGE,
Relations.ICON_OPTION,
Relations.ICON_OPTION
),
filters = listOf(
DVFilter(

View file

@ -9,12 +9,13 @@ import javax.inject.Inject
class CreateSpace @Inject constructor(
private val repo: BlockRepository,
private val dispatchers: AppCoroutineDispatchers
dispatchers: AppCoroutineDispatchers
) : ResultInteractor<CreateSpace.Params, Id>(dispatchers.io) {
override suspend fun doWork(params: Params): Id = repo.createWorkspace(
params.details
details = params.details,
withChat = params.withChat
)
data class Params(val details: Struct)
data class Params(val details: Struct, val withChat: Boolean = true)
}

View file

@ -51,9 +51,6 @@ class DiscussionViewModel(
val navigation = MutableSharedFlow<OpenObjectNavigation>()
val chatBoxMode = MutableStateFlow<ChatBoxMode>(ChatBoxMode.Default)
// TODO naive implementation; switch to state
private lateinit var chat: Id
init {
viewModelScope.launch {
val account = requireNotNull(getAccount.async(Unit).getOrNull())
@ -68,8 +65,7 @@ class DiscussionViewModel(
val root = ObjectWrapper.Basic(obj.details[params.ctx].orEmpty())
name.value = root.name
proceedWithObservingChatMessages(
account = account.id,
root = root
account = account.id
)
},
onFailure = {
@ -80,53 +76,46 @@ class DiscussionViewModel(
}
private suspend fun proceedWithObservingChatMessages(
account: Id,
root: ObjectWrapper.Basic
account: Id
) {
val chat = root.getValue<Id>(Relations.CHAT_ID)
if (chat != null) {
this.chat = chat
chatContainer
.watch(chat)
.onEach { Timber.d("Got new update: $it") }
.collect {
messages.value = it.map { msg ->
val member = members.get().let { type ->
when(type) {
is Store.Data -> type.members.find { member ->
member.identity == msg.creator
}
is Store.Empty -> null
chatContainer
.watch(params.ctx)
.onEach { Timber.d("Got new update: $it") }
.collect {
messages.value = it.map { msg ->
val member = members.get().let { type ->
when(type) {
is Store.Data -> type.members.find { member ->
member.identity == msg.creator
}
is Store.Empty -> null
}
DiscussionView.Message(
id = msg.id,
timestamp = msg.createdAt * 1000,
content = msg.content?.text.orEmpty(),
author = member?.name ?: msg.creator.takeLast(5),
isUserAuthor = msg.creator == account,
isEdited = msg.modifiedAt > msg.createdAt,
reactions = msg.reactions.map{ (emoji, ids) ->
DiscussionView.Message.Reaction(
emoji = emoji,
count = ids.size,
isSelected = ids.contains(account)
)
},
attachments = msg.attachments,
avatar = if (member != null && !member.iconImage.isNullOrEmpty()) {
DiscussionView.Message.Avatar.Image(
urlBuilder.thumbnail(member.iconImage!!)
)
} else {
DiscussionView.Message.Avatar.Initials(member?.name.orEmpty())
}
)
}.reversed()
}
} else {
Timber.w("Chat ID was missing in chat smart-object details")
}
}
DiscussionView.Message(
id = msg.id,
timestamp = msg.createdAt * 1000,
content = msg.content?.text.orEmpty(),
author = member?.name ?: msg.creator.takeLast(5),
isUserAuthor = msg.creator == account,
isEdited = msg.modifiedAt > msg.createdAt,
reactions = msg.reactions.map{ (emoji, ids) ->
DiscussionView.Message.Reaction(
emoji = emoji,
count = ids.size,
isSelected = ids.contains(account)
)
},
attachments = msg.attachments,
avatar = if (member != null && !member.iconImage.isNullOrEmpty()) {
DiscussionView.Message.Avatar.Image(
urlBuilder.thumbnail(member.iconImage!!)
)
} else {
DiscussionView.Message.Avatar.Initials(member?.name.orEmpty())
}
)
}.reversed()
}
}
fun onMessageSent(msg: String) {
@ -137,7 +126,7 @@ class DiscussionViewModel(
// TODO consider moving this use-case inside chat container
addChatMessage.async(
params = Command.ChatCommand.AddMessage(
chat = chat,
chat = params.ctx,
message = Chat.Message.new(
text = msg,
attachments = attachments.value.map { a ->
@ -160,7 +149,7 @@ class DiscussionViewModel(
is ChatBoxMode.EditMessage -> {
editChatMessage.async(
params = Command.ChatCommand.EditMessage(
chat = chat,
chat = params.ctx,
message = Chat.Message.updated(
id = mode.msg,
text = msg
@ -197,7 +186,11 @@ class DiscussionViewModel(
Relations.NAME to input
)
)
)
).onSuccess {
Timber.d("Updated chat title successfully")
}.onFailure {
Timber.e(it, "Error while updating chat title")
}
}
}
@ -216,7 +209,7 @@ class DiscussionViewModel(
if (message != null) {
toggleChatMessageReaction.async(
Command.ChatCommand.ToggleMessageReaction(
chat = chat,
chat = params.ctx,
msg = msg,
emoji = reaction
)
@ -234,7 +227,7 @@ class DiscussionViewModel(
viewModelScope.launch {
deleteChatMessage.async(
Command.ChatCommand.DeleteMessage(
chat = chat,
chat = params.ctx,
msg = msg.id
)
).onFailure {

View file

@ -729,8 +729,12 @@ class BlockMiddleware(
middleware.spaceDelete(space)
}
override suspend fun createWorkspace(details: Struct): Id = middleware.workspaceCreate(
details = details
override suspend fun createWorkspace(
details: Struct,
withChat: Boolean
): Id = middleware.workspaceCreate(
details = details,
withChat = withChat
)
override suspend fun getSpaceConfig(space: Id): Config = middleware.workspaceOpen(

View file

@ -1921,10 +1921,11 @@ class Middleware @Inject constructor(
}
@Throws(Exception::class)
fun workspaceCreate(details: Struct): Id {
fun workspaceCreate(details: Struct, withChat: Boolean): Id {
val request = Rpc.Workspace.Create.Request(
details = details,
useCase = Rpc.Object.ImportUseCase.Request.UseCase.GET_STARTED
useCase = Rpc.Object.ImportUseCase.Request.UseCase.GET_STARTED,
withChat = withChat
)
logRequestIfDebug(request)
val (response, time) = measureTimedValue { service.workspaceCreate(request) }
@ -1935,7 +1936,8 @@ class Middleware @Inject constructor(
@Throws(Exception::class)
fun workspaceOpen(space: Id): Config {
val request = Rpc.Workspace.Open.Request(
spaceId = space
spaceId = space,
withChat = true
)
logRequestIfDebug(request)
val (response, time) = measureTimedValue { service.workspaceOpen(request) }

View file

@ -1,5 +1,6 @@
package com.anytypeio.anytype.presentation.home
import android.R
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@ -33,6 +34,7 @@ import com.anytypeio.anytype.core_models.primitives.TypeKey
import com.anytypeio.anytype.core_utils.ext.cancel
import com.anytypeio.anytype.core_utils.ext.replace
import com.anytypeio.anytype.core_utils.ext.withLatestFrom
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.domain.auth.interactor.ClearLastOpenedObject
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.Resultat
@ -89,8 +91,6 @@ import com.anytypeio.anytype.presentation.navigation.DeepLinkToObjectDelegate
import com.anytypeio.anytype.presentation.navigation.NavigationViewModel
import com.anytypeio.anytype.presentation.objects.SupportedLayouts
import com.anytypeio.anytype.presentation.objects.getCreateObjectParams
import com.anytypeio.anytype.presentation.profile.ProfileIconView
import com.anytypeio.anytype.presentation.profile.profileIcon
import com.anytypeio.anytype.presentation.search.Subscriptions
import com.anytypeio.anytype.presentation.sets.prefillNewObjectDetails
import com.anytypeio.anytype.presentation.sets.resolveSetByRelationPrefilledObjectData
@ -106,6 +106,7 @@ import com.anytypeio.anytype.presentation.widgets.DropDownMenuAction
import com.anytypeio.anytype.presentation.widgets.LinkWidgetContainer
import com.anytypeio.anytype.presentation.widgets.ListWidgetContainer
import com.anytypeio.anytype.presentation.widgets.SpaceBinWidgetContainer
import com.anytypeio.anytype.presentation.widgets.SpaceChatWidgetContainer
import com.anytypeio.anytype.presentation.widgets.SpaceWidgetContainer
import com.anytypeio.anytype.presentation.widgets.TreePath
import com.anytypeio.anytype.presentation.widgets.TreeWidgetBranchStateHolder
@ -205,7 +206,8 @@ class HomeScreenViewModel(
private val addObjectToCollection: AddObjectToCollection,
private val clearLastOpenedSpace: ClearLastOpenedSpace,
private val clearLastOpenedObject: ClearLastOpenedObject,
private val spaceBinWidgetContainer: SpaceBinWidgetContainer
private val spaceBinWidgetContainer: SpaceBinWidgetContainer,
private val featureToggles: FeatureToggles
) : NavigationViewModel<HomeScreenViewModel.Navigation>(),
Reducer<ObjectView, Payload>,
WidgetActiveViewStateHolder by widgetActiveViewStateHolder,
@ -232,11 +234,10 @@ class HomeScreenViewModel(
private val treeWidgetBranchStateHolder = TreeWidgetBranchStateHolder()
private val allContentWidget = AllContentWidgetContainer()
private val spaceChatWidget = SpaceChatWidgetContainer()
private val spaceWidgetView = spaceWidgetContainer.view
val icon = MutableStateFlow<ProfileIconView>(ProfileIconView.Loading)
private val widgetObjectPipelineJobs = mutableListOf<Job>()
private val openWidgetObjectsHistory : MutableSet<OpenObjectHistoryItem> = LinkedHashSet()
@ -348,7 +349,6 @@ class HomeScreenViewModel(
init {
Timber.i("HomeScreenViewModel, init")
proceedWithUserPermissions()
proceedWithObservingProfileIcon()
proceedWithLaunchingUnsubscriber()
proceedWithObjectViewStatePipeline()
proceedWithWidgetContainerPipeline()
@ -408,6 +408,9 @@ class HomeScreenViewModel(
combine(
flows = buildList<Flow<WidgetView>> {
add(spaceWidgetView)
if (featureToggles.isSpaceLevelChatEnabled) {
add(spaceChatWidget.view)
}
add(allContentWidget.view)
addAll(list.map { m -> m.view })
}
@ -581,35 +584,6 @@ class HomeScreenViewModel(
}
}
private fun proceedWithObservingProfileIcon() {
viewModelScope.launch {
spaceManager
.observe()
.flatMapLatest { config ->
storelessSubscriptionContainer.subscribe(
StoreSearchByIdsParams(
space = SpaceId(config.techSpace),
subscription = HOME_SCREEN_PROFILE_OBJECT_SUBSCRIPTION,
targets = listOf(config.profile),
keys = listOf(
Relations.ID,
Relations.NAME,
Relations.ICON_EMOJI,
Relations.ICON_IMAGE,
Relations.ICON_OPTION
)
)
).map { result ->
val obj = result.firstOrNull()
obj?.profileIcon(urlBuilder) ?: ProfileIconView.Placeholder(null)
}
}
.catch { Timber.e(it, "Error while observing space icon") }
.flowOn(appCoroutineDispatchers.io)
.collect { icon.value = it }
}
}
private suspend fun proceedWithClosingWidgetObject(
widgetObject: Id,
space: SpaceId
@ -1075,6 +1049,9 @@ class HomeScreenViewModel(
)
)
}
WidgetView.SpaceChat.id -> {
proceedWithSpaceChatWidgetHeaderClick()
}
WidgetView.AllContent.ALL_CONTENT_WIDGET_ID -> {
if (mode.value == InteractionMode.Edit) {
return@launch
@ -1085,10 +1062,37 @@ class HomeScreenViewModel(
)
)
}
else -> {
Timber.w("Skipping widget click: $widget")
}
}
}
}
private suspend fun proceedWithSpaceChatWidgetHeaderClick() {
if (mode.value == InteractionMode.Edit) {
return
}
val view = views.value.find { it is WidgetView.SpaceWidget.View }
if (view != null) {
val spaceView = (view as WidgetView.SpaceWidget.View)
val chat = spaceView.space.getValue<Id?>(Relations.CHAT_ID)
val space = spaceView.space.targetSpaceId
if (chat != null && space != null) {
navigation(
Navigation.OpenDiscussion(
space = space,
ctx = chat
)
)
} else {
Timber.w("Chat or space not found - not able to open space chat")
}
} else {
Timber.w("Space widget not found")
}
}
private fun proceedWithAddingWidgetBelow(widget: Id) {
viewModelScope.launch {
sendAddWidgetEvent(
@ -2174,7 +2178,8 @@ class HomeScreenViewModel(
private val addObjectToCollection: AddObjectToCollection,
private val clearLastOpenedSpace: ClearLastOpenedSpace,
private val clearLastOpenedObject: ClearLastOpenedObject,
private val spaceBinWidgetContainer: SpaceBinWidgetContainer
private val spaceBinWidgetContainer: SpaceBinWidgetContainer,
private val featureToggles: FeatureToggles
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = HomeScreenViewModel(
@ -2224,7 +2229,8 @@ class HomeScreenViewModel(
addObjectToCollection = addObjectToCollection,
clearLastOpenedSpace = clearLastOpenedSpace,
clearLastOpenedObject = clearLastOpenedObject,
spaceBinWidgetContainer = spaceBinWidgetContainer
spaceBinWidgetContainer = spaceBinWidgetContainer,
featureToggles = featureToggles
) as T
}

View file

@ -1280,8 +1280,9 @@ object ObjectSearchConstants {
val spaceViewKeys = listOf(
Relations.ID,
Relations.NAME,
Relations.TARGET_SPACE_ID,
Relations.CHAT_ID,
Relations.NAME,
Relations.ICON_IMAGE,
Relations.ICON_EMOJI,
Relations.ICON_OPTION,

View file

@ -0,0 +1,10 @@
package com.anytypeio.anytype.presentation.widgets
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class SpaceChatWidgetContainer : WidgetContainer {
override val view: Flow<WidgetView> = flowOf(
WidgetView.SpaceChat
)
}

View file

@ -45,6 +45,7 @@ class SpaceWidgetContainer @Inject constructor(
targets = listOf(config.spaceView),
keys = buildList {
addAll(ObjectSearchConstants.defaultKeys)
add(Relations.CHAT_ID)
add(Relations.SPACE_ACCESS_TYPE)
add(Relations.ICON_OPTION)
}

View file

@ -117,6 +117,12 @@ sealed class WidgetView {
override val id: Id = ALL_CONTENT_WIDGET_ID
}
data object SpaceChat : WidgetView() {
private const val SPACE_CHAT_WIDGET_ID = "bundled-widget.space-chat"
override val isLoading: Boolean = false
override val id: Id = SPACE_CHAT_WIDGET_ID
}
sealed class SpaceWidget: WidgetView() {
override val id: Id get() = SpaceWidgetContainer.SPACE_WIDGET_SUBSCRIPTION
data class View(

View file

@ -29,6 +29,7 @@ import com.anytypeio.anytype.core_models.primitives.Space
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_models.primitives.TypeId
import com.anytypeio.anytype.core_models.primitives.TypeKey
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.domain.auth.interactor.ClearLastOpenedObject
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.base.Resultat
@ -251,6 +252,9 @@ class HomeScreenViewModelTest {
@Mock
lateinit var clearLastOpenedObject: ClearLastOpenedObject
@Mock
lateinit var featureToggles: FeatureToggles
lateinit var userPermissionProvider: UserPermissionProvider
private val objectPayloadDispatcher = Dispatcher.Default<Payload>()
@ -2504,20 +2508,6 @@ class HomeScreenViewModelTest {
)
)
}
verify(storelessSubscriptionContainer, times(1)).subscribe(
StoreSearchByIdsParams(
space = SpaceId(defaultSpaceConfig.techSpace),
subscription = HomeScreenViewModel.HOME_SCREEN_PROFILE_OBJECT_SUBSCRIPTION,
targets = listOf(defaultSpaceConfig.profile),
keys = listOf(
Relations.ID,
Relations.NAME,
Relations.ICON_EMOJI,
Relations.ICON_IMAGE,
Relations.ICON_OPTION
)
)
)
verify(storelessSubscriptionContainer, times(1)).subscribe(
firstTimeParams
)
@ -2986,7 +2976,8 @@ class HomeScreenViewModelTest {
spaceBinWidgetContainer = SpaceBinWidgetContainer(
container = storelessSubscriptionContainer,
manager = spaceManager
)
),
featureToggles = featureToggles
)
companion object {