Skip to content

Commit

Permalink
Implement highlight activity bottom sheet (#254)
Browse files Browse the repository at this point in the history
* Fix referencedEventAuthorId is always NULL
* Cache all events from get_highlights endpoint and save highlight comments
* Connect comments with highlights
* Implement highlight joins on same content
* Implement Highlight Activity bottom sheet
* Render comments in the highlight activity bottom sheet
* Tweak comment section paddings to align with action buttons
* Skip partially expanded state in bottom sheet
  • Loading branch information
markocic authored Dec 16, 2024
1 parent 5896467 commit f549dbe
Show file tree
Hide file tree
Showing 21 changed files with 599 additions and 44 deletions.
4 changes: 2 additions & 2 deletions app/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<ManuallySuppressedIssues></ManuallySuppressedIssues>
<CurrentIssues>
<ID>ComplexCondition:NostrResources.kt$isNote() || isNoteUri() || isNEventUri() || isNEvent()</ID>
<ID>CyclomaticComplexMethod:ArticleDetailsScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun ArticleContentWithComments( state: ArticleDetailsContract.UiState, articleParts: List&lt;ArticlePartRender&gt;, listState: LazyListState = rememberLazyListState(), showHighlights: Boolean, paddingValues: PaddingValues, onArticleCommentClick: (naddr: String) -&gt; Unit, onArticleHashtagClick: (hashtag: String) -&gt; Unit, onZapOptionsClick: () -&gt; Unit, noteCallbacks: NoteCallbacks, onGoToWallet: () -&gt; Unit, onPostAction: ((FeedPostAction) -&gt; Unit)? = null, onPostLongPressAction: ((FeedPostAction) -&gt; Unit)? = null, onFollowUnfollowClick: (() -&gt; Unit)? = null, onUiError: ((UiError) -&gt; Unit)? = null, )</ID>
<ID>CyclomaticComplexMethod:ArticleDetailsScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun ArticleContentWithComments( state: ArticleDetailsContract.UiState, articleParts: List&lt;ArticlePartRender&gt;, listState: LazyListState = rememberLazyListState(), showHighlights: Boolean, paddingValues: PaddingValues, onArticleCommentClick: (naddr: String) -&gt; Unit, onArticleHashtagClick: (hashtag: String) -&gt; Unit, onZapOptionsClick: () -&gt; Unit, onHighlightClick: (String) -&gt; Unit, noteCallbacks: NoteCallbacks, onGoToWallet: () -&gt; Unit, onPostAction: ((FeedPostAction) -&gt; Unit)? = null, onPostLongPressAction: ((FeedPostAction) -&gt; Unit)? = null, onFollowUnfollowClick: (() -&gt; Unit)? = null, onUiError: ((UiError) -&gt; Unit)? = null, )</ID>
<ID>CyclomaticComplexMethod:ArticleDetailsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ArticleDetailsScreen( detailsState: ArticleDetailsContract.UiState, articleState: ArticleContract.UiState, detailsEventPublisher: (UiEvent) -&gt; Unit, articleEventPublisher: (ArticleContract.UiEvent) -&gt; Unit, onArticleHashtagClick: (hashtag: String) -&gt; Unit, noteCallbacks: NoteCallbacks, onGoToWallet: () -&gt; Unit, onClose: () -&gt; Unit, )</ID>
<ID>CyclomaticComplexMethod:ChatScreen.kt$@Composable private fun ChatMessageListItem( chatMessage: ChatMessageUi, previousMessage: ChatMessageUi? = null, nextMessage: ChatMessageUi? = null, onUrlClick: (String) -&gt; Unit, noteCallbacks: NoteCallbacks, )</ID>
<ID>CyclomaticComplexMethod:CreateTransactionScreen.kt$@ExperimentalComposeUiApi @ExperimentalMaterial3Api @Composable fun CreateTransactionScreen( state: CreateTransactionContract.UiState, eventPublisher: (CreateTransactionContract.UiEvent) -&gt; Unit, onClose: () -&gt; Unit, )</ID>
Expand Down Expand Up @@ -33,7 +33,7 @@
<ID>CyclomaticComplexMethod:WalletDashboardScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun WalletDashboardScreen( state: WalletDashboardContract.UiState, onPrimaryDestinationChanged: (PrimalTopLevelDestination) -&gt; Unit, onDrawerDestinationClick: (DrawerScreenDestination) -&gt; Unit, onDrawerQrCodeClick: () -&gt; Unit, onWalletActivateClick: () -&gt; Unit, onProfileClick: (String) -&gt; Unit, onTransactionClick: (String) -&gt; Unit, onSendClick: () -&gt; Unit, onScanClick: () -&gt; Unit, onReceiveClick: () -&gt; Unit, eventPublisher: (UiEvent) -&gt; Unit, )</ID>
<ID>CyclomaticComplexMethod:WalletTransactionsMediator.kt$WalletTransactionsMediator$override suspend fun load(loadType: LoadType, state: PagingState&lt;Int, WalletTransaction&gt;): MediatorResult</ID>
<ID>DestructuringDeclarationWithTooManyEntries:PrimalDrawer.kt$val (avatarRef, usernameRef, iconRef, identifierRef, statsRef) = createRefs()</ID>
<ID>LongMethod:ArticleDetailsScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun ArticleContentWithComments( state: ArticleDetailsContract.UiState, articleParts: List&lt;ArticlePartRender&gt;, listState: LazyListState = rememberLazyListState(), showHighlights: Boolean, paddingValues: PaddingValues, onArticleCommentClick: (naddr: String) -&gt; Unit, onArticleHashtagClick: (hashtag: String) -&gt; Unit, onZapOptionsClick: () -&gt; Unit, noteCallbacks: NoteCallbacks, onGoToWallet: () -&gt; Unit, onPostAction: ((FeedPostAction) -&gt; Unit)? = null, onPostLongPressAction: ((FeedPostAction) -&gt; Unit)? = null, onFollowUnfollowClick: (() -&gt; Unit)? = null, onUiError: ((UiError) -&gt; Unit)? = null, )</ID>
<ID>LongMethod:ArticleDetailsScreen.kt$@OptIn(ExperimentalLayoutApi::class) @Composable private fun ArticleContentWithComments( state: ArticleDetailsContract.UiState, articleParts: List&lt;ArticlePartRender&gt;, listState: LazyListState = rememberLazyListState(), showHighlights: Boolean, paddingValues: PaddingValues, onArticleCommentClick: (naddr: String) -&gt; Unit, onArticleHashtagClick: (hashtag: String) -&gt; Unit, onZapOptionsClick: () -&gt; Unit, onHighlightClick: (String) -&gt; Unit, noteCallbacks: NoteCallbacks, onGoToWallet: () -&gt; Unit, onPostAction: ((FeedPostAction) -&gt; Unit)? = null, onPostLongPressAction: ((FeedPostAction) -&gt; Unit)? = null, onFollowUnfollowClick: (() -&gt; Unit)? = null, onUiError: ((UiError) -&gt; Unit)? = null, )</ID>
<ID>LongMethod:ArticleDetailsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ArticleDetailsScreen( detailsState: ArticleDetailsContract.UiState, articleState: ArticleContract.UiState, detailsEventPublisher: (UiEvent) -&gt; Unit, articleEventPublisher: (ArticleContract.UiEvent) -&gt; Unit, onArticleHashtagClick: (hashtag: String) -&gt; Unit, noteCallbacks: NoteCallbacks, onGoToWallet: () -&gt; Unit, onClose: () -&gt; Unit, )</ID>
<ID>LongMethod:ArticleDropdownMenu.kt$@ExperimentalMaterial3Api @Composable fun ArticleDropdownMenuIcon( modifier: Modifier, articleId: String, articleContent: String?, articleRawData: String?, authorId: String, isBookmarked: Boolean, enabled: Boolean = true, showHighlights: Boolean? = null, onToggleHighlightsClick: (() -&gt; Unit)? = null, onBookmarkClick: (() -&gt; Unit)? = null, onMuteUserClick: (() -&gt; Unit)? = null, onReportContentClick: ((reportType: ReportType) -&gt; Unit)? = null, icon: @Composable () -&gt; Unit, )</ID>
<ID>LongMethod:ArticleFeedList.kt$@ExperimentalMaterial3Api @ExperimentalFoundationApi @Composable private fun ArticleFeedLazyColumn( articleState: ArticleContract.UiState, pagingItems: LazyPagingItems&lt;FeedArticleUi&gt;, listState: LazyListState, showPaywall: Boolean, onArticleClick: (naddr: String) -&gt; Unit, onGetPremiumClick: () -&gt; Unit, articleEventPublisher: (ArticleContract.UiEvent) -&gt; Unit, modifier: Modifier = Modifier, noContentText: String = stringResource(id = R.string.article_feed_no_content), noContentVerticalArrangement: Arrangement.Vertical = Arrangement.Center, noContentPaddingValues: PaddingValues = PaddingValues(all = 0.dp), contentPadding: PaddingValues = PaddingValues(all = 0.dp), header: @Composable (LazyItemScope.() -&gt; Unit)? = null, stickyHeader: @Composable (LazyItemScope.() -&gt; Unit)? = null, )</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import net.primal.android.articles.api.model.ArticleHighlightsRequestBody
import net.primal.android.articles.db.Article
import net.primal.android.core.coroutines.CoroutineDispatcherProvider
import net.primal.android.db.PrimalDatabase
import net.primal.android.nostr.ext.asHighlightData
import net.primal.android.nostr.model.NostrEventKind
import net.primal.android.user.accounts.active.ActiveAccountStore

Expand Down Expand Up @@ -95,10 +94,7 @@ class ArticleRepository @Inject constructor(
),
)

