Merge pull request #8069 from vector-im/feature/mna/poll-history-details

[Poll] History list: details screen of a poll (PSG-1041, PSG-1151)
This commit is contained in:
Maxime NATUREL 2023-02-13 17:47:54 +01:00 committed by GitHub
commit 552af673ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1929 additions and 134 deletions

2
changelog.d/8056.feature Normal file
View file

@ -0,0 +1,2 @@
[Poll] History list: details screen of a poll
[Poll] History list: enable the new settings entry in release mode

View file

@ -3211,6 +3211,7 @@
<string name="room_polls_wait_for_display">Displaying polls</string>
<string name="room_polls_load_more">Load more polls</string>
<string name="room_polls_loading_error">Error fetching polls.</string>
<string name="room_poll_details_go_to_timeline">View poll in timeline</string>
<!-- Location -->
<string name="location_activity_title_static_sharing">Share location</string>

View file

@ -327,6 +327,7 @@
<activity android:name=".features.settings.devices.v2.details.SessionDetailsActivity" />
<activity android:name=".features.settings.devices.v2.rename.RenameSessionActivity" />
<activity android:name=".features.login.qr.QrCodeLoginActivity" />
<activity android:name=".features.roomprofile.polls.detail.ui.RoomPollDetailActivity" />
<!-- Services -->

View file

