Polls analytics (#1285)

Send poll analytics event (creation, vote, end) where appropriate.
This commit is contained in:
Marco Romano 2023-09-12 16:03:40 +02:00 committed by GitHub
parent aeaf3409bf
commit 5b018fd308
8 changed files with 90 additions and 20 deletions

View file

@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -74,6 +75,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.room.canRedactAsState import io.element.android.libraries.matrix.ui.room.canRedactAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -92,6 +94,7 @@ class MessagesPresenter @AssistedInject constructor(
private val messageSummaryFormatter: MessageSummaryFormatter, private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val clipboardHelper: ClipboardHelper, private val clipboardHelper: ClipboardHelper,
private val analyticsService: AnalyticsService,
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
) : Presenter<MessagesState> { ) : Presenter<MessagesState> {
@ -321,8 +324,10 @@ class MessagesPresenter @AssistedInject constructor(
} }
private suspend fun handleEndPollAction(event: TimelineItem.Event) { private suspend fun handleEndPollAction(event: TimelineItem.Event) {
event.eventId?.let { room.endPoll(it, "The poll with event id: $it has ended.") } event.eventId?.let {
// TODO Polls: Send poll end analytic room.endPoll(it, "The poll with event id: $it has ended.")
analyticsService.capture(PollEnd())
}
} }
private suspend fun handleCopyContents(event: TimelineItem.Event) { private suspend fun handleCopyContents(event: TimelineItem.Event) {

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -35,6 +36,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -51,6 +53,7 @@ class TimelinePresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope, private val appScope: CoroutineScope,
private val analyticsService: AnalyticsService,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val timeline = room.timeline private val timeline = room.timeline
@ -93,7 +96,7 @@ class TimelinePresenter @Inject constructor(
pollStartId = event.pollStartId, pollStartId = event.pollStartId,
answers = listOf(event.answerId), answers = listOf(event.answerId),
) )
// TODO Polls: Send poll vote analytic analyticsService.capture(PollVote())
} }
} }
} }

View file

@ -21,6 +21,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.messages.fixtures.aMessageEvent import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.InviteDialogAction import io.element.android.features.messages.impl.InviteDialogAction
@ -575,7 +576,11 @@ class MessagesPresenterTest {
@Test @Test
fun `present - handle poll end`() = runTest { fun `present - handle poll end`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = createMessagePresenter(matrixRoom = room) val analyticsService = FakeAnalyticsService()
val presenter = createMessagePresenter(
matrixRoom = room,
analyticsService = analyticsService,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -586,7 +591,8 @@ class MessagesPresenterTest {
assertThat(room.endPollInvocations.size).isEqualTo(1) assertThat(room.endPollInvocations.size).isEqualTo(1)
assertThat(room.endPollInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID) assertThat(room.endPollInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
assertThat(room.endPollInvocations.first().text).isEqualTo("The poll with event id: \$anEventId has ended.") assertThat(room.endPollInvocations.first().text).isEqualTo("The poll with event id: \$anEventId has ended.")
// TODO Polls: Test poll end analytic assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollEnd())
} }
} }
@ -595,6 +601,7 @@ class MessagesPresenterTest {
matrixRoom: MatrixRoom = FakeMatrixRoom(), matrixRoom: MatrixRoom = FakeMatrixRoom(),
navigator: FakeMessagesNavigator = FakeMessagesNavigator(), navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): MessagesPresenter { ): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter( val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this, appCoroutineScope = this,
@ -604,7 +611,7 @@ class MessagesPresenterTest {
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl), localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom), mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
analyticsService = FakeAnalyticsService(), analyticsService = analyticsService,
messageComposerContext = MessageComposerContextImpl(), messageComposerContext = MessageComposerContextImpl(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
@ -613,7 +620,8 @@ class MessagesPresenterTest {
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom, room = matrixRoom,
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
appScope = this appScope = this,
analyticsService = analyticsService,
) )
val buildMeta = aBuildMeta() val buildMeta = aBuildMeta()
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
@ -633,6 +641,7 @@ class MessagesPresenterTest {
messageSummaryFormatter = FakeMessageSummaryFormatter(), messageSummaryFormatter = FakeMessageSummaryFormatter(),
navigator = navigator, navigator = navigator,
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
analyticsService = analyticsService,
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
) )
} }

View file