val highlights = highlightsResponse.highlights.map { it.asHighlightData() }
if (highlights.isNotEmpty()) {
database.highlights().upsertAll(data = highlights)
}
highlightsResponse.persistToDatabaseAsTransaction(database = database)
}

suspend fun observeArticle(articleId: String, articleAuthorId: String) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ class ArticlesApiImpl @Inject constructor(

return ArticleHighlightsResponse(
highlights = queryResult.filterNostrEvents(kind = NostrEventKind.Highlight),
legendProfiles = queryResult.findPrimalEvent(kind = NostrEventKind.PrimalLegendProfiles),
primalPremiumInfo = queryResult.findPrimalEvent(kind = NostrEventKind.PrimalPremiumInfo),
primalUserNames = queryResult.findPrimalEvent(kind = NostrEventKind.PrimalUserNames),
primalUserScores = queryResult.findPrimalEvent(kind = NostrEventKind.PrimalUserScores),
cdnResources = queryResult.filterPrimalEvents(kind = NostrEventKind.PrimalCdnResource),
profileMetadatas = queryResult.filterNostrEvents(kind = NostrEventKind.Metadata),
eventStats = queryResult.filterPrimalEvents(kind = NostrEventKind.PrimalEventStats),
relayHints = queryResult.filterPrimalEvents(kind = NostrEventKind.PrimalRelayHint),
zaps = queryResult.filterNostrEvents(kind = NostrEventKind.Zap),
primalLongFormContentWordsCount = queryResult.filterPrimalEvents(
kind = NostrEventKind.PrimalLongFormWordsCount,
),
referencedEvents = queryResult.filterPrimalEvents(kind = NostrEventKind.PrimalReferencedEvent),
highlightComments = queryResult.filterNostrEvents(kind = NostrEventKind.ShortTextNote),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package net.primal.android.articles.api.mediator

import androidx.room.withTransaction
import net.primal.android.articles.api.model.ArticleHighlightsResponse
import net.primal.android.core.ext.asMapByKey
import net.primal.android.db.PrimalDatabase
import net.primal.android.highlights.utils.mapNotNullAsHighlightComments
import net.primal.android.nostr.db.eventRelayHintsUpserter
import net.primal.android.nostr.ext.asHighlightData
import net.primal.android.nostr.ext.flatMapAsEventHintsPO
import net.primal.android.nostr.ext.flatMapNotNullAsCdnResource
import net.primal.android.nostr.ext.mapAsEventZapDO
import net.primal.android.nostr.ext.mapAsProfileDataPO
import net.primal.android.nostr.ext.mapNotNullAsEventStatsPO
import net.primal.android.nostr.ext.parseAndMapPrimalLegendProfiles
import net.primal.android.nostr.ext.parseAndMapPrimalPremiumInfo
import net.primal.android.nostr.ext.parseAndMapPrimalUserNames

suspend fun ArticleHighlightsResponse.persistToDatabaseAsTransaction(database: PrimalDatabase) {
val cdnResources = this.cdnResources.flatMapNotNullAsCdnResource().asMapByKey { it.url }
val eventHints = this.relayHints.flatMapAsEventHintsPO()

val primalUserNames = this.primalUserNames.parseAndMapPrimalUserNames()
val primalPremiumInfo = this.primalPremiumInfo.parseAndMapPrimalPremiumInfo()
val primalLegendProfiles = this.legendProfiles.parseAndMapPrimalLegendProfiles()

val profiles = this.profileMetadatas.mapAsProfileDataPO(
cdnResources = cdnResources,
primalUserNames = primalUserNames,
primalPremiumInfo = primalPremiumInfo,
primalLegendProfiles = primalLegendProfiles,
)

val highlights = this.highlights.map { it.asHighlightData() }
val highlightComments = this.highlightComments.mapNotNullAsHighlightComments(highlights = highlights)

val eventZaps = this.zaps.mapAsEventZapDO(profilesMap = profiles.associateBy { it.ownerId })
val eventStats = this.eventStats.mapNotNullAsEventStatsPO()

database.withTransaction {
database.profiles().insertOrUpdateAll(data = profiles)
database.posts().upsertAll(data = highlightComments)
database.eventStats().upsertAll(data = eventStats)
database.eventZaps().upsertAll(data = eventZaps)
database.highlights().upsertAll(data = highlights)

val eventHintsDao = database.eventHints()
val hintsMap = eventHints.associateBy { it.eventId }
eventRelayHintsUpserter(dao = eventHintsDao, eventIds = eventHints.map { it.eventId }) {
copy(relays = hintsMap[this.eventId]?.relays ?: emptyList())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,21 @@ package net.primal.android.articles.api.model

import kotlinx.serialization.Serializable
import net.primal.android.nostr.model.NostrEvent
import net.primal.android.nostr.model.primal.PrimalEvent

@Serializable
data class ArticleHighlightsResponse(
val highlights: List<NostrEvent>,
val legendProfiles: PrimalEvent?,
val primalPremiumInfo: PrimalEvent?,
val primalUserNames: PrimalEvent?,
val primalUserScores: PrimalEvent?,
val cdnResources: List<PrimalEvent>,
val profileMetadatas: List<NostrEvent>,
val eventStats: List<PrimalEvent>,
val relayHints: List<PrimalEvent>,
val highlightComments: List<NostrEvent>,
val zaps: List<NostrEvent>,
val primalLongFormContentWordsCount: List<PrimalEvent>,
val referencedEvents: List<PrimalEvent>,
)
5 changes: 3 additions & 2 deletions app/src/main/kotlin/net/primal/android/articles/db/Article.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package net.primal.android.articles.db
import androidx.room.Embedded
import androidx.room.Relation
import net.primal.android.bookmarks.db.PublicBookmark
import net.primal.android.highlights.db.Highlight
import net.primal.android.highlights.db.HighlightData
import net.primal.android.profile.db.ProfileData
import net.primal.android.stats.db.EventStats
Expand All @@ -28,6 +29,6 @@ data class Article(
@Relation(entityColumn = "tagValue", parentColumn = "aTag")
val bookmark: PublicBookmark? = null,

@Relation(entityColumn = "referencedEventATag", parentColumn = "aTag")
val highlights: List<HighlightData> = emptyList(),
@Relation(entity = HighlightData::class, entityColumn = "referencedEventATag", parentColumn = "aTag")
val highlights: List<Highlight> = emptyList(),
)
18 changes: 18 additions & 0 deletions app/src/main/kotlin/net/primal/android/highlights/db/Highlight.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package net.primal.android.highlights.db

import androidx.room.Embedded
import androidx.room.Relation
import net.primal.android.notes.db.PostData
import net.primal.android.notes.db.PostWithAuthorData
import net.primal.android.profile.db.ProfileData

data class Highlight(
@Embedded
val data: HighlightData,

@Relation(entityColumn = "ownerId", parentColumn = "authorId")
val author: ProfileData? = null,

@Relation(entity = PostData::class, entityColumn = "replyToPostId", parentColumn = "highlightId")
val comments: List<PostWithAuthorData> = emptyList(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package net.primal.android.highlights.model

import java.time.Instant
import net.primal.android.attachments.domain.CdnImage
import net.primal.android.notes.db.PostWithAuthorData
import net.primal.android.premium.legend.LegendaryCustomization
import net.primal.android.premium.legend.asLegendaryCustomization

data class CommentUi(
val commentId: String,
val authorId: String,
val authorDisplayName: String?,
val authorInternetIdentifier: String?,
val authorLegendaryCustomization: LegendaryCustomization?,
val authorCdnImage: CdnImage?,
val content: String,
val createdAt: Instant,
)

fun PostWithAuthorData.toCommentUi() =
CommentUi(
commentId = this.post.postId,
authorId = this.author.ownerId,
authorDisplayName = this.author.displayName,
authorInternetIdentifier = this.author.internetIdentifier,
authorLegendaryCustomization = this.author.primalPremiumInfo?.legendProfile?.asLegendaryCustomization(),
authorCdnImage = this.author.avatarCdnImage,
content = this.post.content,
createdAt = Instant.ofEpochSecond(this.post.createdAt),
)
Original file line number Diff line number Diff line change
@@ -1,26 +1,58 @@
package net.primal.android.highlights.model

import net.primal.android.highlights.db.HighlightData
import net.primal.android.core.compose.profile.model.ProfileDetailsUi
import net.primal.android.core.compose.profile.model.asProfileDetailsUi
import net.primal.android.highlights.db.Highlight

data class HighlightUi(
val highlightId: String,
val authorId: String,
val author: ProfileDetailsUi?,
val content: String,
val context: String?,
val alt: String?,
val referencedEventATag: String?,
val referencedEventAuthorId: String?,
val createdAt: Long,
val comments: List<CommentUi>,
)

fun HighlightData.asHighlightUi() =
data class JoinedHighlightsUi(
val highlightId: String,
val authors: Set<ProfileDetailsUi>,
val content: String,
val comments: List<CommentUi>,
)

fun Highlight.asHighlightUi() =
HighlightUi(
highlightId = highlightId,
authorId = authorId,
content = content,
context = context,
alt = alt,
referencedEventATag = referencedEventATag,
referencedEventAuthorId = referencedEventAuthorId,
createdAt = createdAt,
highlightId = this.data.highlightId,
author = this.author?.asProfileDetailsUi(),
content = this.data.content,
context = this.data.context,
alt = this.data.alt,
referencedEventATag = this.data.referencedEventATag,
referencedEventAuthorId = this.data.referencedEventAuthorId,
createdAt = this.data.createdAt,
comments = this.comments.map { it.toCommentUi() },
)

operator fun JoinedHighlightsUi.plus(element: JoinedHighlightsUi): JoinedHighlightsUi =
JoinedHighlightsUi(
highlightId = this.highlightId,
authors = this.authors + element.authors,
content = this.content,
comments = this.comments + element.comments,
)

fun List<Highlight>.joinOnContent(): List<JoinedHighlightsUi> = this.groupBy { it.data.content }.map { it.value.sum() }

fun List<Highlight>.sum() =
this.map { it.asJoinedHighlightsUi() }.reduce { acc, joinedHighlightsUi -> acc + joinedHighlightsUi }

fun Highlight.asJoinedHighlightsUi() =
JoinedHighlightsUi(
highlightId = this.data.highlightId,
authors = setOfNotNull(this.author?.asProfileDetailsUi()),
content = this.data.content,
comments = this.comments.map { it.toCommentUi() },
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package net.primal.android.highlights.utils

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonArray
import net.primal.android.core.serialization.json.NostrJson
import net.primal.android.core.serialization.json.toJsonObject
import net.primal.android.core.utils.parseHashtags
import net.primal.android.core.utils.parseUris
import net.primal.android.highlights.db.HighlightData
import net.primal.android.nostr.ext.getTagValueOrNull
import net.primal.android.nostr.ext.hasReplyMarker
import net.primal.android.nostr.ext.hasRootMarker
import net.primal.android.nostr.ext.isEventIdTag
import net.primal.android.nostr.model.NostrEvent
import net.primal.android.notes.db.PostData

fun List<NostrEvent>.mapNotNullAsHighlightComments(highlights: List<HighlightData>): List<PostData> =
this.mapNotNull { it.asHighlightComment(highlights = highlights) }

fun NostrEvent.asHighlightComment(highlights: List<HighlightData>): PostData? {
if (!this.tags.containsRootOrReplyTag()) {
return null
}

val replyToPostId = this.tags.find { it.isEventIdTag() }?.getTagValueOrNull()

val replyToAuthorId = highlights.find { it.highlightId == replyToPostId }?.authorId

return PostData(
postId = this.id,
authorId = this.pubKey,
createdAt = this.createdAt,
tags = this.tags,
content = this.content,
uris = this.content.parseUris(),
hashtags = this.parseHashtags(),
sig = this.sig,
raw = NostrJson.encodeToString(this.toJsonObject()),
replyToPostId = replyToPostId,
replyToAuthorId = replyToAuthorId,
)
}

fun List<JsonArray>.containsRootOrReplyTag() = this.any { it.hasRootMarker() || it.hasReplyMarker() }
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ fun NostrEvent.asHighlightData() =
alt = this.tags.findFirstAltDescription(),
context = this.tags.findFirstContextTag(),
referencedEventATag = this.tags.findFirstReplaceableEventId(),
referencedEventAuthorId = this.tags.findFirstProfileId()?.extractProfileId(),
referencedEventAuthorId = this.tags.findFirstProfileId(),
createdAt = this.createdAt,
)
Loading

0 comments on commit f549dbe

Please sign in to comment.