@ -85,6 +85,7 @@ import im.vector.app.features.roomprofile.members.RoomMemberListViewModel
import im.vector.app.features.roomprofile.notifications.RoomNotificationSettingsViewModel
import im.vector.app.features.roomprofile.permissions.RoomPermissionsViewModel
import im.vector.app.features.roomprofile.polls.RoomPollsViewModel
import im.vector.app.features.roomprofile.polls.detail.ui.RoomPollDetailViewModel
import im.vector.app.features.roomprofile.settings.RoomSettingsViewModel
import im.vector.app.features.roomprofile.settings.joinrule.advanced.RoomJoinRuleChooseRestrictedViewModel
import im.vector.app.features.roomprofile.uploads.RoomUploadsViewModel
@ -703,4 +704,9 @@ interface MavericksViewModelModule {
@IntoMap
@MavericksViewModelKey(RoomPollsViewModel::class)
fun roomPollsViewModelFactory(factory: RoomPollsViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(RoomPollDetailViewModel::class)
fun roomPollDetailViewModelFactory(factory: RoomPollDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 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.core.event
import androidx.lifecycle.asFlow
import im.vector.app.core.di.ActiveSessionHolder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.flow.unwrap
import javax.inject.Inject
class GetTimelineEventUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
) {
fun execute(roomId: String, eventId: String): Flow<TimelineEvent> {
return activeSessionHolder.getActiveSession().getRoom(roomId)
?.timelineService()
?.getTimelineEventLive(eventId)
?.asFlow()
?.unwrap()
?: emptyFlow()
}
}

View file

@ -54,6 +54,7 @@ import im.vector.app.features.crypto.verification.SupportedVerificationMethodsPr
import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction
import im.vector.app.features.home.room.detail.error.RoomNotFound
import im.vector.app.features.home.room.detail.location.RedactLiveLocationShareEventUseCase
import im.vector.app.features.home.room.detail.poll.VoteToPollUseCase
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
@ -90,7 +91,6 @@ import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode
import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage
@ -154,6 +154,7 @@ class TimelineViewModel @AssistedInject constructor(
timelineFactory: TimelineFactory,
private val spaceStateHandler: SpaceStateHandler,
private val voiceBroadcastHelper: VoiceBroadcastHelper,
private val voteToPollUseCase: VoteToPollUseCase,
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener, LocationSharingServiceConnection.Callback {
@ -1235,15 +1236,11 @@ class TimelineViewModel @AssistedInject constructor(
private fun handleVoteToPoll(action: RoomDetailAction.VoteToPoll) {
if (room == null) return
// Do not allow to vote unsent local echo of the poll event
if (LocalEcho.isLocalEchoId(action.eventId)) return
// Do not allow to vote the same option twice
room.getTimelineEvent(action.eventId)?.let { pollTimelineEvent ->
val currentVote = pollTimelineEvent.annotations?.pollResponseSummary?.aggregatedContent?.myVote
if (currentVote != action.optionKey) {
room.sendService().voteToPoll(action.eventId, action.optionKey)
}
}
voteToPollUseCase.execute(
roomId = room.roomId,
pollEventId = action.eventId,
optionId = action.optionKey,
)
}
private fun handleEndPoll(eventId: String) {

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2023 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.home.room.detail.poll
import im.vector.app.core.di.ActiveSessionHolder
import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.getTimelineEvent
import timber.log.Timber
import javax.inject.Inject
class VoteToPollUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
) {
fun execute(roomId: String, pollEventId: String, optionId: String) {
// Do not allow to vote unsent local echo of the poll event
if (LocalEcho.isLocalEchoId(pollEventId)) return
runCatching {
val room = activeSessionHolder.getActiveSession().getRoom(roomId)
room?.getTimelineEvent(pollEventId)?.let { pollTimelineEvent ->
val currentVote = pollTimelineEvent
.annotations
?.pollResponseSummary
?.aggregatedContent
?.myVote
if (currentVote != optionId) {
room.sendService().voteToPoll(
pollEventId = pollEventId,
answerId = optionId
)
}
}
}.onFailure { Timber.w("Failed to vote in poll with id $pollEventId in room with id $roomId") }
}
}

View file

@ -255,7 +255,11 @@ class MessageItemFactory @Inject constructor(
attributes: AbsMessageItem.Attributes,
isEnded: Boolean,
): PollItem {
val pollViewState = pollItemViewStateFactory.create(pollContent, informationData)
val pollViewState = pollItemViewStateFactory.create(
pollContent = pollContent,
pollResponseData = informationData.pollResponseAggregatedSummary,
isSent = informationData.sendState.isSent(),
)
return PollItem_()
.attributes(attributes)

View file

@ -18,9 +18,8 @@ package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.poll.PollViewState
import im.vector.app.features.poll.PollItemViewState
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo
@ -33,27 +32,25 @@ class PollItemViewStateFactory @Inject constructor(
fun create(
pollContent: MessagePollContent,
informationData: MessageInformationData,
): PollViewState {
pollResponseData: PollResponseData?,
isSent: Boolean,
): PollItemViewState {
val pollCreationInfo = pollContent.getBestPollCreationInfo()
val question = pollCreationInfo?.question?.getBestQuestion().orEmpty()
val pollResponseSummary = informationData.pollResponseAggregatedSummary
val totalVotes = pollResponseSummary?.totalVotes ?: 0
val totalVotes = pollResponseData?.totalVotes ?: 0
return when {
!informationData.sendState.isSent() -> {
!isSent -> {
createSendingPollViewState(question, pollCreationInfo)
}
informationData.pollResponseAggregatedSummary?.isClosed.orFalse() -> {
createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes)
pollResponseData?.isClosed.orFalse() -> {
createEndedPollViewState(question, pollCreationInfo, pollResponseData, totalVotes)
}
pollContent.getBestPollCreationInfo()?.isUndisclosed().orFalse() -> {
createUndisclosedPollViewState(question, pollCreationInfo, pollResponseSummary)
createUndisclosedPollViewState(question, pollCreationInfo, pollResponseData)
}
informationData.pollResponseAggregatedSummary?.myVote?.isNotEmpty().orFalse() -> {
createVotedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes)
pollResponseData?.myVote?.isNotEmpty().orFalse() -> {
createVotedPollViewState(question, pollCreationInfo, pollResponseData, totalVotes)
}
else -> {
createReadyPollViewState(question, pollCreationInfo, totalVotes)
@ -61,8 +58,8 @@ class PollItemViewStateFactory @Inject constructor(
}
}
private fun createSendingPollViewState(question: String, pollCreationInfo: PollCreationInfo?): PollViewState {
return PollViewState(
private fun createSendingPollViewState(question: String, pollCreationInfo: PollCreationInfo?): PollItemViewState {
return PollItemViewState(
question = question,
votesStatus = stringProvider.getString(R.string.poll_no_votes_cast),
canVote = false,
@ -73,51 +70,51 @@ class PollItemViewStateFactory @Inject constructor(
private fun createEndedPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
pollResponseSummary: PollResponseData?,
pollResponseData: PollResponseData?,
totalVotes: Int,
): PollViewState {
val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
): PollItemViewState {
val totalVotesText = if (pollResponseData?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, totalVotes, totalVotes)
}
return PollViewState(
return PollItemViewState(
question = question,
votesStatus = totalVotesText,
canVote = false,
optionViewStates = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseSummary),
optionViewStates = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseData),
)
}
private fun createUndisclosedPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
pollResponseSummary: PollResponseData?
): PollViewState {
return PollViewState(
pollResponseData: PollResponseData?
): PollItemViewState {
return PollItemViewState(
question = question,
votesStatus = stringProvider.getString(R.string.poll_undisclosed_not_ended),
canVote = true,
optionViewStates = pollOptionViewStateFactory.createPollUndisclosedOptions(pollCreationInfo, pollResponseSummary),
optionViewStates = pollOptionViewStateFactory.createPollUndisclosedOptions(pollCreationInfo, pollResponseData),
)
}
private fun createVotedPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
pollResponseSummary: PollResponseData?,
pollResponseData: PollResponseData?,
totalVotes: Int
): PollViewState {
val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
): PollItemViewState {
val totalVotesText = if (pollResponseData?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, totalVotes, totalVotes)
}
return PollViewState(
return PollItemViewState(
question = question,
votesStatus = totalVotesText,
canVote = true,
optionViewStates = pollOptionViewStateFactory.createPollVotedOptions(pollCreationInfo, pollResponseSummary),
optionViewStates = pollOptionViewStateFactory.createPollVotedOptions(pollCreationInfo, pollResponseData),
)
}
@ -125,13 +122,13 @@ class PollItemViewStateFactory @Inject constructor(
question: String,
pollCreationInfo: PollCreationInfo?,
totalVotes: Int
): PollViewState {
): PollItemViewState {
val totalVotesText = if (totalVotes == 0) {
stringProvider.getString(R.string.poll_no_votes_cast)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_not_voted, totalVotes, totalVotes)
}
return PollViewState(
return PollItemViewState(
question = question,
votesStatus = totalVotesText,
canVote = true,

View file

@ -18,7 +18,7 @@ package im.vector.app.features.poll
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
data class PollViewState(
data class PollItemViewState(
val question: String,
val votesStatus: String,
val canVote: Boolean,

View file

@ -18,7 +18,6 @@
package im.vector.app.features.roomprofile
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.epoxy.expandableTextItem
import im.vector.app.core.epoxy.profiles.buildProfileAction
@ -265,15 +264,14 @@ class RoomProfileController @Inject constructor(
action = { callback?.onBannedMemberListClicked() }
)
}
if (BuildConfig.DEBUG) {
// WIP, will be in release when related screens will be finished
buildProfileAction(
id = "poll_history",
title = stringProvider.getString(R.string.room_profile_section_more_polls),
icon = R.drawable.ic_attachment_poll,
action = { callback?.onPollHistoryClicked() }
)
}
buildProfileAction(
id = "uploads",
title = stringProvider.getString(R.string.room_profile_section_more_uploads),

View file

@ -64,7 +64,7 @@ import javax.inject.Inject
@Parcelize
data class RoomProfileArgs(
val roomId: String
val roomId: String,
) : Parcelable
@AndroidEntryPoint

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.domain
import im.vector.app.core.di.ActiveSessionHolder
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.isPollEnd
import timber.log.Timber
import javax.inject.Inject
class GetEndedPollEventIdUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
) {
fun execute(roomId: String, startPollEventId: String): String? {
val result = runCatching {
activeSessionHolder.getActiveSession().roomService().getRoom(roomId)
?.timelineService()
?.getTimelineEventsRelatedTo(RelationType.REFERENCE, startPollEventId)
?.find { it.root.isPollEnd() }
?.eventId
}.onFailure { Timber.w("failed to retrieve the ended poll event id for eventId:$startPollEventId") }
return result.getOrNull()
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import im.vector.app.features.poll.PollItemViewState
data class RoomPollDetail(
val creationTimestamp: Long,
val isEnded: Boolean,
val endedPollEventId: String?,
val pollItemViewState: PollItemViewState,
)

View file

@ -0,0 +1,23 @@
/*
* 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.roomprofile.polls.detail.ui
import im.vector.app.core.platform.VectorViewModelAction
sealed interface RoomPollDetailAction : VectorViewModelAction {
data class Vote(val pollEventId: String, val optionId: String) : RoomPollDetailAction
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import com.airbnb.mvrx.Mavericks
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.core.extensions.addFragment
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivitySimpleBinding
import im.vector.lib.core.utils.compat.getParcelableExtraCompat
/**
* Display the details of a given poll.
*/
@AndroidEntryPoint
class RoomPollDetailActivity : VectorBaseActivity<ActivitySimpleBinding>() {
override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (isFirstCreation()) {
addFragment(
container = views.simpleFragmentContainer,
fragmentClass = RoomPollDetailFragment::class.java,
params = intent.getParcelableExtraCompat(Mavericks.KEY_ARG)
)
}
}
companion object {
fun newIntent(context: Context, pollId: String, roomId: String, isEnded: Boolean): Intent {
return Intent(context, RoomPollDetailActivity::class.java).apply {
val args = RoomPollDetailArgs(
pollId = pollId,
roomId = roomId,
isEnded = isEnded,
)
putExtra(Mavericks.KEY_ARG, args)
}
}
}
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter
import java.util.UUID
import javax.inject.Inject
class RoomPollDetailController @Inject constructor(
val dateFormatter: VectorDateFormatter,
) : TypedEpoxyController<RoomPollDetailViewState>() {
interface Callback {
fun vote(pollEventId: String, optionId: String)
fun goToTimelineEvent(eventId: String)
}
var callback: Callback? = null
override fun buildModels(viewState: RoomPollDetailViewState?) {
val pollDetail = viewState?.pollDetail ?: return
val pollItemViewState = pollDetail.pollItemViewState
val host = this
roomPollDetailItem {
id(viewState.pollId)
eventId(viewState.pollId)
formattedDate(host.dateFormatter.format(pollDetail.creationTimestamp, DateFormatKind.TIMELINE_DAY_DIVIDER))
question(pollItemViewState.question)
canVote(pollItemViewState.canVote)
votesStatus(pollItemViewState.votesStatus)
optionViewStates(pollItemViewState.optionViewStates.orEmpty())
callback(host.callback)
}
buildGoToTimelineItem(targetEventId = pollDetail.endedPollEventId ?: viewState.pollId)
}
private fun buildGoToTimelineItem(targetEventId: String) {
val host = this
roomPollGoToTimelineItem {
id(UUID.randomUUID().toString())
clickListener {
host.callback?.goToTimelineEvent(targetEventId)
}
}
}
}

View file

@ -0,0 +1,104 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentRoomPollDetailBinding
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@Parcelize
data class RoomPollDetailArgs(
val pollId: String,
val roomId: String,
val isEnded: Boolean,
) : Parcelable
@AndroidEntryPoint
class RoomPollDetailFragment :
VectorBaseFragment<FragmentRoomPollDetailBinding>(),
RoomPollDetailController.Callback {
@Inject lateinit var viewNavigator: RoomPollDetailNavigator
@Inject lateinit var roomPollDetailController: RoomPollDetailController
private val viewModel: RoomPollDetailViewModel by fragmentViewModel()
private val roomPollDetailArgs: RoomPollDetailArgs by args()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomPollDetailBinding {
return FragmentRoomPollDetailBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar(isEnded = roomPollDetailArgs.isEnded)
setupDetailView()
}
override fun onDestroyView() {
roomPollDetailController.callback = null
views.pollDetailRecyclerView.cleanup()
super.onDestroyView()
}
private fun setupDetailView() {
roomPollDetailController.callback = this
views.pollDetailRecyclerView.configureWith(
roomPollDetailController,
hasFixedSize = true,
)
}
private fun setupToolbar(isEnded: Boolean) {
val title = when (isEnded) {
true -> getString(R.string.room_polls_ended)
false -> getString(R.string.room_polls_active)
}
setupToolbar(views.roomPollDetailToolbar)
.setTitle(title)
.allowBack(useCross = true)
}
override fun invalidate() = withState(viewModel) { state ->
roomPollDetailController.setData(state)
}
override fun vote(pollEventId: String, optionId: String) {
viewModel.handle(RoomPollDetailAction.Vote(pollEventId = pollEventId, optionId = optionId))
}
override fun goToTimelineEvent(eventId: String) = withState(viewModel) { state ->
viewNavigator.goToTimelineEvent(
context = requireContext(),
roomId = state.roomId,
eventId = eventId,
)
}
}

View file

@ -0,0 +1,89 @@
/*
* Copyright 2020 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.roomprofile.polls.detail.ui
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.features.home.room.detail.timeline.item.PollOptionView
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
@EpoxyModelClass
abstract class RoomPollDetailItem : VectorEpoxyModel<RoomPollDetailItem.Holder>(R.layout.item_poll_detail) {
@EpoxyAttribute
lateinit var formattedDate: String
@EpoxyAttribute
var question: String? = null
@EpoxyAttribute
var callback: RoomPollDetailController.Callback? = null
@EpoxyAttribute
var eventId: String? = null
@EpoxyAttribute
var canVote: Boolean = false
@EpoxyAttribute
var votesStatus: String? = null
@EpoxyAttribute
lateinit var optionViewStates: List<PollOptionViewState>
@EpoxyAttribute
var ended: Boolean = false
override fun bind(holder: Holder) {
super.bind(holder)
holder.date.text = formattedDate
holder.questionTextView.text = question
holder.votesStatusTextView.text = votesStatus
holder.optionsContainer.removeAllViews()
holder.optionsContainer.isVisible = optionViewStates.isNotEmpty()
for (option in optionViewStates) {
val optionView = PollOptionView(holder.view.context)
holder.optionsContainer.addView(optionView)
optionView.render(option)
optionView.setOnClickListener { onOptionClicked(option) }
}
holder.endedPollTextView.isVisible = false
}
private fun onOptionClicked(optionViewState: PollOptionViewState) {
val relatedEventId = eventId
if (canVote && relatedEventId != null) {
callback?.vote(pollEventId = relatedEventId, optionId = optionViewState.optionId)
}
}
class Holder : VectorEpoxyHolder() {
val date by bind<TextView>(R.id.pollDetailDate)
val questionTextView by bind<TextView>(R.id.questionTextView)
val optionsContainer by bind<LinearLayout>(R.id.optionsContainer)
val votesStatusTextView by bind<TextView>(R.id.optionsVotesStatusTextView)
val endedPollTextView by bind<TextView>(R.id.endedPollTextView)
}
}

View file

@ -0,0 +1,100 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import im.vector.app.core.extensions.getVectorLastMessageContent
import im.vector.app.features.home.room.detail.timeline.factory.PollItemViewStateFactory
import im.vector.app.features.home.room.detail.timeline.helper.PollResponseDataFactory
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.roomprofile.polls.detail.domain.GetEndedPollEventIdUseCase
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import timber.log.Timber
import javax.inject.Inject
class RoomPollDetailMapper @Inject constructor(
private val pollResponseDataFactory: PollResponseDataFactory,
private val pollItemViewStateFactory: PollItemViewStateFactory,
private val getEndedPollEventIdUseCase: GetEndedPollEventIdUseCase,
) {
fun map(timelineEvent: TimelineEvent): RoomPollDetail? {
val eventId = timelineEvent.root.eventId.orEmpty()
val result = runCatching {
val content = timelineEvent.getVectorLastMessageContent()
val pollResponseData = pollResponseDataFactory.create(timelineEvent)
val creationTimestamp = timelineEvent.root.originServerTs ?: 0
return if (eventId.isNotEmpty() && creationTimestamp > 0 && content is MessagePollContent) {
val isPollEnded = pollResponseData?.isClosed.orFalse()
val endedPollEventId = getEndedPollEventId(
isPollEnded,
startPollEventId = eventId,
roomId = timelineEvent.roomId,
)
convertToRoomPollDetail(
creationTimestamp = creationTimestamp,
content = content,
pollResponseData = pollResponseData,
isPollEnded = isPollEnded,
endedPollEventId = endedPollEventId,
)
} else {
Timber.w("missing mandatory info about poll event with id=$eventId")
null
}
}
if (result.isFailure) {
Timber.w("failed to map event with id $eventId")
}
return result.getOrNull()
}
private fun convertToRoomPollDetail(
creationTimestamp: Long,
content: MessagePollContent,
pollResponseData: PollResponseData?,
isPollEnded: Boolean,
endedPollEventId: String?,
): RoomPollDetail {
// we assume the poll has been sent
val pollItemViewState = pollItemViewStateFactory.create(
pollContent = content,
pollResponseData = pollResponseData,
isSent = true,
)
return RoomPollDetail(
creationTimestamp = creationTimestamp,
isEnded = isPollEnded,
pollItemViewState = pollItemViewState,
endedPollEventId = endedPollEventId,
)
}
private fun getEndedPollEventId(
isPollEnded: Boolean,
startPollEventId: String,
roomId: String,
): String? {
return if (isPollEnded) {
getEndedPollEventIdUseCase.execute(startPollEventId = startPollEventId, roomId = roomId)
} else {
null
}
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import android.content.Context
import im.vector.app.features.navigation.Navigator
import javax.inject.Inject
class RoomPollDetailNavigator @Inject constructor(
private val navigator: Navigator,
) {
fun goToTimelineEvent(context: Context, roomId: String, eventId: String) {
navigator.openRoom(
context = context,
roomId = roomId,
eventId = eventId,
buildTask = true,
)
}
}

View file

@ -0,0 +1,74 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.event.GetTimelineEventUseCase
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.home.room.detail.poll.VoteToPollUseCase
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
class RoomPollDetailViewModel @AssistedInject constructor(
@Assisted initialState: RoomPollDetailViewState,
private val getTimelineEventUseCase: GetTimelineEventUseCase,
private val roomPollDetailMapper: RoomPollDetailMapper,
private val voteToPollUseCase: VoteToPollUseCase,
) : VectorViewModel<RoomPollDetailViewState, RoomPollDetailAction, EmptyViewEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<RoomPollDetailViewModel, RoomPollDetailViewState> {
override fun create(initialState: RoomPollDetailViewState): RoomPollDetailViewModel
}
companion object : MavericksViewModelFactory<RoomPollDetailViewModel, RoomPollDetailViewState> by hiltMavericksViewModelFactory()
init {
observePollDetails(
pollId = initialState.pollId,
roomId = initialState.roomId,
)
}
private fun observePollDetails(pollId: String, roomId: String) {
getTimelineEventUseCase.execute(roomId = roomId, eventId = pollId)
.map { roomPollDetailMapper.map(it) }
.onEach { setState { copy(pollDetail = it) } }
.launchIn(viewModelScope)
}
override fun handle(action: RoomPollDetailAction) {
when (action) {
is RoomPollDetailAction.Vote -> handleVote(action)
}
}
private fun handleVote(vote: RoomPollDetailAction.Vote) = withState { state ->
voteToPollUseCase.execute(
roomId = state.roomId,
pollEventId = vote.pollEventId,
optionId = vote.optionId,
)
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import com.airbnb.mvrx.MavericksState
data class RoomPollDetailViewState(
val pollId: String,
val roomId: String,
val pollDetail: RoomPollDetail? = null,
) : MavericksState {
constructor(roomPollDetailArgs: RoomPollDetailArgs) : this(
pollId = roomPollDetailArgs.pollId,
roomId = roomPollDetailArgs.roomId,
)
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import android.widget.Button
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.onClick
@EpoxyModelClass
abstract class RoomPollGoToTimelineItem : VectorEpoxyModel<RoomPollGoToTimelineItem.Holder>(R.layout.item_poll_go_to_timeline) {
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var clickListener: ClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.goToTimelineButton.onClick(clickListener)
}
class Holder : VectorEpoxyHolder() {
val goToTimelineButton by bind<Button>(R.id.roomPollGoToTimeline)
}
}

View file

@ -69,7 +69,9 @@ class PollSummaryMapper @Inject constructor(
creationTimestamp = creationTimestamp,
title = pollTitle,
totalVotes = pollResponseData.totalVotes,
winnerOptions = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseData)
winnerOptions = pollOptionViewStateFactory
.createPollEndedOptions(pollCreationInfo, pollResponseData)
.filter { it.isWinner },
)
} else {
PollSummary.ActivePoll(

View file

@ -35,7 +35,6 @@ import im.vector.app.features.roomprofile.polls.RoomPollsType
import im.vector.app.features.roomprofile.polls.RoomPollsViewEvent
import im.vector.app.features.roomprofile.polls.RoomPollsViewModel
import im.vector.app.features.roomprofile.polls.RoomPollsViewState
import timber.log.Timber
import javax.inject.Inject
abstract class RoomPollsListFragment :
@ -48,6 +47,9 @@ abstract class RoomPollsListFragment :
@Inject
lateinit var stringProvider: StringProvider
@Inject
lateinit var viewNavigator: RoomPollsListNavigator
private val viewModel: RoomPollsViewModel by parentFragmentViewModel(RoomPollsViewModel::class)
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomPollsListBinding {
@ -125,9 +127,13 @@ abstract class RoomPollsListFragment :
views.roomPollsLoadMoreWhenEmptyProgress.isVisible = viewState.hasNoPollsAndCanLoadMore() && viewState.isLoadingMore
}
override fun onPollClicked(pollId: String) {
// TODO navigate to details
Timber.d("poll with id $pollId clicked")
override fun onPollClicked(pollId: String) = withState(viewModel) {
viewNavigator.goToPollDetails(
context = requireContext(),
pollId = pollId,
roomId = it.roomId,
isEnded = getRoomPollsType() == RoomPollsType.ENDED,
)
}
override fun onLoadMoreClicked() {

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.list.ui
import android.content.Context
import im.vector.app.features.roomprofile.polls.detail.ui.RoomPollDetailActivity
import javax.inject.Inject
class RoomPollsListNavigator @Inject constructor() {
fun goToPollDetails(context: Context, pollId: String, roomId: String, isEnded: Boolean) {
context.startActivity(
RoomPollDetailActivity.newIntent(
context = context,
pollId = pollId,
roomId = roomId,
isEnded = isEnded,
)
)
}
}

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/roomPollDetailToolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
tools:title="@string/room_polls_active" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/pollDetailRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="32dp"
android:paddingHorizontal="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appBarLayout"
app:layout_constraintBottom_toBottomOf="parent"
tools:itemCount="1"
tools:listitem="@layout/item_timeline_event_poll" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/pollDetailDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Vector.Caption"
android:textColor="?vctr_content_tertiary"
app:layout_constraintBottom_toTopOf="@id/pollDetailContent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="28/06/22" />
<include
android:id="@+id/pollDetailContent"
layout="@layout/item_timeline_event_poll"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/pollDetailDate" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/roomPollGoToTimeline"
style="@style/Widget.Vector.Button.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="33dp"
android:layout_marginBottom="46dp"
android:padding="0dp"
android:text="@string/room_poll_details_go_to_timeline"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,88 @@
/*
* Copyright (c) 2023 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.core.event
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeFlowLiveDataConversions
import im.vector.app.test.fakes.givenAsFlow
import im.vector.app.test.test
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeNull
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
private const val A_ROOM_ID = "room-id"
private const val AN_EVENT_ID = "event-id"
internal class GetTimelineEventUseCaseTest {
private val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions()
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val getTimelineEventUseCase = GetTimelineEventUseCase(fakeActiveSessionHolder.instance)
@Before
fun setUp() {
fakeFlowLiveDataConversions.setup()
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given a non existing room id, when execute, then returns an empty flow`() = runTest {
// Given
every { fakeActiveSessionHolder.instance.getActiveSession().roomService().getRoom(A_ROOM_ID) } returns null
// When
val result = getTimelineEventUseCase.execute(A_ROOM_ID, AN_EVENT_ID).firstOrNull()
// Then
result.shouldBeNull()
}
@Test
fun `given a LiveData of TimelineEvent, when execute, then returns the expected Flow`() = runTest {
// Given
val aTimelineEvent1: TimelineEvent = mockk()
val aTimelineEvent2: TimelineEvent? = null
val timelineService = fakeActiveSessionHolder.fakeSession
.fakeRoomService
.getRoom(A_ROOM_ID)
.timelineService()
// When
timelineService.givenTimelineEventLiveReturns(AN_EVENT_ID, aTimelineEvent1).givenAsFlow()
val result1 = getTimelineEventUseCase.execute(A_ROOM_ID, AN_EVENT_ID).test(this)
timelineService.givenTimelineEventLiveReturns(AN_EVENT_ID, aTimelineEvent2).givenAsFlow()
val result2 = getTimelineEventUseCase.execute(A_ROOM_ID, AN_EVENT_ID).test(this)
// Then
runCurrent()
result1.assertLatestValue(aTimelineEvent1)
result2.assertNoValues()
}
}

View file

@ -0,0 +1,118 @@
/*
* Copyright (c) 2023 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.home.room.detail.poll
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fixtures.RoomPollFixture
import io.mockk.every
import io.mockk.verify
import org.junit.Test
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.getTimelineEvent
private const val A_LOCAL_EVENT_ID = "\$local.event-id"
private const val AN_EVENT_ID = "event-id"
private const val A_ROOM_ID = "room-id"
private const val AN_EXISTING_OPTION_ID = "an-existing-option-id"
private const val AN_OPTION_ID = "option-id"
private const val AN_EVENT_TIMESTAMP = 123L
internal class VoteToPollUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val voteToPollUseCase = VoteToPollUseCase(fakeActiveSessionHolder.instance)
@Test
fun `given a local echo poll event when voting for an option then the vote is aborted`() {
// Given
val aPollEventId = A_LOCAL_EVENT_ID
val aVoteId = AN_OPTION_ID
givenAPollTimelineEvent(aPollEventId)
// When
voteToPollUseCase.execute(A_ROOM_ID, aPollEventId, aVoteId)
// Then
verify(exactly = 0) {
fakeActiveSessionHolder.fakeSession
.getRoom(A_ROOM_ID)
?.sendService()
?.voteToPoll(aPollEventId, aVoteId)
}
}
@Test
fun `given a poll event when voting for a different option then the vote is sent`() {
// Given
val aPollEventId = AN_EVENT_ID
val aVoteId = AN_OPTION_ID
givenAPollTimelineEvent(aPollEventId)
// When
voteToPollUseCase.execute(A_ROOM_ID, aPollEventId, aVoteId)
// Then
verify(exactly = 1) {
fakeActiveSessionHolder.fakeSession
.getRoom(A_ROOM_ID)
?.sendService()
?.voteToPoll(aPollEventId, aVoteId)
}
}
@Test
fun `given a poll event when voting for the same option then the vote is aborted`() {
// Given
val aPollEventId = AN_EVENT_ID
val aVoteId = AN_EXISTING_OPTION_ID
givenAPollTimelineEvent(aPollEventId)
// When
voteToPollUseCase.execute(A_ROOM_ID, aPollEventId, aVoteId)
// Then
verify(exactly = 0) {
fakeActiveSessionHolder.fakeSession
.getRoom(A_ROOM_ID)
?.sendService()
?.voteToPoll(aPollEventId, aVoteId)
}
}
private fun givenAPollTimelineEvent(eventId: String) {
val pollCreationInfo = RoomPollFixture.givenPollCreationInfo("pollTitle")
val messageContent = RoomPollFixture.givenAMessagePollContent(pollCreationInfo)
val timelineEvent = RoomPollFixture.givenATimelineEvent(
eventId,
A_ROOM_ID,
AN_EVENT_TIMESTAMP,
messageContent
)
every {
timelineEvent.annotations
?.pollResponseSummary
?.aggregatedContent
?.myVote
} returns AN_EXISTING_OPTION_ID
every {
fakeActiveSessionHolder.fakeSession
.getRoom(A_ROOM_ID)
?.getTimelineEvent(eventId)
} returns timelineEvent
}
}

View file

@ -19,9 +19,8 @@ package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.R
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData
import im.vector.app.features.poll.PollViewState
import im.vector.app.features.poll.PollItemViewState
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fixtures.PollFixture.A_MESSAGE_INFORMATION_DATA
import im.vector.app.test.fixtures.PollFixture.A_POLL_CONTENT
import im.vector.app.test.fixtures.PollFixture.A_POLL_OPTION_IDS
import im.vector.app.test.fixtures.PollFixture.A_POLL_RESPONSE_DATA
@ -31,7 +30,6 @@ import io.mockk.verify
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.send.SendState
class PollItemViewStateFactoryTest {
@ -46,18 +44,18 @@ class PollItemViewStateFactoryTest {
@Test
fun `given a sending poll state then poll is not votable and option states are PollSending`() {
// Given
val sendingPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(sendState = SendState.SENDING)
val optionViewStates = listOf(PollOptionViewState.PollSending(optionId = "", optionAnswer = ""))
every { fakePollOptionViewStateFactory.createPollSendingOptions(A_POLL_CONTENT.getBestPollCreationInfo()) } returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = sendingPollInformationData,
pollResponseData = null,
isSent = false,
)
// Then
pollViewState shouldBeEqualTo PollViewState(
pollViewState shouldBeEqualTo PollItemViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = fakeStringProvider.instance.getString(R.string.poll_no_votes_cast),
canVote = false,
@ -70,7 +68,6 @@ class PollItemViewStateFactoryTest {
fun `given a sent poll state when poll is closed then poll is not votable and option states are Ended`() {
// Given
val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true)
val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary)
val optionViewStates = listOf(
PollOptionViewState.PollEnded(
optionId = "", optionAnswer = "", voteCount = 0, votePercentage = 0.0, isWinner = false
@ -79,18 +76,19 @@ class PollItemViewStateFactoryTest {
every {
fakePollOptionViewStateFactory.createPollEndedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
closedPollInformationData.pollResponseAggregatedSummary,
closedPollSummary,
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = closedPollInformationData,
pollResponseData = closedPollSummary,
isSent = true,
)
// Then
pollViewState shouldBeEqualTo PollViewState(
pollViewState shouldBeEqualTo PollItemViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = fakeStringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_after_ended, 0, 0),
canVote = false,
@ -99,7 +97,7 @@ class PollItemViewStateFactoryTest {
verify {
fakePollOptionViewStateFactory.createPollEndedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
closedPollInformationData.pollResponseAggregatedSummary,
closedPollSummary,
)
}
}
@ -108,7 +106,6 @@ class PollItemViewStateFactoryTest {
fun `given a sent poll state with some decryption error when poll is closed then warning message is displayed`() {
// Given
val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true, hasEncryptedRelatedEvents = true)
val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary)
val optionViewStates = listOf(
PollOptionViewState.PollEnded(
optionId = "", optionAnswer = "", voteCount = 0, votePercentage = 0.0, isWinner = false
@ -117,14 +114,15 @@ class PollItemViewStateFactoryTest {
every {
fakePollOptionViewStateFactory.createPollEndedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
closedPollInformationData.pollResponseAggregatedSummary,
closedPollSummary,
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = closedPollInformationData,
pollResponseData = closedPollSummary,
isSent = true,
)
// Then
@ -134,6 +132,7 @@ class PollItemViewStateFactoryTest {
@Test
fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() {
// Given
val pollResponseData = A_POLL_RESPONSE_DATA
val optionViewStates = listOf(
PollOptionViewState.PollUndisclosed(
optionId = "",
@ -144,18 +143,19 @@ class PollItemViewStateFactoryTest {
every {
fakePollOptionViewStateFactory.createPollUndisclosedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
A_MESSAGE_INFORMATION_DATA.pollResponseAggregatedSummary,
pollResponseData,
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = A_MESSAGE_INFORMATION_DATA,
pollResponseData = pollResponseData,
isSent = true,
)
// Then
pollViewState shouldBeEqualTo PollViewState(
pollViewState shouldBeEqualTo PollItemViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = fakeStringProvider.instance.getString(R.string.poll_undisclosed_not_ended),
canVote = true,
@ -164,7 +164,7 @@ class PollItemViewStateFactoryTest {
verify {
fakePollOptionViewStateFactory.createPollUndisclosedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
A_MESSAGE_INFORMATION_DATA.pollResponseAggregatedSummary,
A_POLL_RESPONSE_DATA,
)
}
}
@ -180,7 +180,6 @@ class PollItemViewStateFactoryTest {
kind = PollType.DISCLOSED_UNSTABLE
),
)
val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData)
val optionViewStates = listOf(
PollOptionViewState.PollVoted(
optionId = "",
@ -193,18 +192,19 @@ class PollItemViewStateFactoryTest {
every {
fakePollOptionViewStateFactory.createPollVotedOptions(
disclosedPollContent.getBestPollCreationInfo(),
votedInformationData.pollResponseAggregatedSummary,
votedPollData,
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent,
informationData = votedInformationData,
pollResponseData = votedPollData,
isSent = true,
)
// Then
pollViewState shouldBeEqualTo PollViewState(
pollViewState shouldBeEqualTo PollItemViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = fakeStringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, 1, 1),
canVote = true,
@ -213,7 +213,7 @@ class PollItemViewStateFactoryTest {
verify {
fakePollOptionViewStateFactory.createPollVotedOptions(
disclosedPollContent.getBestPollCreationInfo(),
votedInformationData.pollResponseAggregatedSummary,
votedPollData,
)
}
}
@ -232,7 +232,6 @@ class PollItemViewStateFactoryTest {
kind = PollType.DISCLOSED_UNSTABLE
),
)
val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData)
val optionViewStates = listOf(
PollOptionViewState.PollVoted(
optionId = "",
@ -245,14 +244,15 @@ class PollItemViewStateFactoryTest {
every {
fakePollOptionViewStateFactory.createPollVotedOptions(
disclosedPollContent.getBestPollCreationInfo(),
votedInformationData.pollResponseAggregatedSummary,
votedPollData,
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent,
informationData = votedInformationData,
pollResponseData = votedPollData,
isSent = true,
)
// Then
@ -262,6 +262,7 @@ class PollItemViewStateFactoryTest {
@Test
fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() {
// Given
val pollResponseData = A_POLL_RESPONSE_DATA
val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
kind = PollType.DISCLOSED_UNSTABLE
@ -282,11 +283,12 @@ class PollItemViewStateFactoryTest {
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent,
informationData = A_MESSAGE_INFORMATION_DATA,
pollResponseData = pollResponseData,
isSent = true,
)
// Then
pollViewState shouldBeEqualTo PollViewState(
pollViewState shouldBeEqualTo PollItemViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = fakeStringProvider.instance.getString(R.string.poll_no_votes_cast),
canVote = true,

View file

@ -0,0 +1,136 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.domain
import im.vector.app.test.fakes.FakeActiveSessionHolder
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import org.amshove.kluent.shouldBe
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.isPollEnd
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
private const val A_ROOM_ID = "room-id"
private const val A_START_POLL_EVENT_ID = "start-poll-id"
private const val AN_END_POLL_EVENT_ID = "end-poll-id"
internal class GetEndedPollEventIdUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val getEndedPollEventIdUseCase = GetEndedPollEventIdUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance
)
@Before
fun setup() {
mockkStatic("org.matrix.android.sdk.api.session.events.model.EventKt")
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given existing related end event when execute then result is id of the end event`() {
// Given
val timelineEvent = givenEvent(eventId = AN_END_POLL_EVENT_ID, isPollEnd = true)
givenARelatedEvent(timelineEvent = timelineEvent)
// When
val result = getEndedPollEventIdUseCase.execute(A_ROOM_ID, A_START_POLL_EVENT_ID)
// Then
result shouldBe AN_END_POLL_EVENT_ID
}
@Test
fun `given existing related event but not end poll event when execute then result is null`() {
// Given
val timelineEvent = givenEvent(eventId = AN_END_POLL_EVENT_ID, isPollEnd = false)
givenARelatedEvent(timelineEvent = timelineEvent)
// When
val result = getEndedPollEventIdUseCase.execute(A_ROOM_ID, A_START_POLL_EVENT_ID)
// Then
result shouldBe null
}
@Test
fun `given no existing related event when execute then result is null`() {
// Given
givenARelatedEvent(timelineEvent = null)
// When
val result = getEndedPollEventIdUseCase.execute(A_ROOM_ID, A_START_POLL_EVENT_ID)
// Then
result shouldBe null
}
@Test
fun `given error occurred when execute then result is null`() {
// Given
givenErrorDuringRequest(error = Exception())
// When
val result = getEndedPollEventIdUseCase.execute(A_ROOM_ID, A_START_POLL_EVENT_ID)
// Then
result shouldBe null
}
private fun givenEvent(eventId: String, isPollEnd: Boolean): TimelineEvent {
val timelineEvent = mockk<TimelineEvent>()
val event = mockk<Event>()
every { timelineEvent.root } returns event
every { timelineEvent.eventId } returns eventId
every { event.isPollEnd() } returns isPollEnd
return timelineEvent
}
private fun givenARelatedEvent(timelineEvent: TimelineEvent?) {
val result: List<TimelineEvent> = timelineEvent?.let { listOf(it) } ?: emptyList()
every {
fakeActiveSessionHolder.instance
.getActiveSession()
.roomService()
.getRoom(A_ROOM_ID)
?.timelineService()
?.getTimelineEventsRelatedTo(RelationType.REFERENCE, A_START_POLL_EVENT_ID)
} returns result
}
private fun givenErrorDuringRequest(error: Exception) {
every {
fakeActiveSessionHolder.instance
.getActiveSession()
.roomService()
.getRoom(A_ROOM_ID)
?.timelineService()
?.getTimelineEventsRelatedTo(RelationType.REFERENCE, A_START_POLL_EVENT_ID)
} throws error
}
}

View file

@ -0,0 +1,186 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import im.vector.app.features.home.room.detail.timeline.factory.PollItemViewStateFactory
import im.vector.app.features.home.room.detail.timeline.helper.PollResponseDataFactory
import im.vector.app.features.poll.PollItemViewState
import im.vector.app.features.roomprofile.polls.detail.domain.GetEndedPollEventIdUseCase
import im.vector.app.test.fixtures.RoomPollFixture
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
private const val AN_EVENT_ID = "event-id"
private const val A_ROOM_ID = "room-id"
private const val AN_EVENT_TIMESTAMP = 123L
internal class RoomPollDetailMapperTest {
private val fakePollResponseDataFactory = mockk<PollResponseDataFactory>()
private val fakePollItemViewStateFactory = mockk<PollItemViewStateFactory>()
private val fakeGetEndedPollEventIdUseCase = mockk<GetEndedPollEventIdUseCase>()
private val roomPollDetailMapper = RoomPollDetailMapper(
pollResponseDataFactory = fakePollResponseDataFactory,
pollItemViewStateFactory = fakePollItemViewStateFactory,
getEndedPollEventIdUseCase = fakeGetEndedPollEventIdUseCase,
)
@Before
fun setup() {
mockkStatic("im.vector.app.core.extensions.TimelineEventKt")
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given a not ended poll event when mapping to model then result contains correct poll details`() {
// Given
val aPollItemViewState = givenAPollItemViewState()
val aPollEvent = givenAPollTimelineEvent(
eventId = AN_EVENT_ID,
creationTimestamp = AN_EVENT_TIMESTAMP,
isClosed = false,
pollItemViewState = aPollItemViewState,
)
val expectedResult = RoomPollDetail(
creationTimestamp = AN_EVENT_TIMESTAMP,
isEnded = false,
endedPollEventId = null,
pollItemViewState = aPollItemViewState,
)
// When
val result = roomPollDetailMapper.map(aPollEvent)
// Then
result shouldBeEqualTo expectedResult
}
@Test
fun `given an ended poll event when mapping to model then result contains correct poll details`() {
// Given
val aPollItemViewState = givenAPollItemViewState()
val aPollEvent = givenAPollTimelineEvent(
eventId = AN_EVENT_ID,
creationTimestamp = AN_EVENT_TIMESTAMP,
isClosed = true,
pollItemViewState = aPollItemViewState,
)
val endedPollEventId = givenEndedPollEventId()
val expectedResult = RoomPollDetail(
creationTimestamp = AN_EVENT_TIMESTAMP,
isEnded = true,
endedPollEventId = endedPollEventId,
pollItemViewState = aPollItemViewState,
)
// When
val result = roomPollDetailMapper.map(aPollEvent)
// Then
result shouldBeEqualTo expectedResult
}
@Test
fun `given missing data in event when mapping to model then result is null`() {
// Given
val aPollItemViewState = givenAPollItemViewState()
val noIdPollEvent = givenAPollTimelineEvent(
eventId = "",
creationTimestamp = AN_EVENT_TIMESTAMP,
isClosed = false,
pollItemViewState = aPollItemViewState,
)
val noTimestampPollEvent = givenAPollTimelineEvent(
eventId = AN_EVENT_ID,
creationTimestamp = 0,
isClosed = false,
pollItemViewState = aPollItemViewState,
)
val notAPollEvent = RoomPollFixture.givenATimelineEvent(
eventId = AN_EVENT_ID,
roomId = "room-id",
creationTimestamp = 0,
content = mockk<MessageTextContent>(),
)
// When
val result1 = roomPollDetailMapper.map(noIdPollEvent)
val result2 = roomPollDetailMapper.map(noTimestampPollEvent)
val result3 = roomPollDetailMapper.map(notAPollEvent)
// Then
result1 shouldBe null
result2 shouldBe null
result3 shouldBe null
}
private fun givenAPollItemViewState(): PollItemViewState {
return PollItemViewState(
question = "",
votesStatus = "",
canVote = true,
optionViewStates = emptyList(),
)
}
private fun givenAPollTimelineEvent(
eventId: String,
creationTimestamp: Long,
isClosed: Boolean,
pollItemViewState: PollItemViewState,
): TimelineEvent {
val pollCreationInfo = RoomPollFixture.givenPollCreationInfo("pollTitle")
val messageContent = RoomPollFixture.givenAMessagePollContent(pollCreationInfo)
val timelineEvent = RoomPollFixture.givenATimelineEvent(eventId, A_ROOM_ID, creationTimestamp, messageContent)
val pollResponseData = RoomPollFixture.givenAPollResponseData(isClosed, totalVotes = 1)
every { fakePollResponseDataFactory.create(timelineEvent) } returns pollResponseData
every {
fakePollItemViewStateFactory.create(
pollContent = messageContent,
pollResponseData = pollResponseData,
isSent = true
)
} returns pollItemViewState
return timelineEvent
}
private fun givenEndedPollEventId(): String {
val eventId = "ended-poll-event-id"
every {
fakeGetEndedPollEventIdUseCase.execute(
startPollEventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
)
} returns eventId
return eventId
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import android.content.Context
import im.vector.app.test.fakes.FakeNavigator
import io.mockk.mockk
import org.junit.Test
internal class RoomPollDetailNavigatorTest {
private val fakeNavigator = FakeNavigator()
private val roomPollDetailNavigator = RoomPollDetailNavigator(
navigator = fakeNavigator.instance,
)
@Test
fun `given main navigator when goToTimelineEvent then correct method main navigator is called`() {
// Given
val aContext = mockk<Context>()
val aRoomId = "roomId"
val anEventId = "eventId"
fakeNavigator.givenOpenRoomSuccess(
context = aContext,
roomId = aRoomId,
eventId = anEventId,
buildTask = true,
isInviteAlreadyAccepted = false,
trigger = null,
)
// When
roomPollDetailNavigator.goToTimelineEvent(aContext, aRoomId, anEventId)
// Then
fakeNavigator.verifyOpenRoom(
context = aContext,
roomId = aRoomId,
eventId = anEventId,
buildTask = true,
isInviteAlreadyAccepted = false,
trigger = null,
)
}
}

View file

@ -0,0 +1,126 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.detail.ui
import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.core.event.GetTimelineEventUseCase
import im.vector.app.features.home.room.detail.poll.VoteToPollUseCase
import im.vector.app.test.test
import im.vector.app.test.testDispatcher
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
private const val A_POLL_ID = "poll-id"
private const val A_ROOM_ID = "room-id"
internal class RoomPollDetailViewModelTest {
@get:Rule
val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher)
private val initialState = RoomPollDetailViewState(pollId = A_POLL_ID, roomId = A_ROOM_ID)
private val fakeGetTimelineEventUseCase = mockk<GetTimelineEventUseCase>()
private val fakeRoomPollDetailMapper = mockk<RoomPollDetailMapper>()
private val fakeVoteToPollUseCase = mockk<VoteToPollUseCase>()
private fun createViewModel(): RoomPollDetailViewModel {
return RoomPollDetailViewModel(
initialState = initialState,
getTimelineEventUseCase = fakeGetTimelineEventUseCase,
roomPollDetailMapper = fakeRoomPollDetailMapper,
voteToPollUseCase = fakeVoteToPollUseCase,
)
}
@Test
fun `given viewModel when created then poll detail is observed and viewState is updated`() {
// Given
val aPollEvent = givenAPollEvent()
val pollDetail = givenAPollDetail()
every { fakeGetTimelineEventUseCase.execute(A_ROOM_ID, A_POLL_ID) } returns flowOf(aPollEvent)
every { fakeRoomPollDetailMapper.map(aPollEvent) } returns pollDetail
val expectedViewState = initialState.copy(pollDetail = pollDetail)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
// Then
viewModelTest
.assertLatestState(expectedViewState)
.finish()
verify {
fakeGetTimelineEventUseCase.execute(A_ROOM_ID, A_POLL_ID)
fakeRoomPollDetailMapper.map(aPollEvent)
}
}
@Test
fun `given viewModel when handle vote action then correct use case is called`() {
// Given
val aPollEvent = givenAPollEvent()
val pollDetail = givenAPollDetail()
every { fakeGetTimelineEventUseCase.execute(A_ROOM_ID, A_POLL_ID) } returns flowOf(aPollEvent)
every { fakeRoomPollDetailMapper.map(aPollEvent) } returns pollDetail
val viewModel = createViewModel()
val optionId = "option-id"
justRun {
fakeVoteToPollUseCase.execute(
roomId = A_ROOM_ID,
pollEventId = A_POLL_ID,
optionId = optionId,
)
}
val action = RoomPollDetailAction.Vote(
pollEventId = A_POLL_ID,
optionId = optionId,
)
// When
val viewModelTest = viewModel.test()
viewModel.handle(action)
// Then
viewModelTest.finish()
verify {
fakeVoteToPollUseCase.execute(
roomId = A_ROOM_ID,
pollEventId = A_POLL_ID,
optionId = optionId,
)
}
}
private fun givenAPollEvent(): TimelineEvent {
return mockk()
}
private fun givenAPollDetail(): RoomPollDetail {
return RoomPollDetail(
creationTimestamp = 123L,
isEnded = false,
endedPollEventId = null,
pollItemViewState = mockk(),
)
}
}

View file

@ -16,11 +16,10 @@
package im.vector.app.features.roomprofile.polls.list.ui
import im.vector.app.core.extensions.getVectorLastMessageContent
import im.vector.app.features.home.room.detail.timeline.factory.PollOptionViewStateFactory
import im.vector.app.features.home.room.detail.timeline.helper.PollResponseDataFactory
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.test.fixtures.RoomPollFixture
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -30,11 +29,7 @@ import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo
import org.matrix.android.sdk.api.session.room.model.message.PollQuestion
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
private const val AN_EVENT_ID = "event-id"
@ -84,10 +79,12 @@ internal class PollSummaryMapperTest {
}
@Test
fun `given an ended poll event when mapping to model then result is ended poll`() {
fun `given an ended poll event when mapping to model then result is ended poll with only winner options`() {
// Given
val totalVotes = 10
val winnerOptions = listOf<PollOptionViewState.PollEnded>()
val option1 = givenAPollEndedOption(isWinner = false)
val option2 = givenAPollEndedOption(isWinner = true)
val winnerOptions = listOf(option1, option2)
val endedPollEvent = givenAPollTimelineEvent(
eventId = AN_EVENT_ID,
creationTimestamp = AN_EVENT_TIMESTAMP,
@ -101,7 +98,7 @@ internal class PollSummaryMapperTest {
creationTimestamp = AN_EVENT_TIMESTAMP,
title = A_POLL_TITLE,
totalVotes = totalVotes,
winnerOptions = winnerOptions,
winnerOptions = listOf(option2),
)
// When
@ -126,10 +123,11 @@ internal class PollSummaryMapperTest {
pollTitle = A_POLL_TITLE,
isClosed = false,
)
val notAPollEvent = givenATimelineEvent(
val notAPollEvent = RoomPollFixture.givenATimelineEvent(
eventId = AN_EVENT_ID,
roomId = "room-id",
creationTimestamp = 0,
content = mockk<MessageTextContent>()
content = mockk<MessageTextContent>(),
)
// When
@ -143,18 +141,6 @@ internal class PollSummaryMapperTest {
result3 shouldBe null
}
private fun givenATimelineEvent(
eventId: String,
creationTimestamp: Long,
content: MessageContent,
): TimelineEvent {
val timelineEvent = mockk<TimelineEvent>()
every { timelineEvent.root.eventId } returns eventId
every { timelineEvent.root.originServerTs } returns creationTimestamp
every { timelineEvent.getVectorLastMessageContent() } returns content
return timelineEvent
}
private fun givenAPollTimelineEvent(
eventId: String,
creationTimestamp: Long,
@ -163,10 +149,10 @@ internal class PollSummaryMapperTest {
totalVotes: Int = 0,
winnerOptions: List<PollOptionViewState.PollEnded> = emptyList(),
): TimelineEvent {
val pollCreationInfo = givenPollCreationInfo(pollTitle)
val messageContent = givenAMessagePollContent(pollCreationInfo)
val timelineEvent = givenATimelineEvent(eventId, creationTimestamp, messageContent)
val pollResponseData = givenAPollResponseData(isClosed, totalVotes)
val pollCreationInfo = RoomPollFixture.givenPollCreationInfo(pollTitle)
val messageContent = RoomPollFixture.givenAMessagePollContent(pollCreationInfo)
val timelineEvent = RoomPollFixture.givenATimelineEvent(eventId, "room-id", creationTimestamp, messageContent)
val pollResponseData = RoomPollFixture.givenAPollResponseData(isClosed, totalVotes)
every { fakePollResponseDataFactory.create(timelineEvent) } returns pollResponseData
every {
fakePollOptionViewStateFactory.createPollEndedOptions(
@ -178,24 +164,9 @@ internal class PollSummaryMapperTest {
return timelineEvent
}
private fun givenAMessagePollContent(pollCreationInfo: PollCreationInfo): MessagePollContent {
return MessagePollContent(
unstablePollCreationInfo = pollCreationInfo,
)
}
private fun givenPollCreationInfo(pollTitle: String): PollCreationInfo {
return PollCreationInfo(
question = PollQuestion(unstableQuestion = pollTitle),
)
}
private fun givenAPollResponseData(isClosed: Boolean, totalVotes: Int): PollResponseData {
return PollResponseData(
myVote = "",
votes = emptyMap(),
isClosed = isClosed,
totalVotes = totalVotes,
)
private fun givenAPollEndedOption(isWinner: Boolean): PollOptionViewState.PollEnded {
return mockk<PollOptionViewState.PollEnded>().also {
every { it.isWinner } returns isWinner
}
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2023 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.roomprofile.polls.list.ui
import android.content.Intent
import im.vector.app.features.roomprofile.polls.detail.ui.RoomPollDetailActivity
import im.vector.app.test.fakes.FakeContext
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Before
import org.junit.Test
internal class RoomPollsListNavigatorTest {
private val fakeContext = FakeContext()
private val roomPollsListNavigator = RoomPollsListNavigator()
@Before
fun setUp() {
mockkObject(RoomPollDetailActivity.Companion)
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given info about poll when goToPollDetails then it starts the correct activity`() {
// Given
val aPollId = "pollId"
val aRoomId = "roomId"
val isEnded = true
val intent = givenIntentForPollDetails(aPollId, aRoomId, isEnded)
fakeContext.givenStartActivity(intent)
// When
roomPollsListNavigator.goToPollDetails(fakeContext.instance, aPollId, aRoomId, isEnded)
// Then
fakeContext.verifyStartActivity(intent)
}
private fun givenIntentForPollDetails(pollId: String, roomId: String, isEnded: Boolean): Intent {
val intent = mockk<Intent>()
every { RoomPollDetailActivity.newIntent(fakeContext.instance, pollId, roomId, isEnded) } returns intent
return intent
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2023 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.test.fakes
import android.content.Context
import im.vector.app.features.analytics.plan.ViewRoom
import im.vector.app.features.navigation.Navigator
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.verify
class FakeNavigator {
val instance: Navigator = mockk()
fun givenOpenRoomSuccess(
context: Context,
roomId: String,
eventId: String?,
buildTask: Boolean,
isInviteAlreadyAccepted: Boolean,
trigger: ViewRoom.Trigger?,
) {
justRun { instance.openRoom(context, roomId, eventId, buildTask, isInviteAlreadyAccepted, trigger) }
}
fun verifyOpenRoom(
context: Context,
roomId: String,
eventId: String?,
buildTask: Boolean,
isInviteAlreadyAccepted: Boolean,
trigger: ViewRoom.Trigger?,
) {
verify { instance.openRoom(context, roomId, eventId, buildTask, isInviteAlreadyAccepted, trigger) }
}
}

View file

@ -16,14 +16,26 @@
package im.vector.app.test.fakes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineService
import org.matrix.android.sdk.api.util.Optional
class FakeTimelineService : TimelineService by mockk() {
fun givenTimelineEventReturns(eventId: String, event: TimelineEvent?) {
every { getTimelineEvent(eventId) } returns event
}
fun givenTimelineEventLiveReturns(
eventId: String,
event: TimelineEvent?
): LiveData<Optional<TimelineEvent>> {
return MutableLiveData(Optional(event)).also {
every { getTimelineEventLive(eventId) } returns it
}
}
}

View file

@ -0,0 +1,65 @@
/*
* Copyright (c) 2023 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.test.fixtures
import im.vector.app.core.extensions.getVectorLastMessageContent
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo
import org.matrix.android.sdk.api.session.room.model.message.PollQuestion
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
object RoomPollFixture {
fun givenATimelineEvent(
eventId: String,
roomId: String,
creationTimestamp: Long,
content: MessageContent,
): TimelineEvent {
val timelineEvent = mockk<TimelineEvent>()
every { timelineEvent.root.eventId } returns eventId
every { timelineEvent.roomId } returns roomId
every { timelineEvent.root.originServerTs } returns creationTimestamp
every { timelineEvent.getVectorLastMessageContent() } returns content
return timelineEvent
}
fun givenAMessagePollContent(pollCreationInfo: PollCreationInfo): MessagePollContent {
return MessagePollContent(
unstablePollCreationInfo = pollCreationInfo,
)
}
fun givenPollCreationInfo(pollTitle: String): PollCreationInfo {
return PollCreationInfo(
question = PollQuestion(unstableQuestion = pollTitle),
)
}
fun givenAPollResponseData(isClosed: Boolean, totalVotes: Int): PollResponseData {
return PollResponseData(
myVote = "",
votes = emptyMap(),
isClosed = isClosed,
totalVotes = totalVotes,
)
}
}