From 2cdc6aab1ef6e639eaf458966e4f73223b221f50 Mon Sep 17 00:00:00 2001 From: Rikin Marfatia Date: Wed, 24 Jul 2024 17:10:20 -0700 Subject: [PATCH] Initial Pass at Liking Comments (#80) This builds on the previously added login functionality to be able to like comments. This mostly gets the wiring in place, it is not the final state of the UI, and we still need to think about how eager state updates will work (essentially updating a tree as our backing state). --- .../hackernews/data/HackerNewsWebClient.kt | 30 +++++++++- .../features/comments/CommentsDomain.kt | 32 +++++++++-- .../features/comments/CommentsScreen.kt | 57 ++++++++++++++----- .../hackernews/features/login/LoginScreen.kt | 9 +++ 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsWebClient.kt b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsWebClient.kt index d4d627df..7f1ba8c2 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsWebClient.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsWebClient.kt @@ -1,17 +1,29 @@ package com.emergetools.hackernews.data +import android.util.Log +import androidx.compose.runtime.key import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.internal.trimSubstring import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.select.Elements const val BASE_WEB_URL = "https://news.ycombinator.com/" private const val LOGIN_URL = BASE_WEB_URL + "login" private const val ITEM_URL = BASE_WEB_URL + "item" data class ItemPage( + val id: Long, + val upvoted: Boolean, + val upvoteUrl: String, + val commentUrlMap: Map +) + +data class CommentPage( val id: Long, val upvoted: Boolean, val upvoteUrl: String @@ -66,10 +78,25 @@ class HackerNewsWebClient( val upvoteElement = document.select("#up_$itemId") val upvoteHref = upvoteElement.attr("href") + + val commentTree = document.select("table.comment-tree") + val commentUpvoteLinks = commentTree.select("a[id^=up_]") + val commentUpvoteMap = commentUpvoteLinks.groupBy( + keySelector = { it.id().substring(3).toLong() }, + valueTransform = { + CommentPage( + id = it.id().substring(3).toLong(), + upvoted = it.hasClass("nosee"), + upvoteUrl = BASE_WEB_URL + it.attr("href") + ) + } + ).mapValues { it.value[0] } + ItemPage( id = itemId, upvoted = upvoteElement.hasClass("nosee"), - upvoteUrl = BASE_WEB_URL + upvoteHref + upvoteUrl = BASE_WEB_URL + upvoteHref, + commentUrlMap = commentUpvoteMap ) } } @@ -85,4 +112,5 @@ class HackerNewsWebClient( response.isSuccessful } } + } \ No newline at end of file diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt index 5090c91c..4491b1f5 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.emergetools.hackernews.data.CommentPage import com.emergetools.hackernews.data.HackerNewsSearchClient import com.emergetools.hackernews.data.HackerNewsWebClient import com.emergetools.hackernews.data.ItemPage @@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonNull.content import java.time.OffsetDateTime sealed interface CommentsState { @@ -61,6 +63,8 @@ sealed interface CommentState { val author: String, val content: String, val timeLabel: String, + val upvoted: Boolean, + val upvoteUrl: String, override val children: List, override val level: Int = 0, ): CommentState @@ -78,7 +82,11 @@ sealed interface HeaderState { } sealed interface CommentsAction { - data object LikePostTapped: CommentsAction + data object LikePost: CommentsAction + data class LikeComment( + val id: Long, + val url: String + ): CommentsAction } class CommentsViewModel( @@ -89,6 +97,7 @@ class CommentsViewModel( private val internalState = MutableStateFlow(CommentsState.Loading) val state = internalState.asStateFlow() + init { viewModelScope.launch { withContext(Dispatchers.IO) { @@ -96,7 +105,7 @@ class CommentsViewModel( val page = webClient.getItemPage(itemId) Log.d("CommentsViewModel", "Item Page: $page") val comments = response.children.map { rootComment -> - rootComment.createCommentState(0) + rootComment.createCommentState(0, page.commentUrlMap) } internalState.update { CommentsState.Content( @@ -115,7 +124,7 @@ class CommentsViewModel( fun actions(action: CommentsAction) { when (action) { - CommentsAction.LikePostTapped -> { + CommentsAction.LikePost -> { Log.d("CommentsViewModel", "Post Liked: $itemId") val current = internalState.value if (current is CommentsState.Content && !current.page.upvoted && current.page.upvoteUrl.isNotEmpty()) { @@ -135,22 +144,33 @@ class CommentsViewModel( } } } + + is CommentsAction.LikeComment -> { + viewModelScope.launch { + val success = webClient.upvoteItem(action.url) + if (success) { + Log.d("CommentsViewModel", "Liked Comment ${action.url}") + } + } + } } } - private fun ItemResponse.createCommentState(level: Int): CommentState { + private fun ItemResponse.createCommentState(level: Int, urlMap: Map): CommentState { Log.d("Creating CommentState()", "Level: $level, Id: $id") - + val page = urlMap[id] return CommentState.Content( id = id, author = author ?: "", content = text ?: "", children = children.map { child -> - child.createCommentState(level + 1) + child.createCommentState(level + 1, urlMap) }, timeLabel = relativeTimeStamp( epochSeconds = OffsetDateTime.parse(createdAt).toInstant().epochSecond ), + upvoted = page?.upvoted ?: false, + upvoteUrl = page?.upvoteUrl.orEmpty(), level = level ) } diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt index 11028ae6..897c11b0 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt @@ -62,7 +62,7 @@ fun CommentsScreen( .fillMaxWidth() .wrapContentHeight(), onLikeTapped = { - actions(CommentsAction.LikePostTapped) + actions(CommentsAction.LikePost) } ) } @@ -89,7 +89,17 @@ fun CommentsScreen( ) } items(items = state.comments) { comment -> - CommentRow(comment) + CommentRow( + state = comment, + onLikeTapped = { + actions( + CommentsAction.LikeComment( + id = it.id, + url = it.upvoteUrl + ) + ) + } + ) } } } @@ -108,7 +118,8 @@ private fun CommentsScreenPreview() { page = ItemPage( id = 0, upvoted = false, - upvoteUrl = "upvote.com" + upvoteUrl = "upvote.com", + commentUrlMap = emptyMap() ), comments = listOf( CommentState.Content( @@ -117,6 +128,8 @@ private fun CommentsScreenPreview() { author = "rikinm", content = "Hello Child", timeLabel = "2d ago", + upvoted = false, + upvoteUrl = "", children = listOf( CommentState.Content( id = 2, @@ -124,6 +137,8 @@ private fun CommentsScreenPreview() { author = "vasantm", content = "Hello Parent", timeLabel = "1h ago", + upvoted = false, + upvoteUrl = "", children = listOf() ) ) @@ -147,7 +162,7 @@ private fun CommentsScreenLoadingPreview() { } @Composable -fun CommentRow(state: CommentState) { +fun CommentRow(state: CommentState, onLikeTapped: (CommentState.Content) -> Unit) { val startPadding = (state.level * 16).dp Column( modifier = Modifier @@ -184,12 +199,22 @@ fun CommentRow(state: CommentState) { color = Color.Gray ) Spacer(modifier = Modifier.weight(1f)) - Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.Default.ThumbUp, - tint = MaterialTheme.colorScheme.onSurface, - contentDescription = "upvote" - ) + Box( + modifier = Modifier + .wrapContentSize() + .clip(CircleShape) + .background(color = MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(vertical = 4.dp, horizontal = 8.dp) + .clickable { onLikeTapped(state) }, + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.Default.ThumbUp, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = "upvote" + ) + } } Row { Text( @@ -248,7 +273,7 @@ fun CommentRow(state: CommentState) { } } state.children.forEach { child -> - CommentRow(child) + CommentRow(state = child, onLikeTapped = onLikeTapped) } } @@ -264,6 +289,8 @@ fun CommentRowPreview() { author = "rikinm", content = "Hello Parent", timeLabel = "2d ago", + upvoted = false, + upvoteUrl = "", children = listOf( CommentState.Content( id = 2, @@ -271,10 +298,13 @@ fun CommentRowPreview() { author = "vasantm", content = "Hello Child", timeLabel = "2h ago", + upvoted = false, + upvoteUrl = "", children = listOf() ) ) - ) + ), + onLikeTapped = {} ) } } @@ -286,7 +316,8 @@ fun CommentRowLoadingPreview() { HackerNewsTheme { Column { CommentRow( - state = CommentState.Loading(level = 0) + state = CommentState.Loading(level = 0), + onLikeTapped = {} ) } } diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginScreen.kt index f2573981..d0e3bccd 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginScreen.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/login/LoginScreen.kt @@ -15,7 +15,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.emergetools.hackernews.ui.theme.HackerNewsTheme @@ -63,6 +66,12 @@ fun LoginScreen( TextField( value = state.password, placeholder = { Text("Password") }, + visualTransformation = { text -> + TransformedText( + text = AnnotatedString("*".repeat(text.text.length)), + offsetMapping = OffsetMapping.Identity + ) + }, trailingIcon = { if (state.status == LoginStatus.Failed) { Icon(