Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement quote and comment action on highlights #261

Merged
merged 14 commits into from
Dec 20, 2024
3 changes: 1 addition & 2 deletions app/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
<ID>CyclomaticComplexMethod:NoteActionsRow.kt$@Composable fun FeedNoteActionsRow( modifier: Modifier, eventStats: EventStatsUi, isBookmarked: Boolean, highlightedNote: Boolean = false, showBookmark: Boolean = false, showCounts: Boolean = true, onPostAction: ((FeedPostAction) -&gt; Unit)? = null, onPostLongPressAction: ((FeedPostAction) -&gt; Unit)? = null, )</ID>
<ID>CyclomaticComplexMethod:NoteContent.kt$@OptIn(ExperimentalFoundationApi::class) @Composable fun NoteContent( modifier: Modifier = Modifier, data: NoteContentUi, expanded: Boolean, noteCallbacks: NoteCallbacks, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, enableTweetsMode: Boolean = false, textSelectable: Boolean = false, referencedEventsHaveBorder: Boolean = false, highlightColor: Color = AppTheme.colorScheme.secondary, contentColor: Color = AppTheme.colorScheme.onSurface, referencedEventsContainerColor: Color = AppTheme.extraColorScheme.surfaceVariantAlt1, onClick: ((offset: Offset) -&gt; Unit)? = null, onUrlClick: ((url: String) -&gt; Unit)? = null, )</ID>
<ID>CyclomaticComplexMethod:NoteFeedLazyColumn.kt$@ExperimentalMaterial3Api @ExperimentalFoundationApi @Composable fun NoteFeedLazyColumn( modifier: Modifier = Modifier, pagingItems: LazyPagingItems&lt;FeedPostUi&gt;, listState: LazyListState, showPaywall: Boolean, noteCallbacks: NoteCallbacks, onGoToWallet: () -&gt; Unit, showTopZaps: Boolean = false, shouldShowLoadingState: Boolean = true, shouldShowNoContentState: Boolean = true, showReplyTo: Boolean = true, noContentVerticalArrangement: Arrangement.Vertical = Arrangement.Center, noContentPaddingValues: PaddingValues = PaddingValues(all = 0.dp), noContentText: String = stringResource(id = R.string.feed_no_content), contentPadding: PaddingValues = PaddingValues(all = 0.dp), header: @Composable (LazyItemScope.() -&gt; Unit)? = null, stickyHeader: @Composable (LazyItemScope.() -&gt; Unit)? = null, onUiError: ((UiError) -&gt; Unit)? = null, )</ID>
<ID>CyclomaticComplexMethod:NotePublishHandler.kt$NotePublishHandler$@Throws(NostrPublishException::class) suspend fun publishShortTextNote( userId: String, content: String, attachments: List&lt;NoteAttachment&gt; = emptyList(), rootArticleEventId: String? = null, rootArticleId: String? = null, rootArticleAuthorId: String? = null, rootPostId: String? = null, replyToPostId: String? = null, replyToAuthorId: String? = null, ): Boolean</ID>
<ID>CyclomaticComplexMethod:NotificationEvents.kt$private fun ContentPrimalNotification.parseActionPostId(type: NotificationType): String?</ID>
<ID>CyclomaticComplexMethod:NotificationEvents.kt$private fun ContentPrimalNotification.parseActionUserId(type: NotificationType): String?</ID>
<ID>CyclomaticComplexMethod:NotificationListItem.kt$@Composable private fun NotificationType.toSuffixText(usersZappedCount: Int = 0, totalSatsZapped: String? = null): String</ID>
Expand Down Expand Up @@ -95,7 +94,7 @@
<ID>LongParameterList:NostrResources.kt$( eventId: String, eventIdToNostrEvent: Map&lt;String, NostrEvent&gt;, postIdToPostDataMap: Map&lt;String, PostData&gt;, articleIdToArticle: Map&lt;String, ArticleData&gt;, profileIdToProfileDataMap: Map&lt;String, ProfileData&gt;, cdnResources: Map&lt;String, CdnResource&gt;, linkPreviews: Map&lt;String, LinkPreviewData&gt;, videoThumbnails: Map&lt;String, String&gt;, )</ID>
<ID>LongParameterList:NostrResources.kt$( eventIdToNostrEvent: Map&lt;String, NostrEvent&gt;, postIdToPostDataMap: Map&lt;String, PostData&gt;, articleIdToArticle: Map&lt;String, ArticleData&gt;, profileIdToProfileDataMap: Map&lt;String, ProfileData&gt;, cdnResources: Map&lt;String, CdnResource&gt;, linkPreviews: Map&lt;String, LinkPreviewData&gt;, videoThumbnails: Map&lt;String, String&gt;, )</ID>
<ID>LongParameterList:NostrResources.kt$( refNote: PostData?, refPostAuthor: ProfileData?, cdnResources: Map&lt;String, CdnResource&gt;, linkPreviews: Map&lt;String, LinkPreviewData&gt;, videoThumbnails: Map&lt;String, String&gt;, eventIdToNostrEvent: Map&lt;String, NostrEvent&gt;, postIdToPostDataMap: Map&lt;String, PostData&gt;, articleIdToArticle: Map&lt;String, ArticleData&gt;, profileIdToProfileDataMap: Map&lt;String, ProfileData&gt;, )</ID>
<ID>LongParameterList:NoteEditorViewModel.kt$NoteEditorViewModel$( @Assisted private val args: NoteEditorArgs, private val dispatcherProvider: CoroutineDispatcherProvider, private val fileAnalyser: FileAnalyser, private val activeAccountStore: ActiveAccountStore, private val feedRepository: FeedRepository, private val notePublishHandler: NotePublishHandler, private val attachmentRepository: AttachmentsRepository, private val exploreRepository: ExploreRepository, private val profileRepository: ProfileRepository, private val articleRepository: ArticleRepository, )</ID>
<ID>LongParameterList:NoteEditorViewModel.kt$NoteEditorViewModel$( @Assisted private val args: NoteEditorArgs, private val dispatcherProvider: CoroutineDispatcherProvider, private val fileAnalyser: FileAnalyser, private val activeAccountStore: ActiveAccountStore, private val feedRepository: FeedRepository, private val notePublishHandler: NotePublishHandler, private val attachmentRepository: AttachmentsRepository, private val highlightRepository: HighlightRepository, private val exploreRepository: ExploreRepository, private val profileRepository: ProfileRepository, private val articleRepository: ArticleRepository, )</ID>
<ID>LongParameterList:ProfileDetailsViewModel.kt$ProfileDetailsViewModel$( savedStateHandle: SavedStateHandle, private val dispatcherProvider: CoroutineDispatcherProvider, private val activeAccountStore: ActiveAccountStore, private val feedsRepository: FeedsRepository, private val profileRepository: ProfileRepository, private val mutedUserRepository: MutedUserRepository, private val zapHandler: ZapHandler, )</ID>
<ID>LongParameterList:SubscriptionsManager.kt$SubscriptionsManager$( dispatcherProvider: CoroutineDispatcherProvider, private val activeAccountStore: ActiveAccountStore, private val userRepository: UserRepository, private val nostrNotary: NostrNotary, private val appConfigProvider: AppConfigProvider, @PrimalCacheApiClient private val cacheApiClient: PrimalApiClient, @PrimalWalletApiClient private val walletApiClient: PrimalApiClient, )</ID>
<ID>LongParameterList:UserDataUpdater.kt$UserDataUpdater$( @Assisted val userId: String, private val settingsRepository: SettingsRepository, private val userRepository: UserRepository, private val walletRepository: WalletRepository, private val relayRepository: RelayRepository, private val bookmarksRepository: BookmarksRepository, private val premiumRepository: PremiumRepository, )</ID>
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/kotlin/net/primal/android/core/utils/NullUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@ inline fun <U, V, T> ifNotNull(
block(u, v, t)
}
}

