From 9b2be880fc4c49acd91d529cd2f18bfb270f2a52 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Wed, 27 Mar 2024 15:34:39 +0100 Subject: [PATCH] Remove live updates --- gradle/libs.versions.toml | 2 +- .../io/openfeedback/m3/CommentItemsPreview.kt | 8 +- .../io/openfeedback/m3/CommentPreview.kt | 4 +- .../m3/OpenFeedbackLayoutPreview.kt | 17 +- .../kotlin/io/openfeedback/m3/Comment.kt | 2 +- .../io/openfeedback/m3/OpenFeedbackLayout.kt | 16 +- .../extensions/LocalDateTimeExtensions.kt | 21 - .../viewmodels/OpenFeedbackFirebaseConfig.kt | 1 + .../viewmodels/OpenFeedbackViewModel.kt | 473 ++++++++++++++++-- .../extensions/LocalDateTimeExtensions.kt | 6 - .../viewmodels/mappers/UiMappers.kt | 109 ---- .../viewmodels/models/UIComment.kt | 4 +- .../viewmodels/models/UISessionFeedback.kt | 9 +- .../models/UISessionFeedbackWithColors.kt | 6 - .../extensions/LocalDateTimeExtensions.ios.kt | 17 - .../mappers/FirestoreToModelMappers.kt | 19 +- .../io/openfeedback/OpenFeedbackRepository.kt | 68 --- .../caches/OptimisticVoteCaching.kt | 45 -- .../mappers/FirestoreToModelMappers.kt | 4 +- .../kotlin/io/openfeedback/model/Model.kt | 55 +- .../openfeedback/sources/OpenFeedbackAuth.kt | 33 +- .../sources/OpenFeedbackFirestore.kt | 87 +++- .../moko-resources/base/strings.xml | 1 + .../mappers/FirestoreToModelMappers.ios.kt | 17 +- .../kotlin/io/openfeedback/shared/main.kt | 14 +- 25 files changed, 590 insertions(+), 448 deletions(-) delete mode 100644 openfeedback-viewmodel/src/androidMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.kt delete mode 100644 openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.kt delete mode 100644 openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/mappers/UiMappers.kt delete mode 100644 openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UISessionFeedbackWithColors.kt delete mode 100644 openfeedback-viewmodel/src/iosMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.ios.kt delete mode 100644 openfeedback/src/commonMain/kotlin/io/openfeedback/OpenFeedbackRepository.kt delete mode 100644 openfeedback/src/commonMain/kotlin/io/openfeedback/caches/OptimisticVoteCaching.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d749e6..bcc828b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ activityCompose = "1.8.2" appcompat = "1.6.1" kotlin_lang = "1.9.22" kotlin_coroutines = "1.8.0" -kotlinx_datetime = "0.5.0" +kotlinx_datetime = "0.6.0-RC.2" kotlinx_serialization = "1.6.3" androidx_core = "1.12.0" firebase_common = "20.4.3" diff --git a/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/CommentItemsPreview.kt b/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/CommentItemsPreview.kt index 45f71d1..5261ebc 100644 --- a/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/CommentItemsPreview.kt +++ b/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/CommentItemsPreview.kt @@ -14,21 +14,21 @@ private fun CommentItemsPreview() { comments = listOf( UIComment( id = "", - voteItemId = "", message = "Nice comment", createdAt = "08 August 2023", upVotes = 8, dots = listOf(UIDot(x = .5f, y = .5f, color = "FF00CC")), - votedByUser = true + votedByUser = true, + fromUser = false ), UIComment( id = "", - voteItemId = "", message = "Another comment", createdAt = "08 August 2023", upVotes = 0, dots = listOf(UIDot(x = .5f, y = .5f, color = "FF00CC")), - votedByUser = true + votedByUser = true, + fromUser = false ) ), commentInput = { diff --git a/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/CommentPreview.kt b/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/CommentPreview.kt index 816618c..39e353e 100644 --- a/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/CommentPreview.kt +++ b/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/CommentPreview.kt @@ -13,12 +13,12 @@ private fun CommentPreview() { Comment( comment = UIComment( id = "", - voteItemId = "", message = "Super talk and great speakers!", createdAt = "08 August 2023", upVotes = 8, dots = listOf(UIDot(x = .5f, y = .5f, color = "FF00CC")), - votedByUser = true + votedByUser = true, + fromUser = false ), onClick = {} ) diff --git a/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/OpenFeedbackLayoutPreview.kt b/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/OpenFeedbackLayoutPreview.kt index efd4c82..555786b 100644 --- a/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/OpenFeedbackLayoutPreview.kt +++ b/openfeedback-m3/src/androidMain/kotlin/io/openfeedback/m3/OpenFeedbackLayoutPreview.kt @@ -21,29 +21,27 @@ private fun OpenFeedbackLayoutPreview() { MaterialTheme { OpenFeedbackLayout( sessionFeedback = UISessionFeedback( - commentValue = "", - commentVoteItemId = "", comments = listOf( UIComment( id = "", - voteItemId = "", message = "Nice comment", createdAt = "08 August 2023", upVotes = 8, dots = listOf(UIDot(x = .5f, y = .5f, color = "FF00CC")), - votedByUser = true + votedByUser = true, + fromUser = false ), UIComment( id = "", - voteItemId = "", message = "Another one", createdAt = "08 August 2023", upVotes = 0, dots = listOf(UIDot(x = .5f, y = .5f, color = "FF00CC")), - votedByUser = true + votedByUser = true, + fromUser = false ) ), - voteItem = listOf( + voteItems = listOf( UIVoteItem( id = "", text = "Fun", @@ -56,14 +54,15 @@ private fun OpenFeedbackLayoutPreview() { dots = listOf(UIDot(x = .5f, y = .5f, color = "FF00CC")), votedByUser = true ) - ) + ), + colors = emptyList() ), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), commentInput = { CommentInput(value = "", onValueChange = {}, onSubmit = {}) }, - comment = { Comment(comment = it, onClick = {}) } + comment = { Comment(comment = it, onClick = {}) }, ) { VoteCard( voteModel = it, diff --git a/openfeedback-m3/src/commonMain/kotlin/io/openfeedback/m3/Comment.kt b/openfeedback-m3/src/commonMain/kotlin/io/openfeedback/m3/Comment.kt index bc0138a..8347931 100644 --- a/openfeedback-m3/src/commonMain/kotlin/io/openfeedback/m3/Comment.kt +++ b/openfeedback-m3/src/commonMain/kotlin/io/openfeedback/m3/Comment.kt @@ -59,7 +59,7 @@ fun Comment( style = subStyle ) Text( - text = comment.createdAt, + text = comment.createdAt + (if (comment.fromUser) stringResource(MR.strings.from_you) else ""), color = contentColor.copy(alpha = .7f), style = subStyle ) diff --git a/openfeedback-m3/src/commonMain/kotlin/io/openfeedback/m3/OpenFeedbackLayout.kt b/openfeedback-m3/src/commonMain/kotlin/io/openfeedback/m3/OpenFeedbackLayout.kt index ba70a28..5ba7d90 100644 --- a/openfeedback-m3/src/commonMain/kotlin/io/openfeedback/m3/OpenFeedbackLayout.kt +++ b/openfeedback-m3/src/commonMain/kotlin/io/openfeedback/m3/OpenFeedbackLayout.kt @@ -10,6 +10,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -52,6 +56,8 @@ fun OpenFeedback( is OpenFeedbackUiState.Loading -> loading() is OpenFeedbackUiState.Success -> { val session = (uiState.value as OpenFeedbackUiState.Success).session + var text by remember { mutableStateOf("") } + OpenFeedbackLayout( sessionFeedback = session, modifier = modifier, @@ -64,9 +70,9 @@ fun OpenFeedback( }, commentInput = { CommentInput( - value = session.commentValue, - onValueChange = viewModel::valueChangedComment, - onSubmit = viewModel::submitComment, + value = text, + onValueChange = { text = it }, + onSubmit = { viewModel.submitComment(text) }, modifier = Modifier.fillMaxWidth() ) }, @@ -101,13 +107,13 @@ fun OpenFeedbackLayout( verticalArrangement = verticalArrangement ) { VoteItems( - voteItems = sessionFeedback.voteItem, + voteItems = sessionFeedback.voteItems, columnCount = columnCount, horizontalArrangement = horizontalArrangement, verticalArrangement = verticalArrangement, content = content ) - if (sessionFeedback.commentVoteItemId != null) { + if (sessionFeedback.comments.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) CommentItems( comments = sessionFeedback.comments, diff --git a/openfeedback-viewmodel/src/androidMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.kt b/openfeedback-viewmodel/src/androidMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.kt deleted file mode 100644 index efe694f..0000000 --- a/openfeedback-viewmodel/src/androidMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.kt +++ /dev/null @@ -1,21 +0,0 @@ -package io.openfeedback.viewmodels.extensions - -import com.vanniktech.locale.Locale -import com.vanniktech.locale.toJavaLocale -import kotlinx.datetime.LocalDateTime -import java.text.SimpleDateFormat -import java.util.Date - -actual fun LocalDateTime.format(pattern: String, locale: Locale): String { - val formatter = SimpleDateFormat("dd MMM, hh:mm", locale.toJavaLocale()) - return formatter.format( - Date( - date.year, - date.monthNumber, - date.dayOfMonth, - time.hour, - time.minute, - time.second - ) - ) -} \ No newline at end of file diff --git a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/OpenFeedbackFirebaseConfig.kt b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/OpenFeedbackFirebaseConfig.kt index 0294c9b..f1591ec 100644 --- a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/OpenFeedbackFirebaseConfig.kt +++ b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/OpenFeedbackFirebaseConfig.kt @@ -35,6 +35,7 @@ data class OpenFeedbackFirebaseConfig( apiKey = "AIzaSyB3ELJsaiItrln0uDGSuuHE1CfOJO67Hb4", databaseUrl = "https://open-feedback-42.firebaseio.com/" ) + } } } diff --git a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/OpenFeedbackViewModel.kt b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/OpenFeedbackViewModel.kt index eb8cb79..2f47890 100644 --- a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/OpenFeedbackViewModel.kt +++ b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/OpenFeedbackViewModel.kt @@ -3,99 +3,478 @@ package io.openfeedback.viewmodels import com.vanniktech.locale.Locale import dev.gitlive.firebase.FirebaseApp import dev.icerock.moko.mvvm.viewmodel.ViewModel -import io.openfeedback.OpenFeedbackRepository -import io.openfeedback.viewmodels.mappers.convertToUiSessionFeedback -import io.openfeedback.viewmodels.models.UIComment -import io.openfeedback.viewmodels.models.UISessionFeedback -import io.openfeedback.viewmodels.models.UISessionFeedbackWithColors -import io.openfeedback.viewmodels.models.UIVoteItem -import io.openfeedback.caches.OptimisticVoteCaching +import io.openfeedback.model.Comment +import io.openfeedback.model.CommentsMap +import io.openfeedback.model.Project +import io.openfeedback.model.SessionThing +import io.openfeedback.model.UserVote +import io.openfeedback.model.VoteItemCount import io.openfeedback.model.VoteStatus import io.openfeedback.sources.OpenFeedbackAuth import io.openfeedback.sources.OpenFeedbackFirestore +import io.openfeedback.viewmodels.models.UIComment +import io.openfeedback.viewmodels.models.UIDot +import io.openfeedback.viewmodels.models.UISessionFeedback +import io.openfeedback.viewmodels.models.UIVoteItem +import io.openfeedback.viewmodels.models.commentVoteItemId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.scan import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime +import kotlin.math.absoluteValue +import kotlin.random.Random sealed class OpenFeedbackUiState { data object Loading : OpenFeedbackUiState() class Success(val session: UISessionFeedback) : OpenFeedbackUiState() } +sealed interface Event +private class CommitComment( + val text: String +) : Event + +private class VoteItemEvent( + val voteItemId: String, + val votedByUser: Boolean +) : Event + +private class VoteCommentEvent( + val commentId: String, + val votedByUser: Boolean +) : Event + +internal data class SessionData( + val project: Project, + val userId: String, + val votedItemIds: Set, + val votedCommentIds: Set, + /** + * The aggregate counter for voteItems. + * The counter for comments is in [comments] + * + * key is a voteItemId + */ + val voteItemAggregates: Map, + val comments: List, +) + +/** + * Turns the openfeedback model into something that is a bit more palatable + */ +private fun sessionData( + userId: String, + project: Project, + userVotes: List, + sessionThings: Map, +): SessionData { + /** + * substract the user vote from the aggregate, we'll maintain the aggregate on our side + */ + val votedItemIds = userVotes.mapNotNull { + if (it.text != null) { + // This is a comment + return@mapNotNull null + } + if (it.voteItemId == project.commentVoteItemId()) { + // In theory one cannot vote on the "text" vote item but 🤷 + return@mapNotNull null + } + it.voteItemId + }.toSet() + val votedCommentIds = userVotes.mapNotNull { + if (it.text != null) { + // This is a comment + return@mapNotNull null + } + /** + * Do we need to check voteItemId? + */ +// if (it.voteItemId != project.commentVoteItemId()) { +// return@mapNotNull null +// } + // If it.id is not null, it's the upvote for a comment + it.id + }.toSet() + + val voteItemAggregates = sessionThings.mapValues { + val value = it.value + if (value !is VoteItemCount) { + return@mapValues null + } + /** + * Be robust to negative votes + */ + val minValue = if (votedCommentIds.contains(it.key)) { + 1L + } else { + 0L + } + value.count.coerceAtLeast(minValue) + }.filter { + it.value != null + } as Map + + val commentsMaps = sessionThings.values.filterIsInstance() + if (commentsMaps.size > 1) { + val keys = sessionThings.filter { it.value is CommentsMap }.keys + println("Several comment maps for voteItemIds = '$keys'.") + } + val commentMap = (commentsMaps.firstOrNull() ?: CommentsMap(emptyMap())).coerceAggregations(votedCommentIds) + + val comments = commentMap.all.values.sortedByDescending { it.updatedAt } + return SessionData( + project = project, + userId = userId, + votedItemIds = votedItemIds, + votedCommentIds = votedCommentIds, + voteItemAggregates = voteItemAggregates, + comments = comments + ) +} + +private fun CommentsMap.coerceAggregations(votedCommentIds: Set): CommentsMap { + return CommentsMap(all.mapValues { + val minValue = if (votedCommentIds.contains(it.key)) { + 1L + } else { + 0L + } + it.value.copy(plus = it.value.plus.coerceAtLeast(minValue)) + }) +} +private fun SessionData.voteItem(voteItemId: String, voted: Boolean): SessionData { + val newVotedItemsIds = if (voted) { + votedItemIds + voteItemId + } else { + votedItemIds - voteItemId + } + + val newAggregates = voteItemAggregates.mapValues { + if (it.key == voteItemId) { + if (voted) { + it.value + 1 + } else { + it.value - 1 + } + } else { + it.value + } + } + return copy( + votedItemIds = newVotedItemsIds, + voteItemAggregates = newAggregates + ) +} + +private fun SessionData.voteComment(commentId: String, voted: Boolean): SessionData { + val newVotedCommentIds = if (voted) { + votedCommentIds + commentId + } else { + votedCommentIds - commentId + } + + val newComments = comments.map { + if (it.id == commentId) { + if (voted) { + it.copy(plus = it.plus + 1) + } else { + it.copy(plus = it.plus - 1) + } + } else { + it + } + } + return copy( + votedCommentIds = newVotedCommentIds, + comments = newComments + ) +} +private fun SessionData.commitComment(text: String): SessionData { + var found = false + var newComments = comments.map { + if (it.userId == userId) { + found = true + it.copy(updatedAt = Clock.System.now(), text = text) + } else { + it + } + } + if (!found) { + newComments = newComments + Comment( + id = "placeholderId", + userId = userId, + createdAt = Clock.System.now(), + updatedAt = Clock.System.now(), + text = text, + plus = 0 + ) + } + return copy( + comments = newComments.sortedByDescending { it.updatedAt } + ) +} + +@OptIn(ExperimentalCoroutinesApi::class) class OpenFeedbackViewModel( - private val firebaseApp: FirebaseApp, + firebaseApp: FirebaseApp, private val projectId: String, private val sessionId: String, private val locale: Locale ) : ViewModel() { - private val repository = OpenFeedbackRepository( - auth = OpenFeedbackAuth.create(firebaseApp), - firestore = OpenFeedbackFirestore.create(firebaseApp), - optimisticVoteCaching = OptimisticVoteCaching() - ) + private val auth = OpenFeedbackAuth(firebaseApp) + private val firestore = OpenFeedbackFirestore.create(firebaseApp) + private var commentVoteItemId: String? = null + + private val voteEvents = MutableSharedFlow() private val _uiState = MutableStateFlow(OpenFeedbackUiState.Loading) val uiState: StateFlow = _uiState init { + /** + * Warning: This screen is not 100% reactive because there are 2 sources of truth for votes: + * - userVotes are written by the app + * - sessionVotes is written by the backend which computes the aggregates + * + * We used to be reactive but this creates a blinking effect because there's a long delay until the cloud + * function updates sessionVotes. + * + * Instead, just retrieve the data from the network once and use local votes. + * There is no feedback if a given vote fails. + * + * See also https://stackoverflow.com/questions/58840642/set-update-collection-or-document-but-only-locally + */ viewModelScope.launch { combine( - flow = repository.project(projectId), - flow2 = repository.userVotes(projectId, sessionId), - flow3 = repository.totalVotes(projectId, sessionId), - transform = { project, votes, totals -> - UISessionFeedbackWithColors( - convertToUiSessionFeedback(project, votes, totals, locale), - project.chipColors - ) - } - ).collect { - val oldSession = - if (uiState.value is OpenFeedbackUiState.Success) (uiState.value as OpenFeedbackUiState.Success).session - else null - _uiState.value = OpenFeedbackUiState.Success( - it.convertToUiSessionFeedback(oldSession) + firestore.project(projectId), + firestore.userVotes( + projectId = projectId, + userId = auth.userId(), + sessionId = sessionId, + ), + firestore.sessionThings(projectId = projectId, sessionId = sessionId), + ) { project, userVotesResult, sessionThingsResult -> + sessionData( + auth.userId(), + project, + userVotesResult.data, + sessionThingsResult.data, ) - } - } - } + }.filterNotNull() + /** + * Take only the first (maybe cached) item. Meaning we might be a bit stale sometimes but this prevents + * the network result to kick in with completely different results after the fact, which can be surprising + */ + .filterFirst() + .flatMapLatest { sessionData -> + /** + * Remember the commentVoteItemId + */ + commentVoteItemId = sessionData.project.commentVoteItemId() + + voteEvents.scan(sessionData) { acc, value -> + when (value) { + is VoteItemEvent -> { + acc.voteItem(value.voteItemId, value.votedByUser) + } + + is VoteCommentEvent -> { + acc.voteComment(value.commentId, value.votedByUser) + } - fun valueChangedComment(value: String) { - if (_uiState.value !is OpenFeedbackUiState.Success) return - val session = (_uiState.value as OpenFeedbackUiState.Success).session - _uiState.value = OpenFeedbackUiState.Success(session.copy(commentValue = value)) + is CommitComment -> { + acc.commitComment(value.text) + } + } + } + }.mapWithPreviousValue { prev, cur -> + if (prev == null) { + cur.toUISessionFeedback( + locale, + null, + null + ) + } else { + cur.toUISessionFeedback( + locale, + prev.voteItems, + prev.comments + ) + } + }.collect { + _uiState.value = OpenFeedbackUiState.Success(it) + } + } } - fun submitComment() = viewModelScope.launch { - if (_uiState.value !is OpenFeedbackUiState.Success) return@launch - val session = (_uiState.value as OpenFeedbackUiState.Success).session - if (session.commentVoteItemId == null) return@launch - repository.newComment( + fun submitComment(text: String) = viewModelScope.launch { + if (commentVoteItemId == null) { + println("No commentVoteItemId") + return@launch + } + voteEvents.emit(CommitComment(text)) + firestore.setComment( projectId = projectId, talkId = sessionId, - voteItemId = session.commentVoteItemId, + voteItemId = commentVoteItemId!!, status = VoteStatus.Active, - text = session.commentValue + text = text, + userId = auth.userId() ) } fun vote(voteItem: UIVoteItem) = viewModelScope.launch { - repository.setVote( + voteEvents.emit( + VoteItemEvent( + voteItemId = voteItem.id, + votedByUser = !voteItem.votedByUser + ) + ) + firestore.setVote( projectId = projectId, talkId = sessionId, voteItemId = voteItem.id, - status = if (!voteItem.votedByUser) VoteStatus.Active else VoteStatus.Deleted + status = if (!voteItem.votedByUser) VoteStatus.Active else VoteStatus.Deleted, + userId = auth.userId() ) } fun upVote(comment: UIComment) = viewModelScope.launch { - repository.upVote( + voteEvents.emit( + VoteCommentEvent( + commentId = comment.id, + !comment.votedByUser + ) + ) + if (commentVoteItemId == null) { + println("No commentVoteItemId yet") + } + firestore.upVote( projectId = projectId, talkId = sessionId, - voteItemId = comment.voteItemId, + voteItemId = commentVoteItemId ?: "oopsie", voteId = comment.id, - status = if (!comment.votedByUser) VoteStatus.Active else VoteStatus.Deleted + status = if (!comment.votedByUser) VoteStatus.Active else VoteStatus.Deleted, + userId = auth.userId() ) } } + +/** + * Allows access to the previous emitted value + * We use that to have stable dots coordinates + */ +private fun Flow.mapWithPreviousValue(block: (previous: R?, current: T) -> R): Flow { + var prev: R? = null + return flow { + this@mapWithPreviousValue.collect { + block(prev, it).also { + emit(it) + prev = it + } + } + } +} + +/** + * A variation of take(1) that does not cancel the flow so that the query continues running and + * network results get written + */ +private fun Flow.filterFirst(): Flow { + var first = true + + return mapNotNull { + if (first) { + first = false + it + } else { + null + } + } +} + +private fun SessionData.toUISessionFeedback( + locale: Locale, + oldVoteItems: List?, + oldComments: List?, +): UISessionFeedback { + val sessionData = this + val votedItemIds = sessionData.votedItemIds + return UISessionFeedback( + voteItems = sessionData.project.voteItems + .filter { it.type == "boolean" } + .map { voteItem -> + val oldVoteItem = oldVoteItems?.firstOrNull { it.id == voteItem.id } + val count = sessionData.voteItemAggregates[voteItem.id]?.toInt() ?: 0 + val oldDots = oldVoteItem?.dots.orEmpty() + val diff = count - oldDots.size + val dots = if (diff > 0) { + oldDots + newDots(diff, sessionData.project.chipColors) + } else { + oldDots.dropLast(diff.absoluteValue) + } + UIVoteItem( + id = voteItem.id, + text = voteItem.localizedName(locale.language.code), + dots = dots, + votedByUser = votedItemIds.contains(voteItem.id) + ) + }, + comments = sessionData.comments.map { commentItem -> + val localDateTime = + commentItem.createdAt.toLocalDateTime(TimeZone.currentSystemDefault()) + val oldComment = oldComments?.firstOrNull { it.id == commentItem.id } + val oldDots = oldComment?.dots.orEmpty() + val diff = commentItem.plus.toInt() - oldDots.size + val dots = if (diff > 0) { + oldDots + newDots(diff, sessionData.project.chipColors) + } else { + oldDots.dropLast(diff.absoluteValue) + } + UIComment( + id = commentItem.id, + message = commentItem.text, + createdAt = localDateTime.format(dateFormat), + upVotes = commentItem.plus.toInt(), + dots = dots, + votedByUser = sessionData.votedCommentIds.contains(commentItem.id), + fromUser = commentItem.userId == sessionData.userId + ) + }, + colors = sessionData.project.chipColors + ) +} + +internal fun newDots(count: Int, possibleColors: List): List = 0.until(count).map { + UIDot( + Random.nextFloat(), + Random.nextFloat().coerceIn(0.1f, 0.9f), + possibleColors[Random.nextInt().absoluteValue % possibleColors.size] + ) +} + +private val dateFormat = LocalDateTime.Format { + dayOfMonth() + char(' ') + monthName(MonthNames.ENGLISH_ABBREVIATED) + chars(", ") + hour() + char(':') + minute() +} diff --git a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.kt b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.kt deleted file mode 100644 index c6bdc41..0000000 --- a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.openfeedback.viewmodels.extensions - -import com.vanniktech.locale.Locale -import kotlinx.datetime.LocalDateTime - -expect fun LocalDateTime.format(pattern: String, locale: Locale): String \ No newline at end of file diff --git a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/mappers/UiMappers.kt b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/mappers/UiMappers.kt deleted file mode 100644 index 20dffc2..0000000 --- a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/mappers/UiMappers.kt +++ /dev/null @@ -1,109 +0,0 @@ -package io.openfeedback.viewmodels.mappers - -import com.vanniktech.locale.Locale -import io.openfeedback.model.Project -import io.openfeedback.model.SessionVotes -import io.openfeedback.model.UserVote -import io.openfeedback.viewmodels.extensions.format -import io.openfeedback.viewmodels.models.UIComment -import io.openfeedback.viewmodels.models.UIDot -import io.openfeedback.viewmodels.models.UISessionFeedback -import io.openfeedback.viewmodels.models.UISessionFeedbackWithColors -import io.openfeedback.viewmodels.models.UIVoteItem -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime -import kotlin.math.absoluteValue -import kotlin.random.Random - -fun convertToUiSessionFeedback( - project: Project, - userVotes: List, - totalVotes: SessionVotes, - locale: Locale -): UISessionFeedback { - val userUpVoteIds = userVotes.filter { it.voteId != null }.map { it.voteId!! } - val userVoteIds = userVotes.map { it.voteItemId } - return UISessionFeedback( - commentValue = "", - commentVoteItemId = project.voteItems.find { it.type == "text" }?.id, - comments = totalVotes.comments.map { commentItem -> - val localDateTime = commentItem.value.createdAt.toLocalDateTime(TimeZone.currentSystemDefault()) - UIComment( - id = commentItem.value.id, - voteItemId = commentItem.value.voteItemId, - message = commentItem.value.text, - createdAt = localDateTime.format(pattern = "dd MMM, hh:mm", locale = locale), - upVotes = commentItem.value.plus.toInt(), - dots = dots(commentItem.value.plus.toInt(), project.chipColors), - votedByUser = userUpVoteIds.contains(commentItem.value.id) - ) - }, - voteItem = project.voteItems - .filter { it.type == "boolean" } - .map { voteItem -> - val count = totalVotes.votes[voteItem.id]?.toInt() ?: 0 - UIVoteItem( - id = voteItem.id, - text = voteItem.localizedName(locale.language.code), - dots = dots(count, project.chipColors), - votedByUser = userVoteIds.contains(voteItem.id) - ) - } - ) -} - -fun UISessionFeedbackWithColors.convertToUiSessionFeedback( - oldSessionFeedback: UISessionFeedback? -): UISessionFeedback = UISessionFeedback( - commentValue = oldSessionFeedback?.commentValue ?: "", - commentVoteItemId = oldSessionFeedback?.commentVoteItemId ?: this.session.commentVoteItemId, - comments = this.session.comments.map { newCommentItem -> - val oldCommentItem = oldSessionFeedback?.comments?.find { it.id == newCommentItem.id } - val newDots = if (oldCommentItem != null) { - val diff = newCommentItem.dots.size - oldCommentItem.dots.size - if (diff > 0) { - oldCommentItem.dots + dots(diff, this.colors) - } else { - oldCommentItem.dots.dropLast(diff.absoluteValue) - } - } else { - newCommentItem.dots - } - UIComment( - id = newCommentItem.id, - voteItemId = newCommentItem.voteItemId, - message = newCommentItem.message, - createdAt = newCommentItem.createdAt, - upVotes = newCommentItem.upVotes, - dots = newDots, - votedByUser = newCommentItem.votedByUser - ) - }, - voteItem = this.session.voteItem.map { newVoteItem -> - val oldVoteItem = oldSessionFeedback?.voteItem?.find { it.id == newVoteItem.id } - val newDots = if (oldVoteItem != null) { - val diff = newVoteItem.dots.size - oldVoteItem.dots.size - if (diff > 0) { - oldVoteItem.dots + dots(diff, this.colors) - } else { - oldVoteItem.dots.dropLast(diff.absoluteValue) - } - } else { - newVoteItem.dots - } - UIVoteItem( - id = newVoteItem.id, - text = newVoteItem.text, - dots = newDots, - votedByUser = newVoteItem.votedByUser - ) - } -) - -private fun dots(count: Int, possibleColors: List): List = 0.until(count).map { - UIDot( - Random.nextFloat(), - Random.nextFloat().coerceIn(0.1f, 0.9f), - possibleColors[Random.nextInt().absoluteValue % possibleColors.size] - ) -} diff --git a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UIComment.kt b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UIComment.kt index 2bc71be..120b15f 100644 --- a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UIComment.kt +++ b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UIComment.kt @@ -5,10 +5,10 @@ import androidx.compose.runtime.Immutable @Immutable data class UIComment( val id: String, - val voteItemId: String, val message: String, val createdAt: String, val upVotes: Int, val dots: List, - val votedByUser: Boolean + val votedByUser: Boolean, + val fromUser: Boolean ) diff --git a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UISessionFeedback.kt b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UISessionFeedback.kt index e6c64b3..60c1fa2 100644 --- a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UISessionFeedback.kt +++ b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UISessionFeedback.kt @@ -1,11 +1,14 @@ package io.openfeedback.viewmodels.models import androidx.compose.runtime.Immutable +import io.openfeedback.model.Project @Immutable data class UISessionFeedback( - val commentValue: String, - val commentVoteItemId: String?, val comments: List, - val voteItem: List + val voteItems: List, + val colors: List, ) + +internal fun Project.commentVoteItemId(): String? = voteItems.find { it.type == "text" }?.id + diff --git a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UISessionFeedbackWithColors.kt b/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UISessionFeedbackWithColors.kt deleted file mode 100644 index 3807800..0000000 --- a/openfeedback-viewmodel/src/commonMain/kotlin/io/openfeedback/viewmodels/models/UISessionFeedbackWithColors.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.openfeedback.viewmodels.models - -data class UISessionFeedbackWithColors( - val session: UISessionFeedback, - val colors: List -) diff --git a/openfeedback-viewmodel/src/iosMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.ios.kt b/openfeedback-viewmodel/src/iosMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.ios.kt deleted file mode 100644 index 8b75c5d..0000000 --- a/openfeedback-viewmodel/src/iosMain/kotlin/io/openfeedback/viewmodels/extensions/LocalDateTimeExtensions.ios.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.openfeedback.viewmodels.extensions - -import com.vanniktech.locale.Locale -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.toNSDateComponents -import platform.Foundation.NSDateFormatter -import platform.Foundation.NSLocale - -actual fun LocalDateTime.format(pattern: String, locale: Locale): String { - val dateFormatter = NSDateFormatter() - dateFormatter.dateFormat = pattern - dateFormatter.locale = NSLocale(locale.toString()) - return dateFormatter.stringFromDate( - date = toNSDateComponents().date() - ?: throw IllegalStateException("Could not convert kotlin date to NSDate $this") - ) -} diff --git a/openfeedback/src/androidMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.kt b/openfeedback/src/androidMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.kt index b43d33b..24579c9 100644 --- a/openfeedback/src/androidMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.kt +++ b/openfeedback/src/androidMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.kt @@ -1,21 +1,10 @@ package io.openfeedback.mappers import com.google.firebase.Timestamp -import io.openfeedback.model.Comment import kotlinx.datetime.Instant -actual fun Map.convertToModel( - id: String, - voteItemId: String -): Comment { - val createdAt = this["createdAt"] as Timestamp - val updatedAt = this["updatedAt"] as Timestamp - return Comment( - id = id, - voteItemId = voteItemId, - text = this["text"] as String, - plus = this["plus"] as Long, - createdAt = Instant.fromEpochSeconds(createdAt.seconds, createdAt.nanoseconds), - updatedAt = Instant.fromEpochSeconds(updatedAt.seconds, updatedAt.nanoseconds) - ) +actual fun timestampToInstant(nativeTimestamp: Any): Instant { + (nativeTimestamp as Timestamp) + return Instant.fromEpochSeconds(nativeTimestamp.seconds, nativeTimestamp.nanoseconds) } + diff --git a/openfeedback/src/commonMain/kotlin/io/openfeedback/OpenFeedbackRepository.kt b/openfeedback/src/commonMain/kotlin/io/openfeedback/OpenFeedbackRepository.kt deleted file mode 100644 index 0f3251b..0000000 --- a/openfeedback/src/commonMain/kotlin/io/openfeedback/OpenFeedbackRepository.kt +++ /dev/null @@ -1,68 +0,0 @@ -package io.openfeedback - -import io.openfeedback.caches.OptimisticVoteCaching -import io.openfeedback.model.Project -import io.openfeedback.model.SessionVotes -import io.openfeedback.model.UserVote -import io.openfeedback.model.VoteStatus -import io.openfeedback.sources.OpenFeedbackAuth -import io.openfeedback.sources.OpenFeedbackFirestore -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapConcat -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach - -class OpenFeedbackRepository( - private val auth: OpenFeedbackAuth, - private val firestore: OpenFeedbackFirestore, - private val optimisticVoteCaching: OptimisticVoteCaching -) { - fun project(projectId: String): Flow = firestore.project(projectId) - - @OptIn(ExperimentalCoroutinesApi::class) - fun userVotes(projectId: String, sessionId: String): Flow> = - flow { emit(auth.firebaseUser()) } - .flatMapConcat { - if (it != null) { - firestore.userVotes(projectId, it.uid, sessionId) - } else { - emptyFlow() - } - } - - fun totalVotes(projectId: String, sessionId: String): Flow = - merge( - firestore.sessionVotes(projectId, sessionId) - .onEach { optimisticVoteCaching.setSessionVotes(it) }, - optimisticVoteCaching.votes - ) - - suspend fun newComment(projectId: String, talkId: String, voteItemId: String, status: VoteStatus, text: String) { - auth.withFirebaseUser { - firestore.newComment(projectId, it.uid, talkId, voteItemId, status, text) - } - } - - suspend fun setVote(projectId: String, talkId: String, voteItemId: String, status: VoteStatus) { - auth.withFirebaseUser { - optimisticVoteCaching.updateVotes(voteItemId, status) - firestore.setVote(projectId, it.uid, talkId, voteItemId, status) - } - } - - suspend fun upVote( - projectId: String, - talkId: String, - voteItemId: String, - voteId: String, - status: VoteStatus - ) { - auth.withFirebaseUser { - optimisticVoteCaching.updateCommentVote(voteId, status) - firestore.upVote(projectId, it.uid, talkId, voteItemId, voteId, status) - } - } -} diff --git a/openfeedback/src/commonMain/kotlin/io/openfeedback/caches/OptimisticVoteCaching.kt b/openfeedback/src/commonMain/kotlin/io/openfeedback/caches/OptimisticVoteCaching.kt deleted file mode 100644 index 03953df..0000000 --- a/openfeedback/src/commonMain/kotlin/io/openfeedback/caches/OptimisticVoteCaching.kt +++ /dev/null @@ -1,45 +0,0 @@ -package io.openfeedback.caches - -import io.openfeedback.model.SessionVotes -import io.openfeedback.model.VoteStatus -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update - -class OptimisticVoteCaching { - private val _votes = MutableStateFlow( - SessionVotes(votes = mutableMapOf(), comments = mutableMapOf()) - ) - val votes: StateFlow = _votes - - fun setSessionVotes(votes: SessionVotes) { - _votes.update { votes } - } - - fun updateVotes(voteItemId: String, status: VoteStatus) { - _votes.update { - val map = it.votes.toMutableMap() - var count = it.votes.getOrElse(voteItemId) { 0L } - count += if (status == VoteStatus.Deleted) -1 else 1 - if (count < 0) { - count = 0L - } - map[voteItemId] = count - it.copy(votes = map) - } - } - - fun updateCommentVote(commentItemId: String, status: VoteStatus) { - _votes.update { - val map = it.comments.toMutableMap() - val comment = it.comments[commentItemId] ?: return - var count = comment.plus - count += if (status == VoteStatus.Deleted) -1 else 1 - if (count < 0) { - count = 0L - } - map[commentItemId] = comment.copy(plus = count) - it.copy(comments = map) - } - } -} diff --git a/openfeedback/src/commonMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.kt b/openfeedback/src/commonMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.kt index 327157c..2b59cd2 100644 --- a/openfeedback/src/commonMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.kt +++ b/openfeedback/src/commonMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.kt @@ -1,5 +1,5 @@ package io.openfeedback.mappers -import io.openfeedback.model.Comment +import kotlinx.datetime.Instant -expect fun Map.convertToModel(id: String, voteItemId: String): Comment +expect fun timestampToInstant(nativeTimestamp: Any): Instant diff --git a/openfeedback/src/commonMain/kotlin/io/openfeedback/model/Model.kt b/openfeedback/src/commonMain/kotlin/io/openfeedback/model/Model.kt index c502c6c..c00b6e8 100644 --- a/openfeedback/src/commonMain/kotlin/io/openfeedback/model/Model.kt +++ b/openfeedback/src/commonMain/kotlin/io/openfeedback/model/Model.kt @@ -1,5 +1,6 @@ package io.openfeedback.model +import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @@ -28,24 +29,54 @@ enum class VoteStatus(val value: String) { Deleted("deleted") } +/** + * An user vote. This is a document in firebase. + * [UserVote] may represent: + * - a vote on a voteItem + * - a plus on a comment + * - a comment + * + * Note that this can not represent the absence of a vote. + * + * @param voteItemId the voteItemId + * @param id only if this is an upvote for a comment + * @param text only if this is a comment + */ @Serializable data class UserVote( + val projectId: String, + val talkId: String, + val id: String?, val voteItemId: String, - val voteId: String? + val text: String?, + val userId: String?, + val status: String ) -@Serializable -data class SessionVotes( - val votes: Map, - val comments: Map -) +/** + * Not serializable using kotlinx-serialization because there is no type discriminator + * See https://github.com/Kotlin/kotlinx.serialization/issues/2223 + */ +//@Serializable +sealed interface SessionThing + +class VoteItemCount(val count: Long): SessionThing + +/** + * A SessionThing representing all the comments for that session + */ +class CommentsMap( + /** + * The key is the comment.id + */ + val all: Map +): SessionThing -@Serializable data class Comment( - val id: String = "", - val voteItemId: String = "", - val text: String = "", + val id: String, + val text: String, val plus: Long = 0L, val createdAt: Instant, - val updatedAt: Instant -) + val updatedAt: Instant, + val userId: String?, +): SessionThing diff --git a/openfeedback/src/commonMain/kotlin/io/openfeedback/sources/OpenFeedbackAuth.kt b/openfeedback/src/commonMain/kotlin/io/openfeedback/sources/OpenFeedbackAuth.kt index a1a8347..78d72d5 100644 --- a/openfeedback/src/commonMain/kotlin/io/openfeedback/sources/OpenFeedbackAuth.kt +++ b/openfeedback/src/commonMain/kotlin/io/openfeedback/sources/OpenFeedbackAuth.kt @@ -3,30 +3,25 @@ package io.openfeedback.sources import co.touchlab.kermit.Logger import dev.gitlive.firebase.Firebase import dev.gitlive.firebase.FirebaseApp -import dev.gitlive.firebase.auth.FirebaseAuth -import dev.gitlive.firebase.auth.FirebaseUser import dev.gitlive.firebase.auth.auth import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -class OpenFeedbackAuth(private val auth: FirebaseAuth) { - suspend fun firebaseUser(): FirebaseUser? = Mutex().withLock { - if (auth.currentUser == null) { - auth.signInAnonymously() - val result = auth.signInAnonymously() - if (result.user == null) { - Logger.e("OpenFeedbackAuth") { "Cannot signInAnonymously" } +class OpenFeedbackAuth(app: FirebaseApp) { + private val auth = Firebase.auth(app) + private val mutex = Mutex() + + suspend fun userId(): String { + mutex.withLock { + // TODO: move this to a one-time initialization at startup + if (auth.currentUser == null) { + auth.signInAnonymously() + val result = auth.signInAnonymously() + if (result.user == null) { + Logger.e("OpenFeedbackAuth") { "Cannot signInAnonymously" } + } } } - auth.currentUser - } - - suspend fun withFirebaseUser(block: suspend (FirebaseUser) -> R?): R? { - return firebaseUser()?.let { block.invoke(it) } - } - - companion object Factory { - fun create(app: FirebaseApp): OpenFeedbackAuth = - OpenFeedbackAuth(Firebase.auth(app)) + return auth.currentUser?.uid ?: "woopsie" } } diff --git a/openfeedback/src/commonMain/kotlin/io/openfeedback/sources/OpenFeedbackFirestore.kt b/openfeedback/src/commonMain/kotlin/io/openfeedback/sources/OpenFeedbackFirestore.kt index c0c76f7..807b3f7 100644 --- a/openfeedback/src/commonMain/kotlin/io/openfeedback/sources/OpenFeedbackFirestore.kt +++ b/openfeedback/src/commonMain/kotlin/io/openfeedback/sources/OpenFeedbackFirestore.kt @@ -7,15 +7,21 @@ import dev.gitlive.firebase.firestore.FieldValue import dev.gitlive.firebase.firestore.FirebaseFirestore import dev.gitlive.firebase.firestore.firestore import dev.gitlive.firebase.firestore.where -import io.openfeedback.mappers.convertToModel +import io.openfeedback.mappers.timestampToInstant +import io.openfeedback.model.Comment +import io.openfeedback.model.CommentsMap import io.openfeedback.model.Project -import io.openfeedback.model.SessionVotes +import io.openfeedback.model.SessionThing import io.openfeedback.model.UserVote +import io.openfeedback.model.VoteItemCount import io.openfeedback.model.VoteStatus import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +class UserVotesResult(val data: List, val isFromCache: Boolean) +class SessionThingsResult(val data: Map, val isFromCache: Boolean) + @Suppress("UNCHECKED_CAST") class OpenFeedbackFirestore(private val firestore: FirebaseFirestore) { fun project(projectId: String): Flow = @@ -24,19 +30,53 @@ class OpenFeedbackFirestore(private val firestore: FirebaseFirestore) { .snapshots .map { querySnapshot -> querySnapshot.data() } - fun userVotes(projectId: String, userId: String, sessionId: String): Flow> = + fun userVotes(projectId: String, userId: String, sessionId: String): Flow = firestore.collection("projects/$projectId/userVotes") .where { "userId" equalTo userId } .where { "status" equalTo VoteStatus.Active.value } .where { "talkId" equalTo sessionId } .snapshots .map { querySnapshot -> - querySnapshot.documents.map { + var isFromCache = true + val userVotes = querySnapshot.documents.map { + if (!it.metadata.isFromCache) { + isFromCache = false + } it.data() } + UserVotesResult( + userVotes, + isFromCache + ) } - fun sessionVotes(projectId: String, sessionId: String): Flow = + + private fun Any?.toComment(id: String): Comment { + check(this is Map<*, *>) { + error("expected a map representing a comment, got '$this'") + } + return Comment( + id = id, + text = this["text"] as String, + plus = (this["plus"] as Long).coerceAtLeast(0), + createdAt = timestampToInstant(this["createdAt"]!!), + updatedAt = timestampToInstant(this["updatedAt"]!!), + userId = this["userId"] as String + ) + } + + private fun Any?.toCommentsMap(): CommentsMap { + check(this is Map<*, *>) { + error("expected a map of comments, got '$this'") + } + return CommentsMap((this as Map).mapValues { + it.value.toComment(it.key) + } ) + } + /** + * Return all things related to this session, vote counts and comments + */ + fun sessionThings(projectId: String, sessionId: String): Flow = firestore.collection("projects/$projectId/sessionVotes") .document(sessionId) .snapshots @@ -44,35 +84,24 @@ class OpenFeedbackFirestore(private val firestore: FirebaseFirestore) { if (documentSnapshot.exists.not()) { return@mapNotNull null } - documentSnapshot.data(strategy = SpecialValueSerializer( + val sessionThings = documentSnapshot.data(strategy = SpecialValueSerializer( serialName = "SessionVotes", toNativeValue = {}, fromNativeValue = { val data = it as HashMap - SessionVotes( - votes = data.filter { it.value is Long } as Map, - comments = data - .filter { it.value is HashMap<*, *> } - .map { - val voteItemId = it.key - (it.value as HashMap<*, *>).entries - .filter { (it.value as Map).isNotEmpty() } - .map { entry -> - entry.key as String to (entry.value as Map) - .convertToModel( - id = entry.key as String, - voteItemId = voteItemId - ) - } - } - .flatten() - .associate { it.first to it.second } - ) + data.mapValues { + if (it.value is Long) { + VoteItemCount(it.value as Long) + } else { + it.value.toCommentsMap() + } + } } )) + SessionThingsResult(sessionThings, documentSnapshot.metadata.isFromCache) } - suspend fun newComment( + suspend fun setComment( projectId: String, userId: String, talkId: String, @@ -87,6 +116,9 @@ class OpenFeedbackFirestore(private val firestore: FirebaseFirestore) { .where { "talkId" equalTo talkId } .where { "voteItemId" equalTo voteItemId } .get() + /** + * XXX: There may be a race here where we create 2 documents, not really sure under which circumstances + */ if (querySnapshot.documents.isEmpty()) { val documentReference = collectionReference.document documentReference.set( @@ -99,10 +131,11 @@ class OpenFeedbackFirestore(private val firestore: FirebaseFirestore) { "updatedAt" to FieldValue.serverTimestamp, "userId" to userId, "voteItemId" to voteItemId, - "text" to text.trim() + "text" to text.trim(), ) ) } else { + querySnapshot.documents[0] collectionReference .document(querySnapshot.documents[0].id) .update( diff --git a/openfeedback/src/commonMain/moko-resources/base/strings.xml b/openfeedback/src/commonMain/moko-resources/base/strings.xml index ace1841..0d8baea 100644 --- a/openfeedback/src/commonMain/moko-resources/base/strings.xml +++ b/openfeedback/src/commonMain/moko-resources/base/strings.xml @@ -5,4 +5,5 @@ Submit comment %d votes Powered by + , from you \ No newline at end of file diff --git a/openfeedback/src/iosMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.ios.kt b/openfeedback/src/iosMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.ios.kt index fa1d34a..a771c6e 100644 --- a/openfeedback/src/iosMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.ios.kt +++ b/openfeedback/src/iosMain/kotlin/io/openfeedback/mappers/FirestoreToModelMappers.ios.kt @@ -4,18 +4,7 @@ import dev.gitlive.firebase.firestore.Timestamp import io.openfeedback.model.Comment import kotlinx.datetime.Instant -actual fun Map.convertToModel( - id: String, - voteItemId: String -): Comment { - val createdAt = this["createdAt"] as Timestamp - val updatedAt = this["updatedAt"] as Timestamp - return Comment( - id = id, - voteItemId = voteItemId, - text = this["text"] as String, - plus = this["plus"] as Long, - createdAt = Instant.fromEpochSeconds(createdAt.seconds, createdAt.nanoseconds), - updatedAt = Instant.fromEpochSeconds(updatedAt.seconds, updatedAt.nanoseconds) - ) +actual fun timestampToInstant(nativeTimestamp: Any): Instant { + (nativeTimestamp as Timestamp) + return Instant.fromEpochSeconds(nativeTimestamp.seconds, nativeTimestamp.nanoseconds) } diff --git a/sample-app-shared/src/commonMain/kotlin/io/openfeedback/shared/main.kt b/sample-app-shared/src/commonMain/kotlin/io/openfeedback/shared/main.kt index a65a0d3..e68e3fc 100644 --- a/sample-app-shared/src/commonMain/kotlin/io/openfeedback/shared/main.kt +++ b/sample-app-shared/src/commonMain/kotlin/io/openfeedback/shared/main.kt @@ -36,24 +36,12 @@ fun SampleApp( ThemeSwitcher(isLight = isLight) { isLight = it } } item { - /** - * The firebase parameters are from the openfeedback.io project so we can - * access firestore directly - */ - val openFeedbackFirebaseConfig = OpenFeedbackFirebaseConfig( - context = context, - projectId = "open-feedback-42", - // Hack: I replaced :web: by :ios: for the iOS SDK to behave - applicationId = "1:635903227116:ios:31de912f8bf29befb1e1c9", - apiKey = "AIzaSyB3ELJsaiItrln0uDGSuuHE1CfOJO67Hb4", - databaseUrl = "https://open-feedback-42.firebaseio.com/" - ) /** * The project and session Ids are taken from openfeedback.io demo conference: * https://openfeedback.io/eaJnyMXD3oNfhrrnBYDT/ */ OpenFeedback( - config = openFeedbackFirebaseConfig, + config = OpenFeedbackFirebaseConfig.default(context), projectId = "eaJnyMXD3oNfhrrnBYDT", sessionId = "100", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)