@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
@ -37,6 +38,7 @@ import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitWithLatch import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@ -260,7 +262,11 @@ class TimelinePresenterTest {
@Test @Test
fun `present - PollAnswerSelected event calls into rust room api and analytics`() = runTest { fun `present - PollAnswerSelected event calls into rust room api and analytics`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = createTimelinePresenter(room) val analyticsService = FakeAnalyticsService()
val presenter = createTimelinePresenter(
room = room,
analyticsService = analyticsService,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -271,7 +277,8 @@ class TimelinePresenterTest {
assertThat(room.sendPollResponseInvocations.size).isEqualTo(1) assertThat(room.sendPollResponseInvocations.size).isEqualTo(1)
assertThat(room.sendPollResponseInvocations.first().answers).isEqualTo(listOf("anAnswerId")) assertThat(room.sendPollResponseInvocations.first().answers).isEqualTo(listOf("anAnswerId"))
assertThat(room.sendPollResponseInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID) assertThat(room.sendPollResponseInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
// TODO Polls: Test poll vote analytic assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollVote())
} }
private fun TestScope.createTimelinePresenter( private fun TestScope.createTimelinePresenter(
@ -282,18 +289,21 @@ class TimelinePresenterTest {
timelineItemsFactory = timelineItemsFactory, timelineItemsFactory = timelineItemsFactory,
room = FakeMatrixRoom(matrixTimeline = timeline), room = FakeMatrixRoom(matrixTimeline = timeline),
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
appScope = this appScope = this,
analyticsService = FakeAnalyticsService(),
) )
} }
private fun TestScope.createTimelinePresenter( private fun TestScope.createTimelinePresenter(
room: MatrixRoom, room: MatrixRoom,
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): TimelinePresenter { ): TimelinePresenter {
return TimelinePresenter( return TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = room, room = room,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
appScope = this appScope = this,
analyticsService = analyticsService,
) )
} }
} }

View file

@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.features.messages.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
@ -48,6 +49,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -24,15 +24,17 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class CreatePollNode @AssistedInject constructor( class CreatePollNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: CreatePollPresenter.Factory, presenterFactory: CreatePollPresenter.Factory,
// analyticsService: AnalyticsService, // TODO Polls: add analytics analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val presenter = presenterFactory.create(backNavigator = ::navigateUp) private val presenter = presenterFactory.create(backNavigator = ::navigateUp)
@ -40,8 +42,7 @@ class CreatePollNode @AssistedInject constructor(
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {
// TODO Polls: add analytics analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreatePollView))
// analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.PollView))
} }
) )
} }

View file

@ -29,9 +29,13 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -44,9 +48,9 @@ private const val MAX_SELECTIONS = 1
class CreatePollPresenter @AssistedInject constructor( class CreatePollPresenter @AssistedInject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
// private val analyticsService: AnalyticsService, // TODO Polls: add analytics private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext,
@Assisted private val navigateUp: () -> Unit, @Assisted private val navigateUp: () -> Unit,
// private val messageComposerContext: MessageComposerContext, // TODO Polls: add analytics
) : Presenter<CreatePollState> { ) : Presenter<CreatePollState> {
@AssistedFactory @AssistedFactory
@ -78,7 +82,21 @@ class CreatePollPresenter @AssistedInject constructor(
maxSelections = MAX_SELECTIONS, maxSelections = MAX_SELECTIONS,
pollKind = pollKind, pollKind = pollKind,
) )
// analyticsService.capture(PollCreate()) // TODO Polls: add analytics analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
messageType = Composer.MessageType.Poll,
)
)
analyticsService.capture(
PollCreation(
action = PollCreation.Action.Create,
isUndisclosed = pollKind == PollKind.Undisclosed,
numberOfAnswers = answers.size,
)
)
navigateUp() navigateUp()
} else { } else {
Timber.d("Cannot create poll") Timber.d("Cannot create poll")
@ -153,7 +171,7 @@ private val pollKindSaver: Saver<MutableState<PollKind>, Boolean> = Saver(
}, },
restore = { restore = {
mutableStateOf( mutableStateOf(
when(it) { when (it) {
true -> PollKind.Undisclosed true -> PollKind.Undisclosed
else -> PollKind.Disclosed else -> PollKind.Disclosed
} }

View file

@ -20,9 +20,13 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.test.MessageComposerContextFake
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.test.room.CreatePollInvocation import io.element.android.libraries.matrix.test.room.CreatePollInvocation
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -37,11 +41,13 @@ class CreatePollPresenterTest {
private var navUpInvocationsCount = 0 private var navUpInvocationsCount = 0
private val fakeMatrixRoom = FakeMatrixRoom() private val fakeMatrixRoom = FakeMatrixRoom()
// private val fakeAnalyticsService = FakeAnalyticsService() // TODO Polls: add analytics private val fakeAnalyticsService = FakeAnalyticsService()
private val messageComposerContextFake = MessageComposerContextFake()
private val presenter = CreatePollPresenter( private val presenter = CreatePollPresenter(
room = fakeMatrixRoom, room = fakeMatrixRoom,
// analyticsService = fakeAnalyticsService, // TODO Polls: add analytics analyticsService = fakeAnalyticsService,
messageComposerContext = messageComposerContextFake,
navigateUp = { navUpInvocationsCount++ }, navigateUp = { navUpInvocationsCount++ },
) )
@ -104,6 +110,22 @@ class CreatePollPresenterTest {
pollKind = PollKind.Disclosed pollKind = PollKind.Disclosed
) )
) )
Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
Truth.assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
Composer(
inThread = false,
isEditing = false,
isReply = false,
messageType = Composer.MessageType.Poll,
)
)
Truth.assertThat(fakeAnalyticsService.capturedEvents[1]).isEqualTo(
PollCreation(
action = PollCreation.Action.Create,
isUndisclosed = false,
numberOfAnswers = 2,
)
)
} }
} }