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(