Add analytics to threads

This commit is contained in:
ariskotsomitopoulos 2022-02-28 17:13:06 +02:00
parent eda723c230
commit e59f2bba0a
15 changed files with 189 additions and 16 deletions

View File

@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.session.room.timeline package org.matrix.android.sdk.api.session.room.timeline
import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.BuildConfig
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
@ -159,6 +160,13 @@ fun TimelineEvent.isSticker(): Boolean {
return root.isSticker() return root.isSticker()
} }
/**
* Returns whether or not the event is a root thread event
*/
fun TimelineEvent.isRootThread(): Boolean {
return root.threadDetails?.isRootThread.orFalse()
}
/** /**
* Get the latest message body, after a possible edition, stripping the reply prefix if necessary * Get the latest message body, after a possible edition, stripping the reply prefix if necessary
*/ */

View File

@ -0,0 +1,28 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.analytics.extensions
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.home.room.detail.composer.MessageComposerViewState
import im.vector.app.features.home.room.detail.composer.SendMode
fun MessageComposerViewState.toAnalyticsComposer(): Composer =
Composer(
inThread = isInThreadTimeline(),
isEditing = sendMode is SendMode.Edit,
isReply = sendMode is SendMode.Reply,
startsThread = startsThread)

View File

@ -0,0 +1,24 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.analytics.extensions
import im.vector.app.features.analytics.plan.Interaction
fun Interaction.Name.toAnalyticsInteraction(interactionType: Interaction.InteractionType = Interaction.InteractionType.Touch) =
Interaction(
name = this,
interactionType = interactionType)

View File

@ -39,6 +39,10 @@ data class Composer(
* sent event. * sent event.
*/ */
val isReply: Boolean, val isReply: Boolean,
/**
* Whether this message begins a new thread or not.
*/
val startsThread: Boolean? = null,
) : VectorAnalyticsEvent { ) : VectorAnalyticsEvent {
override fun getName() = "Composer" override fun getName() = "Composer"
@ -48,6 +52,7 @@ data class Composer(
put("inThread", inThread) put("inThread", inThread)
put("isEditing", isEditing) put("isEditing", isEditing)
put("isReply", isReply) put("isReply", isReply)
startsThread?.let { put("startsThread", it) }
}.takeIf { it.isNotEmpty() } }.takeIf { it.isNotEmpty() }
} }
} }

View File