fun assertOnlyOneNotNull(message: Any, vararg args: Any?) {
markocic marked this conversation as resolved.
Show resolved Hide resolved
args.sumOf { (it != null).toInt() }.apply {
if (this != 1) {
error(message = message)
}
}
}

private fun Boolean.toInt() = if (this) 1 else 0
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import net.primal.android.attachments.domain.CdnImage
import net.primal.android.core.compose.profile.model.UserProfileItemUi
import net.primal.android.editor.domain.NoteAttachment
import net.primal.android.editor.domain.NoteTaggedUser
import net.primal.android.highlights.model.HighlightUi
import net.primal.android.notes.feed.model.FeedPostUi
import net.primal.android.premium.legend.LegendaryCustomization

Expand All @@ -17,6 +18,7 @@ interface NoteEditorContract {
val content: TextFieldValue = TextFieldValue(),
val conversation: List<FeedPostUi> = emptyList(),
val replyToArticle: FeedArticleUi? = null,
val replyToHighlight: HighlightUi? = null,
val publishing: Boolean = false,
val error: NoteEditorError? = null,
val activeAccountAvatarCdnImage: CdnImage? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import net.primal.android.editor.domain.NoteAttachment
import net.primal.android.editor.domain.NoteEditorArgs
import net.primal.android.editor.domain.NoteTaggedUser
import net.primal.android.explore.repository.ExploreRepository
import net.primal.android.highlights.model.asHighlightUi
import net.primal.android.highlights.repository.HighlightRepository
import net.primal.android.networking.primal.upload.PrimalFileUploader
import net.primal.android.networking.primal.upload.UnsuccessfulFileUpload
import net.primal.android.networking.primal.upload.domain.UploadJob
Expand All @@ -65,13 +67,15 @@ class NoteEditorViewModel @AssistedInject constructor(
private val feedRepository: FeedRepository,
private val notePublishHandler: NotePublishHandler,
private val attachmentRepository: AttachmentsRepository,
private val highlightRepository: HighlightRepository,
private val exploreRepository: ExploreRepository,
private val profileRepository: ProfileRepository,
private val articleRepository: ArticleRepository,
) : ViewModel() {

private val replyToNoteId = args.replyToNoteId
private val replyToArticleNaddr = args.replyToArticleNaddr?.let(Nip19TLV::parseUriAsNaddrOrNull)
private val replyToHighlightId = args.replyToHighlightId

private val _state = MutableStateFlow(UiState())
val state = _state.asStateFlow()
Expand Down Expand Up @@ -108,6 +112,10 @@ class NoteEditorViewModel @AssistedInject constructor(
observeArticleByNaddr(naddr = replyToArticleNaddr)
}

if (replyToHighlightId != null) {
markocic marked this conversation as resolved.
Show resolved Hide resolved
observeHighlight(highlightId = replyToHighlightId)
}

if (args.mediaUris.isNotEmpty()) {
importPhotos(args.mediaUris.mapNotNull { Uri.parse(it) })
}
Expand Down Expand Up @@ -162,6 +170,14 @@ class NoteEditorViewModel @AssistedInject constructor(
}
}

private fun observeHighlight(highlightId: String) =
viewModelScope.launch {
highlightRepository.observeHighlightById(highlightId = highlightId)
.collect {
setState { copy(replyToHighlight = it.asHighlightUi()) }
}
}

private fun subscribeToActiveAccount() =
viewModelScope.launch {
activeAccountStore.activeAccountState
Expand Down Expand Up @@ -251,6 +267,8 @@ class NoteEditorViewModel @AssistedInject constructor(
rootArticleAuthorId = article?.authorId,
rootPostId = if (article == null) rootPost?.postId else null,
replyToPostId = replyToPost?.postId,
rootHighlightId = replyToHighlightId,
rootHighlightAuthorId = _state.value.replyToHighlight?.author?.pubkey,
replyToAuthorId = replyToPost?.authorId,
)

Expand Down
80 changes: 56 additions & 24 deletions app/src/main/kotlin/net/primal/android/editor/NotePublishHandler.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package net.primal.android.editor

import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
import net.primal.android.core.coroutines.CoroutineDispatcherProvider
import net.primal.android.core.utils.assertOnlyOneNotNull
import net.primal.android.db.PrimalDatabase
import net.primal.android.editor.domain.NoteAttachment
import net.primal.android.networking.relays.errors.NostrPublishException
Expand All @@ -20,6 +20,7 @@ import net.primal.android.nostr.ext.parseHashtagTags
import net.primal.android.nostr.ext.parsePubkeyTags
import net.primal.android.nostr.model.NostrEventKind
import net.primal.android.nostr.publish.NostrPublisher
import net.primal.android.notes.db.PostData

class NotePublishHandler @Inject constructor(
private val dispatcherProvider: CoroutineDispatcherProvider,
Expand All @@ -35,20 +36,33 @@ class NotePublishHandler @Inject constructor(
rootArticleEventId: String? = null,
rootArticleId: String? = null,
rootArticleAuthorId: String? = null,
rootHighlightId: String? = null,
rootHighlightAuthorId: String? = null,
rootPostId: String? = null,
replyToPostId: String? = null,
replyToAuthorId: String? = null,
): Boolean {
if (rootArticleId != null && rootPostId != null) {
error("You can not have both article and post as root events.")
}
assertOnlyOneNotNull(
"You can have only one root event.",
rootArticleId,
rootPostId,
rootHighlightId,
)

val replyPostData = replyToPostId?.let {
withContext(dispatcherProvider.io()) {
database.posts().findByPostId(postId = it)
}
}

val rootHighlightTags = if (rootHighlightId != null && rootHighlightAuthorId != null) {
listOf(
rootHighlightId.asEventIdTag(marker = "root"),
)
markocic marked this conversation as resolved.
Show resolved Hide resolved
} else {
null
}

/* Article tag */
val rootArticleTags = if (rootArticleId != null && rootArticleAuthorId != null && rootArticleEventId != null) {
val tagContent = "${NostrEventKind.LongFormContent.value}:$rootArticleAuthorId:$rootArticleId"
Expand All @@ -68,7 +82,7 @@ class NotePublishHandler @Inject constructor(
} else {
null
}
val rootEventTags = rootArticleTags ?: listOf(rootPostTag)
val rootEventTags = rootHighlightTags ?: rootArticleTags ?: listOf(rootPostTag)
val eventTags = setOfNotNull(*rootEventTags.toTypedArray(), replyEventTag) + mentionEventTags

val relayHintsMap = withContext(dispatcherProvider.io()) {
Expand All @@ -83,12 +97,8 @@ class NotePublishHandler @Inject constructor(
}

/* Pubkey tags */
val existingPubkeyTags = replyPostData?.tags?.filter { it.isPubKeyTag() }?.toSet() ?: setOf()
val replyAuthorPubkeyTag = replyToAuthorId?.asPubkeyTag()
val rootArticleAuthorPubkeyTag = rootArticleAuthorId?.asPubkeyTag()
val mentionPubkeyTags = content.parsePubkeyTags(marker = "mention").toSet()
val pubkeyTags = existingPubkeyTags + mentionPubkeyTags +
setOfNotNull(replyAuthorPubkeyTag, rootArticleAuthorPubkeyTag)
val pubkeyTags =
constructPubkeyTags(replyPostData, replyToAuthorId, rootHighlightAuthorId, rootArticleAuthorId, content)

/* Hashtag tags */
val hashtagTags = content.parseHashtagTags().toSet()
Expand All @@ -98,19 +108,7 @@ class NotePublishHandler @Inject constructor(
val iMetaTags = attachments.filter { it.isImageAttachment }.map { it.asIMetaTag() }

/* Content */
val refinedContent = if (attachmentUrls.isEmpty()) {
content
} else {
StringBuilder().apply {
append(content)
appendLine()
appendLine()
attachmentUrls.forEach {
append(it)
appendLine()
}
}.toString()
}
val refinedContent = buildRefinedContent(attachmentUrls, content)

val outboxRelays = replyToPostId?.let { noteId ->
relayHintsMap[noteId]?.let { relayUrl -> listOf(relayUrl) }
Expand All @@ -125,4 +123,38 @@ class NotePublishHandler @Inject constructor(
)
}
}

private fun constructPubkeyTags(
markocic marked this conversation as resolved.
Show resolved Hide resolved
replyPostData: PostData?,
replyToAuthorId: String?,
rootHighlightAuthorId: String?,
rootArticleAuthorId: String?,
content: String,
): Set<JsonArray> {
val existingPubkeyTags = replyPostData?.tags?.filter { it.isPubKeyTag() }?.toSet() ?: emptySet()
val replyAuthorPubkeyTag = replyToAuthorId?.asPubkeyTag()
val replyHighlightAuthorPubkeyTag = rootHighlightAuthorId?.asPubkeyTag()
val rootArticleAuthorPubkeyTag = rootArticleAuthorId?.asPubkeyTag()
val mentionPubkeyTags = content.parsePubkeyTags(marker = "mention").toSet()

return existingPubkeyTags + mentionPubkeyTags +
setOfNotNull(replyAuthorPubkeyTag, rootArticleAuthorPubkeyTag, replyHighlightAuthorPubkeyTag)
}

private fun buildRefinedContent(attachmentUrls: List<String>, content: String): String {
val refinedContent = if (attachmentUrls.isEmpty()) {
content
} else {
StringBuilder().apply {
append(content)
appendLine()
appendLine()
attachmentUrls.forEach {
append(it)
appendLine()
}
}.toString()
}
return refinedContent
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import net.primal.android.core.serialization.json.decodeFromStringOrNull
data class NoteEditorArgs(
val replyToNoteId: String? = null,
val replyToArticleNaddr: String? = null,
val replyToHighlightId: String? = null,
val mediaUris: List<String> = emptyList(),
val content: String = "",
val contentSelectionStart: Int = 0,
Expand All @@ -19,6 +20,12 @@ data class NoteEditorArgs(
fun toJson(): String = NostrJson.encodeToString(this)

companion object {
fun List<String>.toNostrUriInNoteEditorArgs(): NoteEditorArgs {
val preFillContent =
markocic marked this conversation as resolved.
Show resolved Hide resolved
TextFieldValue(text = this.joinToString(separator = "\n\n", prefix = "\n\n") { "nostr:$it" })
return preFillContent.asNoteEditorArgs()
}

fun String.toNostrUriInNoteEditorArgs(): NoteEditorArgs {
val preFillContent = TextFieldValue(text = "\n\nnostr:$this")
return preFillContent.asNoteEditorArgs()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import net.primal.android.core.compose.ReplyingToText
import net.primal.android.core.compose.TakePhotoIconButton
import net.primal.android.core.compose.UniversalAvatarThumbnail
import net.primal.android.core.compose.button.PrimalLoadingButton
import net.primal.android.core.compose.foundation.isAppInDarkPrimalTheme
import net.primal.android.core.compose.icons.PrimalIcons
import net.primal.android.core.compose.icons.primaliconpack.ImportPhotoFromCamera
import net.primal.android.core.compose.icons.primaliconpack.ImportPhotoFromGallery
Expand All @@ -78,12 +79,16 @@ import net.primal.android.editor.NoteEditorContract.UiEvent
import net.primal.android.editor.NoteEditorContract.UiState.NoteEditorError
import net.primal.android.editor.NoteEditorViewModel
import net.primal.android.editor.domain.NoteAttachment
import net.primal.android.notes.db.ReferencedHighlight
import net.primal.android.notes.db.toReferencedHighlight
import net.primal.android.notes.feed.model.FeedPostUi
import net.primal.android.notes.feed.model.toNoteContentUi
import net.primal.android.notes.feed.note.ui.FeedNoteHeader
import net.primal.android.notes.feed.note.ui.NoteContent
import net.primal.android.notes.feed.note.ui.ReferencedHighlight
import net.primal.android.notes.feed.note.ui.events.NoteCallbacks
import net.primal.android.theme.AppTheme
import timber.log.Timber

@Composable
fun NoteEditorScreen(viewModel: NoteEditorViewModel, onClose: () -> Unit) {
Expand Down Expand Up @@ -223,6 +228,20 @@ private fun NoteEditorBox(
.onSizeChanged { noteEditorMaxHeightPx = it.height },
state = editorListState,
) {
if (state.replyToHighlight != null) {
item(
key = state.replyToHighlight.highlightId,
contentType = "MentionedHighlight",
) {
ReferencedHighlight(
modifier = Modifier.padding(all = 16.dp),
highlight = state.replyToHighlight.toReferencedHighlight(),
isDarkTheme = isAppInDarkPrimalTheme(),
onClick = {},
)
PrimalDivider()
}
}
if (state.replyToArticle != null) {
item(
key = state.replyToArticle.eventId,
Expand Down Expand Up @@ -338,6 +357,7 @@ private fun NoteEditor(
legendaryCustomization = state.activeAccountLegendaryCustomization,
)

Timber.tag("content").i(state.content.text)
markocic marked this conversation as resolved.
Show resolved Hide resolved
NoteOutlinedTextField(
modifier = Modifier
.offset(x = (-8).dp)
Expand Down
Loading