diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 5284af15a5..8c0616431b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.PinUnpinAction import io.element.android.appconfig.MessageComposerConfig import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.features.messages.impl.actionlist.ActionListEvents @@ -77,6 +78,7 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.room.canCall import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -104,6 +106,7 @@ class MessagesPresenter @AssistedInject constructor( private val buildMeta: BuildMeta, private val timelineController: TimelineController, private val permalinkParser: PermalinkParser, + private val analyticsService: AnalyticsService, ) : Presenter { private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator) private val actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default) @@ -285,6 +288,12 @@ class MessagesPresenter @AssistedInject constructor( private suspend fun handlePinAction(targetEvent: TimelineItem.Event) { if (targetEvent.eventId == null) return + analyticsService.capture( + PinUnpinAction( + from = PinUnpinAction.From.Timeline, + kind = PinUnpinAction.Kind.Pin, + ) + ) timelineController.invokeOnCurrentTimeline { pinEvent(targetEvent.eventId) .onFailure { @@ -296,6 +305,12 @@ class MessagesPresenter @AssistedInject constructor( private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) { if (targetEvent.eventId == null) return + analyticsService.capture( + PinUnpinAction( + from = PinUnpinAction.From.Timeline, + kind = PinUnpinAction.Kind.Unpin, + ) + ) timelineController.invokeOnCurrentTimeline { unpinEvent(targetEvent.eventId) .onFailure { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt index d9a8d48476..ed7e8be652 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import im.vector.app.features.analytics.plan.Interaction import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementPreview @@ -51,6 +52,8 @@ import io.element.android.libraries.designsystem.theme.pinnedMessageBannerIndica import io.element.android.libraries.designsystem.utils.annotatedTextWithBold import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @Composable fun PinnedMessagesBannerView( @@ -79,6 +82,7 @@ private fun PinnedMessagesBannerRow( onViewAllClick: () -> Unit, modifier: Modifier = Modifier, ) { + val analyticsService = LocalAnalyticsService.current val borderColor = ElementTheme.colors.pinnedMessageBannerBorder Row( modifier = modifier @@ -88,6 +92,7 @@ private fun PinnedMessagesBannerRow( .heightIn(min = 64.dp) .clickable { if (state is PinnedMessagesBannerState.Loaded) { + analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerClick) onClick(state.currentPinnedMessage.eventId) state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned) } @@ -112,7 +117,13 @@ private fun PinnedMessagesBannerRow( message = state.formattedMessage(), modifier = Modifier.weight(1f) ) - ViewAllButton(state, onViewAllClick) + ViewAllButton( + state = state, + onViewAllClick = { + onViewAllClick() + analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerViewAllButton) + }, + ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 1525a8a5e1..7dcd3ecfaa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -20,6 +20,8 @@ import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.Interaction +import im.vector.app.features.analytics.plan.PinUnpinAction import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction @@ -39,6 +41,8 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview @@ -58,6 +62,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( private val snackbarDispatcher: SnackbarDispatcher, actionListPresenterFactory: ActionListPresenter.Factory, private val appCoroutineScope: CoroutineScope, + private val analyticsService: AnalyticsService, ) : Presenter { @AssistedFactory interface Factory { @@ -128,6 +133,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor( TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.ViewInTimeline -> { targetEvent.eventId?.let { eventId -> + analyticsService.captureInteraction(Interaction.Name.PinnedMessageListViewTimeline) navigator.onViewInTimelineClick(eventId) } } @@ -137,6 +143,12 @@ class PinnedMessagesListPresenter @AssistedInject constructor( private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) { if (targetEvent.eventId == null) return + analyticsService.capture( + PinUnpinAction( + from = PinUnpinAction.From.MessagePinningList, + kind = PinUnpinAction.Kind.Unpin, + ) + ) timelineProvider.invokeOnTimeline { unpinEvent(targetEvent.eventId) .onFailure { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 2562de95f8..7c1e6a724b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import im.vector.app.features.analytics.plan.Interaction import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListView @@ -44,6 +45,8 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @Composable fun PinnedMessagesListView( @@ -57,7 +60,14 @@ fun PinnedMessagesListView( Scaffold( modifier = modifier, topBar = { - PinnedMessagesListTopBar(state, onBackClick) + val analyticsService = LocalAnalyticsService.current + PinnedMessagesListTopBar( + state = state, + onBackClick = { + analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerCloseListButton) + onBackClick() + } + ) }, content = { padding -> PinnedMessagesListContent( @@ -67,8 +77,8 @@ fun PinnedMessagesListView( onLinkClick = onLinkClick, onErrorDismiss = onBackClick, modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding), + .padding(padding) + .consumeWindowInsets(padding), ) } ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 3d6af28f3d..0676efd258 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -13,6 +13,7 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.PinUnpinAction import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction @@ -896,6 +897,7 @@ class MessagesPresenterTest { fun `present - handle action pin`() = runTest { val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) } val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure(A_THROWABLE) } + val analyticsService = FakeAnalyticsService() val timeline = FakeTimeline() val room = FakeMatrixRoom( liveTimeline = timeline, @@ -906,7 +908,7 @@ class MessagesPresenterTest { typingNoticeResult = { Result.success(Unit) }, canUserPinUnpinResult = { Result.success(true) }, ) - val presenter = createMessagesPresenter(matrixRoom = room) + val presenter = createMessagesPresenter(matrixRoom = room, analyticsService = analyticsService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -923,6 +925,10 @@ class MessagesPresenterTest { initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent)) assert(failurePinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) assertThat(awaitItem().snackbarMessage).isNotNull() + assertThat(analyticsService.capturedEvents).containsExactly( + PinUnpinAction(kind = PinUnpinAction.Kind.Pin, from = PinUnpinAction.From.Timeline), + PinUnpinAction(kind = PinUnpinAction.Kind.Pin, from = PinUnpinAction.From.Timeline) + ) } } @@ -931,6 +937,7 @@ class MessagesPresenterTest { val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) } val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure(A_THROWABLE) } val timeline = FakeTimeline() + val analyticsService = FakeAnalyticsService() val room = FakeMatrixRoom( liveTimeline = timeline, canUserSendMessageResult = { _, _ -> Result.success(true) }, @@ -940,7 +947,7 @@ class MessagesPresenterTest { typingNoticeResult = { Result.success(Unit) }, canUserPinUnpinResult = { Result.success(true) }, ) - val presenter = createMessagesPresenter(matrixRoom = room) + val presenter = createMessagesPresenter(matrixRoom = room, analyticsService = analyticsService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -957,6 +964,10 @@ class MessagesPresenterTest { initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent)) assert(failureUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) assertThat(awaitItem().snackbarMessage).isNotNull() + assertThat(analyticsService.capturedEvents).containsExactly( + PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.Timeline), + PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.Timeline) + ) } } @@ -1074,6 +1085,7 @@ class MessagesPresenterTest { htmlConverterProvider = FakeHtmlConverterProvider(), timelineController = TimelineController(matrixRoom), permalinkParser = permalinkParser, + analyticsService = analyticsService, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt index efbe67a337..b7c3a09c5c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -8,6 +8,7 @@ package io.element.android.features.messages.impl.pinned.list import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.PinUnpinAction import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator @@ -30,6 +31,8 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -142,7 +145,7 @@ class PinnedMessagesListPresenterTest { val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) } val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure(A_THROWABLE) } val pinnedEventsTimeline = createPinnedMessagesTimeline() - + val analyticsService = FakeAnalyticsService() val room = FakeMatrixRoom( pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }, canRedactOwnResult = { Result.success(true) }, @@ -151,7 +154,7 @@ class PinnedMessagesListPresenterTest { ).apply { givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) } - val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true) + val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true, analyticsService = analyticsService) presenter.test { skipItems(3) val filledState = awaitItem() as PinnedMessagesListState.Filled @@ -174,6 +177,11 @@ class PinnedMessagesListPresenterTest { assert(failureUnpinEventLambda) .isCalledOnce() .with(value(AN_EVENT_ID)) + + assertThat(analyticsService.capturedEvents).containsExactly( + PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.MessagePinningList), + PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.MessagePinningList) + ) } } @@ -286,6 +294,7 @@ class PinnedMessagesListPresenterTest { room: MatrixRoom = FakeMatrixRoom(), networkMonitor: NetworkMonitor = FakeNetworkMonitor(), isFeatureEnabled: Boolean = true, + analyticsService: AnalyticsService = FakeAnalyticsService(), ): PinnedMessagesListPresenter { val timelineProvider = PinnedEventsTimelineProvider( room = room, @@ -302,6 +311,7 @@ class PinnedMessagesListPresenterTest { timelineProvider = timelineProvider, snackbarDispatcher = SnackbarDispatcher(), actionListPresenterFactory = FakeActionListPresenter.Factory, + analyticsService = analyticsService, appCoroutineScope = this, ) } diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 6fb5a7f714..2998a82dc5 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -50,7 +50,7 @@ dependencies { implementation(projects.features.createroom.api) implementation(projects.features.leaveroom.api) implementation(projects.features.userprofile.shared) - implementation(projects.services.analytics.api) + implementation(projects.services.analytics.compose) implementation(projects.features.poll.api) implementation(projects.features.messages.api) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index b28749a994..b3b017f0e3 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import im.vector.app.features.analytics.plan.Interaction import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.leaveroom.api.LeaveRoomView @@ -80,6 +81,8 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList @@ -111,9 +114,9 @@ fun RoomDetailsView( ) { padding -> Column( modifier = Modifier - .padding(padding) - .verticalScroll(rememberScrollState()) - .consumeWindowInsets(padding) + .padding(padding) + .verticalScroll(rememberScrollState()) + .consumeWindowInsets(padding) ) { LeaveRoomView(state = state.leaveRoomState) @@ -270,8 +273,8 @@ private fun MainActionsSection( ) { Row( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .fillMaxWidth() + .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly, ) { val roomNotificationSettings = state.roomNotificationSettings @@ -330,8 +333,8 @@ private fun RoomHeaderSection( ) { Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .fillMaxWidth() + .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { CompositeAvatar( @@ -340,8 +343,8 @@ private fun RoomHeaderSection( user.getAvatarData(size = AvatarSize.RoomHeader) }.toPersistentList(), modifier = Modifier - .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } - .testTag(TestTags.roomDetailAvatar) + .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } + .testTag(TestTags.roomDetailAvatar) ) TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value) } @@ -357,8 +360,8 @@ private fun DmHeaderSection( ) { Column( modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .fillMaxWidth() + .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { DmAvatars( @@ -509,6 +512,7 @@ private fun PinnedMessagesItem( pinnedMessagesCount: Int?, onPinnedMessagesClick: () -> Unit, ) { + val analyticsService = LocalAnalyticsService.current ListItem( headlineContent = { Text(stringResource(CommonStrings.screen_room_details_pinned_events_row_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())), @@ -520,7 +524,10 @@ private fun PinnedMessagesItem( } else { ListItemContent.Text(pinnedMessagesCount.toString()) }, - onClick = onPinnedMessagesClick, + onClick = { + analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton) + onPinnedMessagesClick() + } ) } diff --git a/services/analytics/compose/build.gradle.kts b/services/analytics/compose/build.gradle.kts index ab6c1244f4..931abc55ab 100644 --- a/services/analytics/compose/build.gradle.kts +++ b/services/analytics/compose/build.gradle.kts @@ -10,7 +10,6 @@ plugins { android { namespace = "io.element.android.services.analytics.compose" - } dependencies { api(projects.services.analytics.api)