@ -40,6 +40,36 @@ data class Interaction(
) : VectorAnalyticsEvent { ) : VectorAnalyticsEvent {
enum class Name { enum class Name {
/**
* User tapped on Add to Home button on Room Details screen.
*/
MobileRoomAddHome,
/**
* User tapped on Leave Room button on Room Details screen.
*/
MobileRoomLeave,
/**
* User tapped on Threads button on Room screen.
*/
MobileRoomThreadListButton,
/**
* User tapped on a thread summary item on Room screen.
*/
MobileRoomThreadSummaryItem,
/**
* User tapped on the filter button on ThreadList screen.
*/
MobileThreadListFilterItem,
/**
* User selected a thread on ThreadList screen.
*/
MobileThreadListThreadItem,
/** /**
* User tapped the already selected space from the space list. * User tapped the already selected space from the space list.
*/ */
@ -52,8 +82,8 @@ data class Interaction(
SpacePanelSwitchSpace, SpacePanelSwitchSpace,
/** /**
* User clicked the create room button in the + context menu of the room * User clicked the create room button in the add existing room to space
* list header in Element Web/Desktop. * dialog in Element Web/Desktop.
*/ */
WebAddExistingToSpaceDialogCreateRoomButton, WebAddExistingToSpaceDialogCreateRoomButton,
@ -105,12 +135,24 @@ data class Interaction(
*/ */
WebRightPanelRoomUserInfoInviteButton, WebRightPanelRoomUserInfoInviteButton,
/**
* User clicked the threads 'show' filter dropdown in the threads panel
* in Element Web/Desktop.
*/
WebRightPanelThreadPanelFilterDropdown,
/** /**
* User clicked the create room button in the room directory of Element * User clicked the create room button in the room directory of Element
* Web/Desktop. * Web/Desktop.
*/ */
WebRoomDirectoryCreateRoomButton, WebRoomDirectoryCreateRoomButton,
/**
* User clicked the Threads button in the top right of a room in Element
* Web/Desktop.
*/
WebRoomHeaderButtonsThreadsButton,
/** /**
* User adjusted their favourites using the context menu on the header * User adjusted their favourites using the context menu on the header
* of a room in Element Web/Desktop. * of a room in Element Web/Desktop.
@ -153,6 +195,12 @@ data class Interaction(
*/ */
WebRoomListHeaderPlusMenuCreateRoomItem, WebRoomListHeaderPlusMenuCreateRoomItem,
/**
* User clicked the explore rooms button in the + context menu of the
* room list header in Element Web/Desktop.
*/
WebRoomListHeaderPlusMenuExploreRoomsItem,
/** /**
* User adjusted their favourites using the context menu on a room tile * User adjusted their favourites using the context menu on a room tile
* in the room list in Element Web/Desktop. * in the room list in Element Web/Desktop.
@ -189,6 +237,12 @@ data class Interaction(
*/ */
WebRoomListRoomsSublistPlusMenuCreateRoomItem, WebRoomListRoomsSublistPlusMenuCreateRoomItem,
/**
* User clicked the explore rooms button in the + context menu of the
* rooms sublist in Element Web/Desktop.
*/
WebRoomListRoomsSublistPlusMenuExploreRoomsItem,
/** /**
* User interacted with leave action in the general tab of the room * User interacted with leave action in the general tab of the room
* settings dialog in Element Web/Desktop. * settings dialog in Element Web/Desktop.
@ -201,6 +255,12 @@ data class Interaction(
*/ */
WebRoomSettingsSecurityTabCreateNewRoomButton, WebRoomSettingsSecurityTabCreateNewRoomButton,
/**
* User clicked a thread summary in the timeline of a room in Element
* Web/Desktop.
*/
WebRoomTimelineThreadSummaryButton,
/** /**
* User interacted with the theme radio selector in the Appearance tab * User interacted with the theme radio selector in the Appearance tab
* of Settings in Element Web/Desktop. * of Settings in Element Web/Desktop.
@ -214,17 +274,40 @@ data class Interaction(
WebSettingsSidebarTabSpacesCheckbox, WebSettingsSidebarTabSpacesCheckbox,
/** /**
* User clicked the create room button in the + context menu of the room * User clicked the explore rooms button in the context menu of a space
* list header in Element Web/Desktop. * in Element Web/Desktop.
*/
WebSpaceContextMenuExploreRoomsItem,
/**
* User clicked the home button in the context menu of a space in
* Element Web/Desktop.
*/
WebSpaceContextMenuHomeItem,
/**
* User clicked the new room button in the context menu of a space in
* Element Web/Desktop.
*/ */
WebSpaceContextMenuNewRoomItem, WebSpaceContextMenuNewRoomItem,
/** /**
* User clicked the create room button in the + context menu of the room * User clicked the new room button in the context menu on the space
* list header in Element Web/Desktop. * home in Element Web/Desktop.
*/ */
WebSpaceHomeCreateRoomButton, WebSpaceHomeCreateRoomButton,
/**
* User clicked the back button on a Thread view going back to the
* Threads Panel of Element Web/Desktop.
*/
WebThreadViewBackButton,
/**
* User selected a thread in the Threads panel in Element Web/Desktop.
*/
WebThreadsPanelThreadItem,
/** /**
* User clicked the theme toggle button in the user menu of Element * User clicked the theme toggle button in the user menu of Element
* Web/Desktop. * Web/Desktop.

View File

@ -225,6 +225,11 @@ data class MobileScreen(
*/ */
SwitchDirectory, SwitchDirectory,
/**
* Screen that displays list of threads for a room
*/
ThreadList,
/** /**
* A screen that shows information about a room member. * A screen that shows information about a room member.
*/ */

View File

@ -119,7 +119,8 @@ import im.vector.app.core.utils.startInstallFromSourceIntent
import im.vector.app.core.utils.toast import im.vector.app.core.utils.toast
import im.vector.app.databinding.DialogReportContentBinding import im.vector.app.databinding.DialogReportContentBinding
import im.vector.app.databinding.FragmentTimelineBinding import im.vector.app.databinding.FragmentTimelineBinding
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.extensions.toAnalyticsInteraction
import im.vector.app.features.analytics.plan.Interaction
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.attachments.AttachmentTypeSelectorView import im.vector.app.features.attachments.AttachmentTypeSelectorView
import im.vector.app.features.attachments.AttachmentsHelper import im.vector.app.features.attachments.AttachmentsHelper
@ -1505,9 +1506,6 @@ class TimelineFragment @Inject constructor(
return return
} }
if (text.isNotBlank()) { if (text.isNotBlank()) {
withState(messageComposerViewModel) { state ->
analyticsTracker.capture(Composer(isThreadTimeLine(), isEditing = state.sendMode is SendMode.Edit, isReply = state.sendMode is SendMode.Reply))
}
// We collapse ASAP, if not there will be a slight annoying delay // We collapse ASAP, if not there will be a slight annoying delay
views.composerLayout.collapse(true) views.composerLayout.collapse(true)
lockSendButton = true lockSendButton = true
@ -2204,7 +2202,7 @@ class TimelineFragment @Inject constructor(
} }
is EventSharedAction.ReplyInThread -> { is EventSharedAction.ReplyInThread -> {
if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) {
navigateToThreadTimeline(action.eventId) navigateToThreadTimeline(action.eventId, action.startsThread)
} else { } else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
} }
@ -2363,9 +2361,11 @@ class TimelineFragment @Inject constructor(
* using the ThreadsActivity * using the ThreadsActivity
*/ */
private fun navigateToThreadTimeline(rootThreadEventId: String) { private fun navigateToThreadTimeline(rootThreadEventId: String, startsThread: Boolean = false) {
analyticsTracker.capture(Interaction.Name.MobileRoomThreadSummaryItem.toAnalyticsInteraction())
context?.let { context?.let {
val roomThreadDetailArgs = ThreadTimelineArgs( val roomThreadDetailArgs = ThreadTimelineArgs(
startsThread = startsThread,
roomId = timelineArgs.roomId, roomId = timelineArgs.roomId,
displayName = timelineViewModel.getRoomSummary()?.displayName, displayName = timelineViewModel.getRoomSummary()?.displayName,
avatarUrl = timelineViewModel.getRoomSummary()?.avatarUrl, avatarUrl = timelineViewModel.getRoomSummary()?.avatarUrl,
@ -2381,6 +2381,7 @@ class TimelineFragment @Inject constructor(
*/ */
private fun navigateToThreadList() { private fun navigateToThreadList() {
analyticsTracker.capture(Interaction.Name.MobileRoomThreadListButton.toAnalyticsInteraction())
context?.let { context?.let {
val roomThreadDetailArgs = ThreadTimelineArgs( val roomThreadDetailArgs = ThreadTimelineArgs(
roomId = timelineArgs.roomId, roomId = timelineArgs.roomId,

View File

@ -27,6 +27,7 @@ import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.AnalyticsTracker
import im.vector.app.features.analytics.extensions.toAnalyticsComposer
import im.vector.app.features.analytics.extensions.toAnalyticsJoinedRoom import im.vector.app.features.analytics.extensions.toAnalyticsJoinedRoom
import im.vector.app.features.attachments.toContentAttachmentData import im.vector.app.features.attachments.toContentAttachmentData
import im.vector.app.features.command.CommandParser import im.vector.app.features.command.CommandParser
@ -188,6 +189,9 @@ class MessageComposerViewModel @AssistedInject constructor(
private fun handleSendMessage(action: MessageComposerAction.SendMessage) { private fun handleSendMessage(action: MessageComposerAction.SendMessage) {
withState { state -> withState { state ->
analyticsTracker.capture(state.toAnalyticsComposer()).also {
setState { copy(startsThread = false) }
}
when (state.sendMode) { when (state.sendMode) {
is SendMode.Regular -> { is SendMode.Regular -> {
when (val slashCommandResult = commandParser.parseSlashCommand( when (val slashCommandResult = commandParser.parseSlashCommand(

View File

@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.composer
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/** /**
@ -62,6 +63,7 @@ data class MessageComposerViewState(
val canSendMessage: CanSendStatus = CanSendStatus.Allowed, val canSendMessage: CanSendStatus = CanSendStatus.Allowed,
val isSendButtonVisible: Boolean = false, val isSendButtonVisible: Boolean = false,
val rootThreadEventId: String? = null, val rootThreadEventId: String? = null,
val startsThread: Boolean = false,
val sendMode: SendMode = SendMode.Regular("", false), val sendMode: SendMode = SendMode.Regular("", false),
val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle
) : MavericksState { ) : MavericksState {
@ -80,6 +82,7 @@ data class MessageComposerViewState(
constructor(args: TimelineArgs) : this( constructor(args: TimelineArgs) : this(
roomId = args.roomId, roomId = args.roomId,
startsThread = args.threadTimelineArgs?.startsThread.orFalse(),
rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId) rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId)
fun isInThreadTimeline(): Boolean = rootThreadEventId != null fun isInThreadTimeline(): Boolean = rootThreadEventId != null

View File

@ -48,7 +48,7 @@ sealed class EventSharedAction(@StringRes val titleRes: Int,
data class Reply(val eventId: String) : data class Reply(val eventId: String) :
EventSharedAction(R.string.reply, R.drawable.ic_reply) EventSharedAction(R.string.reply, R.drawable.ic_reply)
data class ReplyInThread(val eventId: String) : data class ReplyInThread(val eventId: String, val startsThread: Boolean) :
EventSharedAction(R.string.reply_in_thread, R.drawable.ic_reply_in_thread) EventSharedAction(R.string.reply_in_thread, R.drawable.ic_reply_in_thread)
object ViewInRoom : object ViewInRoom :

View File

@ -61,6 +61,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited
import org.matrix.android.sdk.api.session.room.timeline.isPoll import org.matrix.android.sdk.api.session.room.timeline.isPoll
import org.matrix.android.sdk.api.session.room.timeline.isRootThread
import org.matrix.android.sdk.api.session.room.timeline.isSticker import org.matrix.android.sdk.api.session.room.timeline.isSticker
import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.flow
import org.matrix.android.sdk.flow.unwrap import org.matrix.android.sdk.flow.unwrap
@ -328,7 +329,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
} }
if (canReplyInThread(timelineEvent, messageContent, actionPermissions)) { if (canReplyInThread(timelineEvent, messageContent, actionPermissions)) {
add(EventSharedAction.ReplyInThread(eventId)) add(EventSharedAction.ReplyInThread(eventId, !timelineEvent.isRootThread()))
} }
if (canViewInRoom(timelineEvent, messageContent, actionPermissions)) { if (canViewInRoom(timelineEvent, messageContent, actionPermissions)) {

View File

@ -26,6 +26,8 @@ import im.vector.app.core.extensions.addFragmentToBackstack
import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityThreadsBinding import im.vector.app.databinding.ActivityThreadsBinding
import im.vector.app.features.analytics.extensions.toAnalyticsInteraction
import im.vector.app.features.analytics.plan.Interaction
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.TimelineFragment import im.vector.app.features.home.room.detail.TimelineFragment
import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.arguments.TimelineArgs
@ -92,6 +94,7 @@ class ThreadsActivity : VectorBaseActivity<ActivityThreadsBinding>() {
* One usage of that is from the Threads Activity * One usage of that is from the Threads Activity
*/ */
fun navigateToThreadTimeline(threadTimelineArgs: ThreadTimelineArgs) { fun navigateToThreadTimeline(threadTimelineArgs: ThreadTimelineArgs) {
analyticsTracker.capture(Interaction.Name.MobileThreadListThreadItem.toAnalyticsInteraction())
val commonOption: (FragmentTransaction) -> Unit = { val commonOption: (FragmentTransaction) -> Unit = {
it.setCustomAnimations( it.setCustomAnimations(
R.anim.animation_slide_in_right, R.anim.animation_slide_in_right,

View File

@ -26,5 +26,6 @@ data class ThreadTimelineArgs(
val displayName: String?, val displayName: String?,
val avatarUrl: String?, val avatarUrl: String?,
val roomEncryptionTrustLevel: RoomEncryptionTrustLevel?, val roomEncryptionTrustLevel: RoomEncryptionTrustLevel?,
val rootThreadEventId: String? = null val rootThreadEventId: String? = null,
val startsThread: Boolean = false
) : Parcelable ) : Parcelable

View File

@ -25,6 +25,9 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.platform.EmptyAction import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.analytics.AnalyticsTracker
import im.vector.app.features.analytics.extensions.toAnalyticsInteraction
import im.vector.app.features.analytics.plan.Interaction
import im.vector.app.features.home.room.threads.list.views.ThreadListFragment import im.vector.app.features.home.room.threads.list.views.ThreadListFragment
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -34,6 +37,7 @@ import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent
import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.flow
class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState: ThreadListViewState, class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState: ThreadListViewState,
private val analyticsTracker: AnalyticsTracker,
private val session: Session) : private val session: Session) :
VectorViewModel<ThreadListViewState, EmptyAction, EmptyViewEvents>(initialState) { VectorViewModel<ThreadListViewState, EmptyAction, EmptyViewEvents>(initialState) {
@ -113,9 +117,10 @@ class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState
} }
} }
fun canHomeserverUseThreading() = session.getHomeServerCapabilities().canUseThreading fun canHomeserverUseThreading() = session.getHomeServerCapabilities().canUseThreading
fun applyFiltering(shouldFilterThreads: Boolean) { fun applyFiltering(shouldFilterThreads: Boolean) {
analyticsTracker.capture(Interaction.Name.MobileThreadListFilterItem.toAnalyticsInteraction())
setState { setState {
copy(shouldFilterThreads = shouldFilterThreads) copy(shouldFilterThreads = shouldFilterThreads)
} }

View File

@ -30,6 +30,7 @@ import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentThreadListBinding import im.vector.app.databinding.FragmentThreadListBinding
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.animation.TimelineItemAnimator import im.vector.app.features.home.room.detail.timeline.animation.TimelineItemAnimator
import im.vector.app.features.home.room.threads.ThreadsActivity import im.vector.app.features.home.room.threads.ThreadsActivity
@ -62,6 +63,7 @@ class ThreadListFragment @Inject constructor(
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
analyticsScreenName = MobileScreen.ScreenName.ThreadList
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {