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

DROID-3662 Chats | Enhancement | Introduce system widget "Chat" + navigation changes (#2420)

This commit is contained in:
Evgenii Kozlov 2025-05-21 13:17:07 +02:00 committed by GitHub
parent d7946bd54d
commit 5bddcd640c
Signed by: github
GPG key ID: B5690EEEBB952194
21 changed files with 175 additions and 82 deletions

View file

@ -31,7 +31,7 @@ object WidgetAnalytics {
const val WIDGET_SOURCE_SETS = "Sets"
const val WIDGET_SOURCE_BIN = "Bin"
const val WIDGET_SOURCE_ALL_OBJECTS = "AllObjects"
const val WIDGET_SOURCE_COLLECTIONS = "Sets"
const val WIDGET_SOURCE_CHAT = "Chat"
const val CUSTOM_OBJECT_TYPE = "custom"

View file

@ -63,16 +63,6 @@ class Navigator : AppNavigation {
)
}
override fun openDiscussion(target: Id, space: Id) {
navController?.navigate(
R.id.chatScreen,
ChatFragment.args(
ctx = target,
space = space
)
)
}
override fun openModalTemplateSelect(
template: Id,
templateTypeId: Id,

View file

@ -464,7 +464,7 @@ class HomeScreenFragment : BaseComposeFragment(),
)
}
is Navigation.OpenChat -> runCatching {
navigation().openDiscussion(
navigation().openChat(
target = destination.ctx,
space = destination.space
)

View file

@ -617,4 +617,5 @@ fun Widget.Source.Bundled.res(): Int = when (this) {
Widget.Source.Bundled.RecentLocal -> R.string.recently_opened
Widget.Source.Bundled.Bin -> R.string.bin
Widget.Source.Bundled.AllObjects -> R.string.all_content
Widget.Source.Bundled.Chat -> R.string.chat
}

View file

@ -5,6 +5,7 @@ import com.anytypeio.anytype.core_models.ext.typeOf
import com.anytypeio.anytype.core_models.multiplayer.ParticipantStatus
import com.anytypeio.anytype.core_models.multiplayer.SpaceAccessType
import com.anytypeio.anytype.core_models.multiplayer.SpaceMemberPermissions
import com.anytypeio.anytype.core_models.multiplayer.SpaceType
import com.anytypeio.anytype.core_models.restrictions.ObjectRestriction
import com.anytypeio.anytype.core_models.restrictions.SpaceStatus
@ -331,6 +332,14 @@ sealed class ObjectWrapper {
.firstOrNull { it.code == code?.toInt() }
}
val spaceType: SpaceType?
get() {
val code = getValue<Double?>(Relations.SPACE_TYPE)
return SpaceType
.entries
.firstOrNull { it.code == code?.toInt() }
}
val writersLimit: Double? by default
val readersLimit: Double? by default

View file

@ -36,6 +36,7 @@ object Relations {
const val FEATURED_RELATIONS = "featuredRelations"
const val SNIPPET = "snippet"
const val SPACE_ID = "spaceId"
const val SPACE_TYPE = "spaceType"
const val TARGET_SPACE_ID = "targetSpaceId"
const val SET_OF = "setOf"
const val URL = "url"

View file

@ -55,6 +55,11 @@ enum class SpaceAccessType(val code: Int) {
SHARED(2)
}
enum class SpaceType(val code: Int) {
DATA(0),
CHAT(1)
}
sealed class SpaceInviteError : Exception() {
class SpaceDeleted : SpaceInviteError()
class InvalidInvite : SpaceInviteError()

View file

@ -6,6 +6,7 @@ object BundledWidgetSourceIds {
const val RECENT_LOCAL = "recentOpen"
const val BIN = "bin"
const val ALL_OBJECTS = "allObjects"
const val CHAT = "chat"
val ids = listOf(FAVORITE, RECENT, RECENT_LOCAL, BIN, ALL_OBJECTS)
val ids = listOf(FAVORITE, RECENT, RECENT_LOCAL, BIN, ALL_OBJECTS, CHAT)
}

View file

@ -273,6 +273,13 @@ class BundledWidgetSourceHolder(
ivIcon.setBackgroundResource(R.drawable.ic_widget_system_all_objects)
}
}
BundledWidgetSourceView.Chat -> {
with(binding) {
tvTitle.setText(R.string.chat)
tvSubtitle.gone()
// TODO DROID-3662 Set icon
}
}
}
}
}

View file

@ -108,6 +108,7 @@ interface SpaceViewSubscriptionContainer {
subscription = GLOBAL_SUBSCRIPTION,
keys = listOf(
Relations.ID,
Relations.SPACE_TYPE,
Relations.TARGET_SPACE_ID,
Relations.CHAT_ID,
Relations.SPACE_ACCOUNT_STATUS,

View file

@ -120,7 +120,8 @@ class ChatViewModel @Inject constructor(
icon = view.spaceIcon(
builder = urlBuilder,
spaceGradientProvider = SpaceGradientProvider.Default
)
),
showIcon = false
)
}.collect {
header.value = it
@ -1159,7 +1160,8 @@ class ChatViewModel @Inject constructor(
data object Init : HeaderView()
data class Default(
val icon: SpaceIconView,
val title: String
val title: String,
val showIcon: Boolean
) : HeaderView()
}

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -75,26 +76,30 @@ fun ChatTopToolbar(
textAlign = TextAlign.Center,
style = Title1
)
Box(
modifier = Modifier
.width(60.dp)
.fillMaxHeight()
.noRippleClickable {
onSpaceIconClicked()
}
) {
SpaceIconView(
modifier = Modifier.align(Alignment.Center),
mainSize = 28.dp,
icon = when(header) {
is ChatViewModel.HeaderView.Default -> header.icon
is ChatViewModel.HeaderView.Init -> SpaceIconView.Loading
},
onSpaceIconClick = {
onSpaceIconClicked()
}
if (header is ChatViewModel.HeaderView.Default && header.showIcon) {
Box(
modifier = Modifier
.width(60.dp)
.fillMaxHeight()
.noRippleClickable {
onSpaceIconClicked()
}
) {
SpaceIconView(
modifier = Modifier.align(Alignment.Center),
mainSize = 28.dp,
icon = header.icon,
onSpaceIconClick = {
onSpaceIconClicked()
}
)
}
} else {
Spacer(
modifier = Modifier.width(60.dp)
)
}
}
}
@ -104,7 +109,8 @@ fun ChatTopToolbarPreview() {
ChatTopToolbar(
header = ChatViewModel.HeaderView.Default(
title = LoremIpsum(words = 10).values.joinToString(),
icon = SpaceIconView.Placeholder(name = "Us")
icon = SpaceIconView.Placeholder(name = "Us"),
showIcon = true
),
onSpaceIconClicked = {},
onBackButtonClicked = {}

View file

@ -1666,6 +1666,12 @@ fun CoroutineScope.sendChangeWidgetSourceEvent(
BundledWidgetSourceView.AllObjects -> {
put(WidgetAnalytics.TYPE, WidgetAnalytics.WIDGET_SOURCE_ALL_OBJECTS)
}
BundledWidgetSourceView.AllObjects -> {
put(WidgetAnalytics.TYPE, WidgetAnalytics.WIDGET_SOURCE_CHAT)
}
BundledWidgetSourceView.Chat -> {
put(WidgetAnalytics.TYPE, WidgetAnalytics.WIDGET_SOURCE_CHAT)
}
}
if (isForNewWidget)
put(WidgetAnalytics.ROUTE, WidgetAnalytics.ROUTE_ADD_WIDGET)
@ -1769,6 +1775,9 @@ fun CoroutineScope.sendDeleteWidgetEvent(
Widget.Source.Bundled.AllObjects -> {
put(WidgetAnalytics.TYPE, WidgetAnalytics.WIDGET_SOURCE_ALL_OBJECTS)
}
Widget.Source.Bundled.Chat -> {
put(WidgetAnalytics.TYPE, WidgetAnalytics.WIDGET_SOURCE_CHAT)
}
}
if (isInEditMode)
put(WidgetAnalytics.CONTEXT, WidgetAnalytics.CONTEXT_EDITOR)
@ -1814,6 +1823,9 @@ fun CoroutineScope.sendClickWidgetTitleEvent(
Widget.Source.Bundled.AllObjects -> {
put(WidgetAnalytics.TAB, WidgetAnalytics.WIDGET_SOURCE_ALL_OBJECTS)
}
Widget.Source.Bundled.Chat -> {
put(WidgetAnalytics.TAB, WidgetAnalytics.WIDGET_SOURCE_CHAT)
}
}
isAutoCreated?.let {
@ -1902,6 +1914,9 @@ fun CoroutineScope.sendReorderWidgetEvent(
Widget.Source.Bundled.AllObjects -> {
put(WidgetAnalytics.TYPE, WidgetAnalytics.WIDGET_SOURCE_ALL_OBJECTS)
}
Widget.Source.Bundled.Chat -> {
put(WidgetAnalytics.TYPE, WidgetAnalytics.WIDGET_SOURCE_CHAT)
}
}
isAutoCreated?.let {

View file

@ -136,6 +136,7 @@ import com.anytypeio.anytype.presentation.widgets.WidgetId
import com.anytypeio.anytype.presentation.widgets.WidgetSessionStateHolder
import com.anytypeio.anytype.presentation.widgets.WidgetView
import com.anytypeio.anytype.presentation.widgets.collection.Subscription
import com.anytypeio.anytype.presentation.widgets.forceChatPosition
import com.anytypeio.anytype.presentation.widgets.hasValidLayout
import com.anytypeio.anytype.presentation.widgets.parseActiveViews
import com.anytypeio.anytype.presentation.widgets.parseWidgets
@ -503,7 +504,8 @@ class HomeScreenViewModel(
viewModelScope.launch {
widgets.filterNotNull().map { widgets ->
val currentlyDisplayedViews = views.value
widgets.filter { widget -> widget.hasValidLayout() }.map { widget ->
widgets.forceChatPosition().filter { widget -> widget.hasValidLayout() }.map { widget ->
when (widget) {
is Widget.Link -> LinkWidgetContainer(
widget = widget,
@ -738,6 +740,7 @@ class HomeScreenViewModel(
if (
dispatch.source == BundledWidgetSourceView.AllObjects.id
|| dispatch.source == BundledWidgetSourceView.Bin.id
|| dispatch.source == BundledWidgetSourceView.Chat.id
) {
// Applying link layout automatically to all-objects widget
proceedWithCreatingWidget(
@ -1105,6 +1108,26 @@ class HomeScreenViewModel(
)
}
}
is Widget.Source.Bundled.Chat -> {
viewModelScope.launch {
if (mode.value == InteractionMode.Edit) {
return@launch
}
val space = spaceManager.get()
val view = spaceViewSubscriptionContainer.get(SpaceId(space))
val chat = view?.chatId
if (chat != null) {
navigation(
Navigation.OpenChat(
ctx = chat,
space = space
)
)
} else {
Timber.w("Failed to open chat from widget: chat not found")
}
}
}
}
}
@ -2487,41 +2510,46 @@ class HomeScreenViewModel(
viewModelScope.launch {
val source = view.source
if (source is Widget.Source.Default) {
if (source.obj.layout == ObjectType.Layout.OBJECT_TYPE) {
val wrapper = ObjectWrapper.Type(source.obj.map)
val space = SpaceId(spaceManager.get())
val startTime = System.currentTimeMillis()
createObject.async(
params = CreateObject.Param(
space = space,
type = TypeKey(wrapper.uniqueKey),
prefilled = mapOf(Relations.IS_FAVORITE to true)
)
).onSuccess { result ->
sendAnalyticsObjectCreateEvent(
objType = wrapper.uniqueKey,
analytics = analytics,
route = EventsDictionary.Routes.widget,
startTime = startTime,
view = null,
spaceParams = provideParams(space.id)
)
proceedWithNavigation(result.obj.navigation())
when (source.obj.layout) {
ObjectType.Layout.OBJECT_TYPE -> {
val wrapper = ObjectWrapper.Type(source.obj.map)
val space = SpaceId(spaceManager.get())
val startTime = System.currentTimeMillis()
createObject.async(
params = CreateObject.Param(
space = space,
type = TypeKey(wrapper.uniqueKey),
prefilled = mapOf(Relations.IS_FAVORITE to true)
)
).onSuccess { result ->
sendAnalyticsObjectCreateEvent(
objType = wrapper.uniqueKey,
analytics = analytics,
route = EventsDictionary.Routes.widget,
startTime = startTime,
view = null,
spaceParams = provideParams(space.id)
)
proceedWithNavigation(result.obj.navigation())
}
}
ObjectType.Layout.COLLECTION -> {
onCreateDataViewObject(
widget = view.id,
view = null,
navigate = true
)
}
ObjectType.Layout.SET -> {
onCreateDataViewObject(
widget = view.id,
view = null,
navigate = true
)
}
else -> {
Timber.w("Unexpected source layout: ${source.obj.layout}")
}
} else if (source.obj.layout == ObjectType.Layout.COLLECTION) {
onCreateDataViewObject(
widget = view.id,
view = null,
navigate = true
)
} else if (source.obj.layout == ObjectType.Layout.SET) {
onCreateDataViewObject(
widget = view.id,
view = null,
navigate = true
)
} else {
Timber.w("Unexpected source layout: ${source.obj.layout}")
}
}
}

View file

@ -17,7 +17,6 @@ interface AppNavigation {
)
fun openChat(target: Id, space: Id)
fun openDocument(target: Id, space: Id)
fun openDiscussion(target: Id, space: Id)
fun openModalTemplateSelect(
template: Id,
templateTypeId: Id,
@ -98,7 +97,7 @@ interface AppNavigation {
val space: Id
) : Command()
object OpenSettings : Command()
data object OpenSettings : Command()
data class OpenShareScreen(
val space: SpaceId
@ -132,7 +131,7 @@ interface AppNavigation {
data class LaunchObjectSet(val target: Id, val space: Id) : Command()
object OpenUpdateAppScreen : Command()
data object OpenUpdateAppScreen : Command()
data class DeletedAccountScreen(val deadline: Long) : Command()

View file

@ -18,6 +18,7 @@ import com.anytypeio.anytype.core_models.ObjectTypeIds.COLLECTION
import com.anytypeio.anytype.core_models.SupportedLayouts
import com.anytypeio.anytype.core_models.exceptions.AccountMigrationNeededException
import com.anytypeio.anytype.core_models.exceptions.NeedToUpdateApplicationException
import com.anytypeio.anytype.core_models.multiplayer.SpaceType
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_models.primitives.TypeKey
import com.anytypeio.anytype.core_models.restrictions.SpaceStatus
@ -257,7 +258,7 @@ class SplashViewModel(
Command.NavigateToObjectSet(
id = target,
space = spaceId,
chat = view.chatId
chat = if (view.spaceType == SpaceType.CHAT) view.chatId else null
)
)
} else {
@ -265,7 +266,7 @@ class SplashViewModel(
Command.NavigateToObject(
id = target,
space = spaceId,
chat = view.chatId
chat = if (view.spaceType == SpaceType.CHAT) view.chatId else null
)
)
}
@ -317,7 +318,7 @@ class SplashViewModel(
Command.NavigateToObjectSet(
id = id,
space = space,
chat = view.chatId
chat = if (view.spaceType == SpaceType.CHAT) view.chatId else null
)
)
ObjectType.Layout.DATE -> {
@ -325,7 +326,7 @@ class SplashViewModel(
Command.NavigateToDateObject(
id = id,
space = space,
chat = view.chatId
chat = if (view.spaceType == SpaceType.CHAT) view.chatId else null
)
)
}
@ -334,7 +335,7 @@ class SplashViewModel(
Command.NavigateToObjectType(
id = id,
space = space,
chat = view.chatId
chat = if (view.spaceType == SpaceType.CHAT) view.chatId else null
)
)
}
@ -343,7 +344,7 @@ class SplashViewModel(
Command.NavigateToObject(
id = id,
space = space,
chat = view.chatId
chat = if (view.spaceType == SpaceType.CHAT) view.chatId else null
)
)
}
@ -398,7 +399,7 @@ class SplashViewModel(
deeplink = deeplink
)
)
} else {
} else if (view.spaceType == SpaceType.CHAT) {
commands.emit(
Command.NavigateToSpaceLevelChat(
space = space.id,

View file

@ -12,6 +12,7 @@ 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.Wallpaper
import com.anytypeio.anytype.core_models.multiplayer.SpaceType
import com.anytypeio.anytype.core_models.primitives.Space
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.domain.base.fold
@ -147,7 +148,8 @@ class VaultViewModel(
onSuccess = {
proceedWithSavingCurrentSpace(
targetSpace = targetSpace,
chat = view.space.chatId?.ifEmpty { null }
chat = view.space.chatId?.ifEmpty { null },
spaceType = view.space.spaceType
)
}
)
@ -252,7 +254,8 @@ class VaultViewModel(
private suspend fun proceedWithSavingCurrentSpace(
targetSpace: String,
chat: Id?
chat: Id?,
spaceType: SpaceType?
) {
saveCurrentSpace.async(
SaveCurrentSpace.Params(SpaceId(targetSpace))
@ -261,7 +264,7 @@ class VaultViewModel(
Timber.e(it, "Error while saving current space on vault screen")
},
onSuccess = {
if (chat != null && ChatConfig.isChatAllowed(space = targetSpace)) {
if (spaceType == SpaceType.CHAT && chat != null && ChatConfig.isChatAllowed(space = targetSpace)) {
commands.emit(
Command.EnterSpaceLevelChat(
space = Space(targetSpace),

View file

@ -130,6 +130,9 @@ class SelectWidgetSourceViewModel(
if (contains(BundledWidgetSourceIds.ALL_OBJECTS)) {
add(BundledWidgetSourceView.AllObjects)
}
if (contains(BundledWidgetSourceIds.CHAT)) {
add(BundledWidgetSourceView.Chat)
}
if (contains(BundledWidgetSourceIds.RECENT)) {
add(BundledWidgetSourceView.Recent)
}
@ -270,6 +273,7 @@ class SelectWidgetSourceViewModel(
if (
view is BundledWidgetSourceView.AllObjects
|| view is BundledWidgetSourceView.Bin
|| view is BundledWidgetSourceView.Chat
) {
isDismissed.value = true
}

View file

@ -109,10 +109,24 @@ sealed class Widget {
override val id: Id = BundledWidgetSourceIds.ALL_OBJECTS
override val type: Id? = null
}
data object Chat : Bundled() {
override val id: Id = BundledWidgetSourceIds.CHAT
override val type: Id? = null
}
}
}
}
fun List<Widget>.forceChatPosition(): List<Widget> {
// Partition the list into chat widgets and the rest
val (chatWidgets, otherWidgets) = partition { widget ->
widget.source is Widget.Source.Bundled.Chat
}
// Place chat widgets first, followed by the others
return chatWidgets + otherWidgets
}
fun Widget.hasValidLayout(): Boolean = when (val widgetSource = source) {
is Widget.Source.Default -> isSupportedForWidgets(widgetSource.obj.layout)
is Widget.Source.Bundled -> true
@ -246,6 +260,7 @@ fun Id.bundled() : Widget.Source.Bundled = when (this) {
BundledWidgetSourceIds.FAVORITE -> Widget.Source.Bundled.Favorites
BundledWidgetSourceIds.BIN -> Widget.Source.Bundled.Bin
BundledWidgetSourceIds.ALL_OBJECTS -> Widget.Source.Bundled.AllObjects
BundledWidgetSourceIds.CHAT -> Widget.Source.Bundled.Chat
else -> throw IllegalStateException("Widget bundled id can't be $this")
}

View file

@ -78,6 +78,7 @@ sealed class WidgetView {
val canCreateObjectOfType : Boolean get() {
return when(source) {
Widget.Source.Bundled.AllObjects -> false
Widget.Source.Bundled.Chat -> false
Widget.Source.Bundled.Bin -> false
Widget.Source.Bundled.Favorites -> true
Widget.Source.Bundled.Recent -> false

View file

@ -29,6 +29,10 @@ sealed class BundledWidgetSourceView : DefaultSearchItem {
data object AllObjects : BundledWidgetSourceView() {
override val id: Id get() = BundledWidgetSourceIds.ALL_OBJECTS
}
data object Chat : BundledWidgetSourceView() {
override val id: Id get() = BundledWidgetSourceIds.CHAT
}
}
data class SuggestWidgetObjectType(