Merge branch 'main' of https://github.com/vitorpamplona/amethyst ite resolve branch.

This commit is contained in:
KotlinGeekDev
2025-08-28 14:38:56 +01:00
1041 changed files with 8584 additions and 6677 deletions
+1
View File
@@ -15,6 +15,7 @@
/.idea/other.xml
/.idea/runConfigurations.xml
/.idea/ChatHistory_schema_v2.xml
/.idea/artifacts/*
.DS_Store
/build
/captures
+1 -1
View File
@@ -1,4 +1,4 @@
<div align="center">
<div align="center">
<a href="https://amethyst.social">
<img src="./docs/design/3rd%20Logo%20-%20Zitron/amethyst.svg" alt="Amethyst Logo" title="Amethyst logo" width="80"/>
+2 -2
View File
@@ -44,8 +44,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 424
versionName = generateVersionName("1.00.5")
versionCode = 425
versionName = generateVersionName("1.01.0")
buildConfigField "String", "RELEASE_NOTES_ID", "\"08abe267baf5d7ce14db7975866f929e2794cc23484171aef0816c60a2416597\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -21,14 +21,14 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.ots.OkHttpBitcoinExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.amethyst.service.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpCalendarBuilder
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OtsBlockHeightCache
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -52,3 +54,11 @@ fun TranslatableRichTextViewer(
accountViewModel,
nav,
)
@Composable
fun TranslatableRichTextViewer(
content: String,
id: String,
accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit,
) = displayText(content)
@@ -38,7 +38,6 @@ import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor
import com.vitorpamplona.amethyst.service.ots.OtsBlockHeightCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
@@ -51,6 +50,7 @@ import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OtsBlockHeightCache
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -89,7 +89,6 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.ots.OkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
@@ -137,6 +136,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpOtsResolverBuilder
import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
@@ -187,6 +187,8 @@ import com.vitorpamplona.quartz.nip68Picture.pictureIMeta
import com.vitorpamplona.quartz.nip71Video.VideoMeta
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
@@ -323,7 +325,9 @@ class Account(
val otsResolverBuilder: OkHttpOtsResolverBuilder =
OkHttpOtsResolverBuilder(
Amethyst.instance.okHttpClients,
{
Amethyst.instance.okHttpClients.getHttpClient(privacyState.shouldUseTorForMoneyOperations(it))
},
privacyState::shouldUseTorForMoneyOperations,
Amethyst.instance.otsBlockHeightCache,
)
@@ -879,6 +883,24 @@ class Account(
suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash))
suspend fun approveCommunityPost(
post: Note,
community: AddressableNote,
) {
val commEvent = community.event as? CommunityDefinitionEvent ?: return
val postHint = post.toEventHint<Event>() ?: return
val communityHint = community.toEventHint<CommunityDefinitionEvent>() ?: return
val template = CommunityPostApprovalEvent.build(postHint, communityHint)
val signedEvent = signer.sign(template)
val relays = outboxRelays.flow.value + commEvent.relayUrls() + community.relays + (post.author?.inboxRelays() ?: emptyList())
cache.justConsumeMyOwnEvent(signedEvent)
client.send(signedEvent, relays)
}
fun sendAutomatic(events: List<Event>) = events.forEach { sendAutomatic(it) }
fun sendAutomatic(event: Event?) {
@@ -34,7 +34,6 @@ object Constants {
val elites = RelayUrlNormalizer.normalize("wss://nostrelites.org")
val bitcoiner = RelayUrlNormalizer.normalize("wss://nostr.bitcoiner.social")
val bg = RelayUrlNormalizer.normalize("wss://relay.nostr.bg")
val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev")
val fmtwiz = RelayUrlNormalizer.normalize("wss://nostr.fmt.wiz.biz")
@@ -47,7 +46,7 @@ object Constants {
val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social")
val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es")
val eventFinderRelays = setOf(band, wine, damus, primal, mom, nos, bitcoiner, oxtr, fmtwiz, bg)
val eventFinderRelays = setOf(band, wine, damus, primal, mom, nos, bitcoiner, oxtr, fmtwiz)
val defaultSearchRelaySet = setOf(band, wine, where)
}
@@ -1453,6 +1453,8 @@ object LocalCache : ILocalCache {
// Counts the replies
repliesTo.forEach { it.addBoost(note) }
eventsApproved.forEach { it.addBoost(note) }
event.containedPost()?.let {
checkDeletionAndConsume(it, relay, false)
}
@@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -64,6 +65,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.anyAsync
import com.vitorpamplona.quartz.utils.containsAny
@@ -201,6 +203,16 @@ open class Note(
}
fun relayHintUrl(): NormalizedRelayUrl? {
val noteEvent = event
val communityPostRelays =
when (noteEvent) {
is CommunityDefinitionEvent -> noteEvent.relayUrls().ifEmpty { null }?.toSet()
is IsInPublicChatChannel -> LocalCache.getAnyChannel(this)?.relays()
else -> null
}
if (!communityPostRelays.isNullOrEmpty()) return communityPostRelays.firstOrNull()
val currentOutbox = author?.outboxRelays()?.toSet()
return if (relays.isNotEmpty()) {
@@ -36,12 +36,14 @@ class ThreadAssembler {
): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
if (note.event is RepostEvent || note.event is GenericRepostEvent) return note
val noteEvent = note.event
if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) return note
testedNotes.add(note)
val markedAsRoot =
note.event
noteEvent
?.tags
?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }
?.getOrNull(1)
@@ -52,7 +54,10 @@ class ThreadAssembler {
}
}
val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
val hasNoReplyTo =
note.replyTo?.lastOrNull {
it.replyTo?.isEmpty() == true
}
if (hasNoReplyTo != null) return hasNoReplyTo
// recursive
@@ -24,10 +24,10 @@ import android.util.Log
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ots.OkHttpOtsResolverBuilder
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpOtsResolverBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.update
import java.util.Base64
@@ -29,6 +29,11 @@ import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@@ -356,3 +361,49 @@ fun observeNoteEdits(
.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
fun observeCommunityApprovalNeedStatus(
note: Note,
community: Note,
accountViewModel: AccountViewModel,
): State<Boolean?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(note, community) {
combine(
community.flow().metadata.stateFlow,
note.flow().boosts.stateFlow,
) { communityMetadata, boosts ->
(communityMetadata.note.event as? CommunityDefinitionEvent)?.let { communityDefEvent ->
val moderators = communityDefEvent.moderatorKeys().toSet()
if (note.author?.pubkeyHex in moderators) {
false
} else {
val isModerator = accountViewModel.account.userProfile().pubkeyHex in moderators
if (isModerator) {
val wasAlreadyApproved =
note.boosts.any {
val approvalEvent = it.event
(approvalEvent is CommunityPostApprovalEvent || approvalEvent is RepostEvent || approvalEvent is GenericRepostEvent) &&
approvalEvent.pubKey in moderators &&
approvalEvent.isForCommunity(community.idHex)
}
!wasAlreadyApproved
} else {
false
}
}
}
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(false)
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -45,6 +46,7 @@ val RepliesAndReactionsToAddressesKinds1 =
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommentEvent.KIND,
)
val PostsAndChatMessagesToAddresses =
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
@@ -51,7 +52,7 @@ val RepliesAndReactionsKinds =
PollNoteEvent.KIND,
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
GitReplyEvent.KIND,
CommentEvent.KIND,
)
val RepliesAndReactionsKinds2 =
@@ -60,6 +61,7 @@ val RepliesAndReactionsKinds2 =
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND,
GitReplyEvent.KIND,
)
fun filterRepliesAndReactionsToNotes(
@@ -108,7 +110,7 @@ fun filterRepliesAndReactionsToNotes(
relay = relay,
filter =
Filter(
kinds = listOf(TextNoteEvent.KIND),
kinds = listOf(TextNoteEvent.KIND, CommentEvent.KIND),
tags = mapOf("q" to sortedList),
since = since,
limit = 1000,
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
@@ -113,7 +114,7 @@ fun SensitivityWarning(
@Composable
fun ContentWarningNote(onDismiss: () -> Unit) {
Column {
Row(modifier = Modifier.padding(horizontal = 12.dp)) {
Row(modifier = PaddingHorizontal12Modifier) {
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Box(
@@ -40,11 +40,6 @@ abstract class AdditiveFeedFilter<T> :
oldList
}
}
fun removeNotes(
oldList: List<T>,
deletedNotes: Set<T>,
): List<T> = oldList - deletedNotes
}
interface IAdditiveFeedFilter<T> : IFeedFilter<T> {
@@ -105,6 +105,8 @@ class ChannelFeedContentState(
}
}
fun deleteFromFeed(deletedNotes: Set<Note>) {}
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
if (oldNotesState is ChannelFeedState.Loaded) {
@@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -49,8 +48,6 @@ fun FeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
LazyColumn(
contentPadding = FeedPadding,
state = listState,
@@ -64,9 +64,10 @@ fun DisappearingScaffold(
bottomBar: (@Composable () -> Unit)? = null,
floatingButton: (@Composable () -> Unit)? = null,
accountViewModel: AccountViewModel,
isActive: () -> Boolean = { true },
mainContent: @Composable (padding: PaddingValues) -> Unit,
) {
val shouldShow = remember { mutableStateOf(true) }
val shouldShow = remember { mutableStateOf(isActive()) }
val modifier =
if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) {
@@ -80,6 +81,13 @@ fun DisappearingScaffold(
available: Offset,
source: NestedScrollSource,
): Offset {
if (!isActive()) {
if (!shouldShow.value) {
shouldShow.value = true
}
return Offset.Zero
}
val newOffset = bottomBarOffsetHeightPx.floatValue + available.y
if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) {
@@ -28,9 +28,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -56,6 +58,7 @@ import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
@@ -139,7 +142,9 @@ import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@@ -164,7 +169,6 @@ import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
@@ -212,7 +216,9 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
@@ -552,6 +558,8 @@ fun InnerNoteWithReactions(
accountViewModel = accountViewModel,
nav = nav,
)
RenderApprovalIfNeeded(baseNote, accountViewModel, nav)
}
}
@@ -577,6 +585,47 @@ fun InnerNoteWithReactions(
}
}
@Composable
private fun RenderApprovalIfNeeded(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (baseNote.isNewThread()) {
val communityAddress =
remember(baseNote) {
baseNote.event?.communityAddress()
}
communityAddress?.let {
LoadAddressableNote(it, accountViewModel) { community ->
if (community != null) {
val showApproveButton by observeCommunityApprovalNeedStatus(baseNote, community, accountViewModel)
if (showApproveButton == true) {
RenderApproveButton(baseNote, community, accountViewModel)
}
}
}
}
}
}
@Composable
fun RenderApproveButton(
post: Note,
community: AddressableNote,
accountViewModel: AccountViewModel,
) {
OutlinedButton(
modifier = Modifier.fillMaxWidth().defaultMinSize(minWidth = Size10dp, minHeight = Size30dp),
contentPadding = PaddingValues(0.dp),
onClick = {
accountViewModel.approveCommunityPost(post, community)
},
) {
Text("Approve")
}
}
@Composable
fun NoteBody(
baseNote: Note,
@@ -1090,14 +1139,7 @@ fun FirstUserInfoRow(
) {
Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) {
val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
val isCommunityPost by
remember(baseNote) {
derivedStateOf {
baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true
}
}
val isDraft = baseNote.isDraft()
val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified
if (showAuthorPicture) {
@@ -1108,10 +1150,28 @@ fun FirstUserInfoRow(
NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel)
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
if (isDraft) {
ObserveDraftEvent(baseNote, accountViewModel) { draftNote ->
val isCommunityPost by remember(draftNote) {
derivedStateOf { draftNote.event?.isACommunityPost() == true }
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(draftNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(draftNote, accountViewModel, nav)
}
}
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
val isCommunityPost by remember(baseNote) {
derivedStateOf { baseNote.event?.isACommunityPost() == true }
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
}
if (isRepost) {
@@ -1120,7 +1180,7 @@ fun FirstUserInfoRow(
CheckAndDisplayEditStatus(editState)
if (baseNote.isDraft()) {
if (isDraft) {
DisplayDraft()
}
@@ -1192,7 +1252,7 @@ fun observeEdits(
}
@Composable
private fun BadgeBox(
fun BadgeBox(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
@@ -1207,7 +1267,7 @@ private fun BadgeBox(
}
@Composable
private fun RenderAuthorImages(
fun RenderAuthorImages(
baseNote: Note,
nav: INav,
accountViewModel: AccountViewModel,
@@ -34,9 +34,8 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.getTagOfAddressableKind
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
@Composable
fun DisplayFollowingCommunityInPost(
@@ -55,11 +54,15 @@ private fun DisplayCommunity(
nav: INav,
) {
val communityTag =
remember(note) { note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.KIND) } ?: return
remember(note) { note.event?.communityAddress() } ?: return
val displayTag =
remember(note) {
buildLinkString(getCommunityShortName(communityTag)) { nav.nav(Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag)) }
buildLinkString(
getCommunityShortName(communityTag),
) {
nav.nav(Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag))
}
}
Text(
@@ -75,12 +78,12 @@ private fun DisplayCommunity(
)
}
private fun getCommunityShortName(communityTag: ATag): String {
fun getCommunityShortName(communityAddress: Address): String {
val name =
if (communityTag.dTag.length > 10) {
communityTag.dTag.take(10) + "..."
if (communityAddress.dTag.length > 10) {
communityAddress.dTag.take(10) + "..."
} else {
communityTag.dTag.take(10)
communityAddress.dTag.take(10)
}
return "/n/$name"
@@ -86,6 +86,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip73ExternalIds.scope
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
@@ -252,8 +253,7 @@ open class CommentPostViewModel :
}
private fun loadFromDraft(draftEvent: CommentEvent) {
val scope = draftEvent.scope() ?: return
this.externalIdentity = scope
this.externalIdentity = draftEvent.scope()
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
@@ -278,8 +278,14 @@ open class CommentPostViewModel :
zapRaiserAmount.value = zapraiser
}
draftEvent.replyingTo()?.let {
replyingTo = LocalCache.getOrCreateNote(it)
val replyAddress = draftEvent.replyAddress()
if (replyAddress.isNotEmpty()) {
replyingTo = LocalCache.getOrCreateAddressableNote(replyAddress.first())
} else {
draftEvent.replyingTo()?.let {
replyingTo = LocalCache.getOrCreateNote(it)
}
}
wantsToAddGeoHash = draftEvent.hasGeohashes()
@@ -317,7 +323,7 @@ open class CommentPostViewModel :
cancel()
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
accountViewModel.viewModelScope.launch {
accountViewModel.viewModelScope.launch(Dispatchers.Default) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
@@ -356,6 +362,7 @@ open class CommentPostViewModel :
val contentWarningReason = if (wantsToMarkAsSensitive) "" else null
val replyingTo = replyingTo
val replyingToEvent = replyingTo?.event
val template =
if (replyingTo != null) {
@@ -365,7 +372,22 @@ open class CommentPostViewModel :
msg = tagger.message,
replyingTo = eventHint,
) {
tagger.pTags?.let { pTagList -> notify(pTagList.map { it.toPTag() }) }
val notifyPTags = tagger.pTags?.let { pTagList -> pTagList.map { it.toPTag() } } ?: emptyList()
val extraNotificationAuthors =
if (replyingToEvent is CommunityDefinitionEvent) {
replyingToEvent.moderatorKeys().mapNotNull {
if (it != replyingToEvent.pubKey) {
accountViewModel.checkGetOrCreateUser(it)?.toPTag()
} else {
null
}
}
} else {
emptyList()
}
notify((notifyPTags + extraNotificationAuthors).distinctBy { it.pubKey })
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -135,6 +136,14 @@ fun GenericCommentPostScreen(
) {
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.runIOCatching {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
}
Scaffold(
topBar = {
PostingTopBar(
@@ -20,16 +20,27 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import android.R.attr.onClick
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -37,14 +48,18 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -52,8 +67,11 @@ import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
@@ -62,9 +80,10 @@ import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.stringRes
@@ -74,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
@@ -81,12 +101,12 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.util.Locale
import kotlinx.collections.immutable.toImmutableList
@Composable
fun RenderCommunity(
@@ -110,6 +130,16 @@ fun RenderCommunity(
}
}
@Composable
fun Title(title: String) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.W600,
textAlign = TextAlign.Start,
)
}
@Composable
fun LongCommunityHeader(
baseNote: AddressableNote,
@@ -118,142 +148,169 @@ fun LongCommunityHeader(
nav: INav,
) {
val noteEvent by observeNoteEvent<CommunityDefinitionEvent>(baseNote, accountViewModel)
val callbackUri = baseNote.toNostrUri()
Row(
lineModifier,
) {
val rulesLabel = stringRes(id = R.string.rules)
val summary =
remember(noteEvent) {
val subject = noteEvent?.subject()?.ifEmpty { null }
val body = noteEvent?.description()?.ifBlank { null }
val rules = noteEvent?.rules()?.ifBlank { null }
val defaultBackground = MaterialTheme.colorScheme.background
val background = remember { mutableStateOf(defaultBackground) }
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
if (rules == null) {
"### $subject\n$body"
} else {
"### $subject\n$body\n\n### $rulesLabel\n\n$rules"
}
} else {
if (rules == null) {
body
} else {
"$body\n\n$rulesLabel\n$rules"
}
val description = noteEvent?.description()?.ifBlank { null }
val guidelines = noteEvent?.rules()?.ifBlank { null }
val image = noteEvent?.image()?.imageUrl
val tagList = noteEvent?.tags?.toImmutableListOfLists() ?: EmptyTagList
Column(Modifier.verticalScroll(rememberScrollState())) {
Box(contentAlignment = Alignment.TopEnd) {
image?.let {
MyAsyncImage(
imageUrl = it,
contentDescription =
stringRes(
R.string.preview_card_image_for,
it,
),
contentScale = ContentScale.FillWidth,
mainImageModifier = Modifier,
loadedImageModifier = Modifier.fillMaxWidth(),
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) },
onError = { DefaultImageHeader(baseNote, accountViewModel) },
)
} ?: run {
DefaultImageHeader(baseNote, accountViewModel)
}
Box(
modifier = Modifier.padding(5.dp),
) {
LongCommunityActionOptions(baseNote, accountViewModel, nav)
}
}
Column(lineModifier) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = noteEvent?.name() ?: baseNote.dTag(),
fontSize = 20.sp,
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
Title(title = stringRes(R.string.about_us))
Spacer(modifier = Modifier.height(16.dp))
Column(Modifier.padding(16.dp, 0.dp, 16.dp, 0.dp)) {
TranslatableRichTextViewer(
content = description ?: stringRes(id = R.string.community_no_descriptor),
id = baseNote.idHex + "description",
accountViewModel = accountViewModel,
) {
RichTextViewer(
it,
modifier = Modifier.fillMaxWidth(),
canPreview = false,
quotesLeft = 1,
tags = tagList,
backgroundColor = background,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
Column(
Modifier.weight(1f),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val defaultBackground = MaterialTheme.colorScheme.background
val background = remember { mutableStateOf(defaultBackground) }
guidelines?.let {
Spacer(modifier = Modifier.height(24.dp))
Title(title = stringRes(R.string.guidelines))
Spacer(modifier = Modifier.height(16.dp))
TranslatableRichTextViewer(
content = summary ?: stringRes(id = R.string.community_no_descriptor),
canPreview = false,
quotesLeft = 1,
tags = EmptyTagList,
backgroundColor = background,
id = baseNote.idHex,
accountViewModel = accountViewModel,
nav = nav,
)
Column(Modifier.padding(16.dp, 0.dp, 16.dp, 0.dp)) {
TranslatableRichTextViewer(
content = guidelines,
id = baseNote.idHex + "guidelines",
accountViewModel = accountViewModel,
) {
RichTextViewer(
it,
modifier = Modifier.fillMaxWidth(),
canPreview = false,
quotesLeft = 1,
tags = tagList,
backgroundColor = background,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
noteEvent?.let {
if (it.hasHashtags()) {
DisplayUncitedHashtags(
event = it,
content = summary ?: "",
content = description ?: "",
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
Column {
Row {
Spacer(modifier = Modifier.height(24.dp))
Title(title = stringRes(R.string.owner))
Spacer(modifier = Modifier.height(16.dp))
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav)
Spacer(DoubleHorzSpacer)
LongCommunityActionOptions(baseNote, accountViewModel, nav)
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
}
}
}
Row(
lineModifier,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.owner),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp),
)
Spacer(DoubleHorzSpacer)
NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav)
Spacer(DoubleHorzSpacer)
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
}
var participantUsers by
remember(baseNote) {
mutableStateOf<ImmutableList<Pair<ModeratorTag, User>>>(
persistentListOf(),
)
}
var participantUsers by
remember(baseNote) {
mutableStateOf<ImmutableList<Pair<ModeratorTag, User>>>(
persistentListOf(),
)
}
LaunchedEffect(key1 = noteEvent) {
val participants = noteEvent?.moderators()
LaunchedEffect(key1 = noteEvent) {
val participants = noteEvent?.moderators()
if (participants != null) {
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
if (participants != null) {
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
val noOwner = newParticipantUsers.filter { it.second != baseNote.author }.toImmutableList()
if (!equalImmutableLists(noOwner, participantUsers)) {
participantUsers = noOwner
}
}
}
}
}
}
participantUsers.forEach {
Row(
lineModifier.clickable { nav.nav(routeFor(it.second)) },
verticalAlignment = Alignment.CenterVertically,
) {
it.first.role?.let { it1 ->
Text(
text =
it1.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp),
)
if (participantUsers.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Title(title = stringRes(R.string.moderators))
Spacer(modifier = Modifier.height(16.dp))
participantUsers.forEach {
Column(Modifier.padding(vertical = 5.dp).clickable { nav.nav(routeFor(it.second)) }) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(it.second, Size25dp, accountViewModel)
Spacer(DoubleHorzSpacer)
UsernameDisplay(it.second, Modifier.weight(1f), accountViewModel = accountViewModel)
}
}
}
}
Spacer(DoubleHorzSpacer)
ClickableUserPicture(it.second, Size25dp, accountViewModel)
Spacer(DoubleHorzSpacer)
UsernameDisplay(it.second, Modifier.weight(1f), accountViewModel = accountViewModel)
}
}
Row(
lineModifier,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.created_at),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(75.dp),
)
Spacer(DoubleHorzSpacer)
NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f))
MoreOptionsButton(baseNote, null, accountViewModel, nav)
Spacer(modifier = Modifier.height(15.dp))
}
}
}
@@ -308,7 +365,47 @@ fun ShortCommunityHeader(
}
@Composable
private fun ShortCommunityActionOptions(
fun ShortCommunityHeaderNoActions(
baseNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent by observeNoteEvent<CommunityDefinitionEvent>(baseNote, accountViewModel)
Row(verticalAlignment = Alignment.CenterVertically) {
noteEvent?.image()?.let {
RobohashFallbackAsyncImage(
robot = baseNote.idHex,
model = it.imageUrl,
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
)
}
Column(
modifier =
Modifier
.padding(start = 10.dp)
.height(Size35dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = noteEvent?.name() ?: baseNote.dTag(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
fun ShortCommunityActionOptions(
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
@@ -346,9 +443,12 @@ private fun LongCommunityActionOptions(
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (isFollowing) {
LeaveCommunityButton(accountViewModel, note, nav)
Row {
ShareCommunityButton(accountViewModel, note, nav)
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (isFollowing) {
LeaveCommunityButton(accountViewModel, note, nav)
}
}
}
}
@@ -388,18 +488,48 @@ fun LeaveCommunityButton(
note: AddressableNote,
nav: INav,
) {
val scope = rememberCoroutineScope()
Button(
FilledTonalButton(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = { accountViewModel.unfollow(note) },
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
contentPadding = ButtonPadding,
) {
Text(text = stringRes(R.string.leave), color = Color.White)
Text(text = stringRes(R.string.leave))
}
}
@Composable
fun ShareCommunityButton(
accountViewModel: AccountViewModel,
note: AddressableNote,
nav: INav,
) {
val actContext = LocalContext.current
FilledTonalIconButton(
onClick = {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(actContext, R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
ContextCompat.startActivity(actContext, shareIntent, null)
},
) {
Icon(
imageVector = Icons.Default.Share,
modifier = Size18Modifier,
contentDescription = stringRes(R.string.quick_action_share),
)
}
}
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -102,8 +103,7 @@ fun RenderFeedState(
nav: INav,
routeForLastRead: String?,
onLoaded: @Composable (FeedState.Loaded) -> Unit = {
com.vitorpamplona.amethyst.ui.feeds
.FeedLoaded(it, listState, routeForLastRead, accountViewModel, nav)
FeedLoaded(it, listState, routeForLastRead, accountViewModel, nav)
},
onEmpty: @Composable () -> Unit = { FeedEmpty { viewModel.invalidateData() } },
onError: @Composable (String) -> Unit = { FeedError(it) { viewModel.invalidateData() } },
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -123,36 +124,40 @@ class FollowListState(
val livePeopleListsFlow = MutableStateFlow(emptyList<FeedDefinition>())
fun hasItemInNoteList(notes: Set<Note>): Boolean =
notes.any { it ->
val noteEvent = it.event
noteEvent?.pubKey == account.userProfile().pubkeyHex &&
(
(
noteEvent is PeopleListEvent ||
noteEvent is FollowListEvent ||
noteEvent is MuteListEvent ||
noteEvent is ContactListEvent
) ||
(
noteEvent is DeletionEvent &&
(
noteEvent.deleteEventIds().any { LocalCache.getNoteIfExists(it)?.event is PeopleListEvent } ||
noteEvent.deleteAddresses().any { it.kind == PeopleListEvent.KIND }
)
)
)
}
fun deleteFromFeed(deletedNotes: Set<Note>) {
checkNotInMainThread()
if (hasItemInNoteList(deletedNotes)) {
livePeopleListsFlow.tryEmit(getPeopleLists())
}
}
fun updateFeedWith(newNotes: Set<Note>) {
checkNotInMainThread()
val hasNewList =
newNotes.any { it ->
val noteEvent = it.event
noteEvent?.pubKey == account.userProfile().pubkeyHex &&
(
(
noteEvent is PeopleListEvent ||
noteEvent is FollowListEvent ||
noteEvent is MuteListEvent ||
noteEvent is ContactListEvent
) ||
(
noteEvent is DeletionEvent &&
(
noteEvent.deleteEventIds().any {
LocalCache.getNoteIfExists(it)?.event is PeopleListEvent
} ||
noteEvent.deleteAddresses().any {
it.kind == PeopleListEvent.KIND
}
)
)
)
}
if (hasNewList) {
if (hasItemInNoteList(newNotes)) {
livePeopleListsFlow.tryEmit(getPeopleLists())
}
}
@@ -163,7 +168,7 @@ class FollowListState(
checkNotInMainThread()
val communities =
communityList.mapNotNull {
communityList.map {
LocalCache.getOrCreateAddressableNote(it.address).let { communityNote ->
TagFeedDefinition(
"Community/${communityNote.idHex}",
@@ -377,6 +382,7 @@ val DEFAULT_FEED_KINDS =
val DEFAULT_COMMUNITY_FEEDS =
listOf(
TextNoteEvent.KIND,
CommentEvent.KIND,
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
@@ -104,6 +104,34 @@ class AccountFeedContentStates(
drafts.updateFeedWith(newNotes)
}
fun deleteNotes(newNotes: Set<Note>) {
checkNotInMainThread()
homeLive.deleteFromFeed(newNotes)
homeNewThreads.deleteFromFeed(newNotes)
homeReplies.deleteFromFeed(newNotes)
dmKnown.updateFeedWith(newNotes)
dmNew.updateFeedWith(newNotes)
videoFeed.deleteFromFeed(newNotes)
discoverMarketplace.deleteFromFeed(newNotes)
discoverFollowSets.deleteFromFeed(newNotes)
discoverReads.deleteFromFeed(newNotes)
discoverDVMs.deleteFromFeed(newNotes)
discoverLive.deleteFromFeed(newNotes)
discoverCommunities.deleteFromFeed(newNotes)
discoverPublicChats.deleteFromFeed(newNotes)
notifications.deleteFromFeed(newNotes)
notificationSummary.invalidateInsertData(newNotes)
feedListOptions.deleteFromFeed(newNotes)
drafts.deleteFromFeed(newNotes)
}
fun destroy() {
notifications.destroy()
notificationSummary.destroy()
@@ -774,6 +774,11 @@ class AccountViewModel(
}
}
fun approveCommunityPost(
post: Note,
community: AddressableNote,
) = runIOCatching { account.approveCommunityPost(post, community) }
fun follow(community: AddressableNote) = runIOCatching { account.follow(community) }
fun follow(channel: PublicChatChannel) = runIOCatching { account.follow(channel) }
@@ -1188,6 +1193,7 @@ class AccountViewModel(
}
logTime("AccountViewModel deletedEventBundle Update with ${newNotes.size} new notes") {
newNotesPreProcessor.runDeleted(newNotes)
feedStates.deleteNotes(newNotes)
}
}
}
@@ -351,7 +351,7 @@ class ChatNewMessageViewModel :
val version = draftTag.current
innerSendPost(null)
cancel()
accountViewModel.viewModelScope.launch {
accountViewModel.viewModelScope.launch(Dispatchers.Default) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
@@ -165,6 +166,14 @@ fun NewGroupDMScreen(
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.runIOCatching {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
}
Scaffold(
topBar = {
PostingTopBar(
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -79,6 +80,14 @@ fun PrivateMessageEditFieldRow(
onSendNewMessage: () -> Unit,
nav: INav,
) {
BackHandler {
accountViewModel.runIOCatching {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
}
nav.popBack()
}
channelScreenModel.replyTo.value?.let {
DisplayReplyingToNote(it, accountViewModel, nav) {
channelScreenModel.clearReply()
@@ -276,7 +276,7 @@ open class ChannelNewMessageViewModel :
cancel()
accountViewModel.account.signAndSendPrivately(template, channelRelays)
accountViewModel.viewModelScope.launch {
accountViewModel.viewModelScope.launch(Dispatchers.Default) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -60,6 +61,14 @@ fun EditFieldRow(
onSendNewMessage: suspend () -> Unit,
nav: INav,
) {
BackHandler {
accountViewModel.runIOCatching {
channelScreenModel.sendDraftSync()
channelScreenModel.cancel()
}
nav.popBack()
}
channelScreenModel.replyTo.value?.let {
DisplayReplyingToNote(it, accountViewModel, nav) {
channelScreenModel.clearReply()
@@ -20,29 +20,56 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities
import android.R.attr.onClick
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults.contentPadding
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader
import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader
import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityActionOptions
import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeaderNoActions
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal.CommunityFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal.CommunityModerationFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import kotlinx.coroutines.launch
@Composable
fun CommunityScreen(
@@ -77,61 +104,116 @@ fun PrepareViewModelsCommunityScreen(
),
)
CommunityScreen(note, followsFeedViewModel, accountViewModel, nav)
val modFeedViewModel: CommunityModerationFeedViewModel =
viewModel(
key = note.idHex + "CommunityModerationFeedViewModel",
factory =
CommunityModerationFeedViewModel.Factory(
note,
accountViewModel.account,
),
)
CommunityScreen(note, followsFeedViewModel, modFeedViewModel, accountViewModel, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CommunityScreen(
note: AddressableNote,
feedViewModel: CommunityFeedViewModel,
modFeedViewModel: CommunityModerationFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedViewModel)
WatchLifecycleAndUpdateModel(modFeedViewModel)
CommunityFilterAssemblerSubscription(note, accountViewModel.dataSources().community)
val pagerState = rememberForeverPagerState(note.idHex + "CommunityScreenPagerState") { 2 }
val expanded = remember { mutableStateOf(false) }
DisappearingScaffold(
isInvertedLayout = false,
isActive = { !expanded.value },
topBar = {
CommunityTopBar(note.address, accountViewModel, nav)
Column(
Modifier.clickable { expanded.value = !expanded.value },
) {
ShorterTopAppBar(
title = {
ShortCommunityHeaderNoActions(note, accountViewModel, nav)
},
navigationIcon = {
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
}
},
actions = {
Row(
modifier =
Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ShortCommunityActionOptions(note, accountViewModel, nav)
}
},
)
if (expanded.value) {
val elevation = if (MaterialTheme.colorScheme.isLight) 1.dp else 10.dp
Surface(modifier = Modifier.fillMaxWidth(), tonalElevation = elevation) {
LongCommunityHeader(baseNote = note, accountViewModel = accountViewModel, nav = nav)
}
}
}
TabRow(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
modifier = TabRowHeight,
selectedTabIndex = pagerState.currentPage,
) {
val coroutineScope = rememberCoroutineScope()
Tab(
selected = pagerState.currentPage == 0,
text = { Text(text = stringRes(R.string.feed)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
)
Tab(
selected = pagerState.currentPage == 1,
text = { Text(text = stringRes(R.string.mod_queue)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
)
}
},
floatingButton = {
NewCommunityNoteButton(note.idHex, accountViewModel, nav)
},
accountViewModel = accountViewModel,
) {
Column(
modifier = Modifier.padding(it).consumeWindowInsets(it),
) {
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
fun CommunityTopBar(
id: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadAddressableNote(id, accountViewModel) { baseNote ->
if (baseNote != null) {
TopBarExtensibleWithBackButton(
title = { ShortCommunityHeader(baseNote, accountViewModel, nav) },
extendableRow = {
Column(Modifier.verticalScroll(rememberScrollState())) {
LongCommunityHeader(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav)
}
},
popBack = nav::popBack,
)
} else {
Spacer(BottomTopHeight)
HorizontalPager(
contentPadding = it,
state = pagerState,
) { page ->
when (page) {
0 ->
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
1 ->
RefresheableFeedView(
modFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@@ -57,8 +57,8 @@ fun NewCommunityNoteButton(
FloatingActionButton(
onClick = {
val route =
Route.NewShortNote(
baseReplyTo = note.idHex,
Route.GenericCommentPost(
replyTo = note.idHex,
)
nav.nav(route)
},
@@ -26,21 +26,28 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
import kotlin.collections.toSet
class CommunityFeedFilter(
val note: AddressableNote,
val communityDefNote: AddressableNote,
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + note.idHex
val communityDefEvent = communityDefNote.event as? CommunityDefinitionEvent
val moderators = communityDefEvent?.moderatorKeys()?.toSet() ?: emptySet()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + communityDefNote.idHex
override fun feed(): List<Note> {
val myPubKey = account.userProfile().pubkeyHex
if (communityDefEvent == null) return emptyList()
val result =
LocalCache.notes.mapFlattenIntoSet { _, it ->
filterMap(it, myPubKey)
filterMap(it)
}
return sort(result)
@@ -49,35 +56,39 @@ class CommunityFeedFilter(
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myPubKey = account.userProfile().pubkeyHex
if (communityDefEvent == null) return emptySet()
return collection
.mapNotNull {
filterMap(it, myPubKey)
filterMap(it)
}.flatten()
.toSet()
}
private fun filterMap(
note: Note,
myPubKey: HexKey,
): List<Note>? =
if (
private fun filterMap(note: Note): List<Note>? {
val noteEvent = note.event ?: return null
return if (
// Only Approvals
note.event is CommunityPostApprovalEvent &&
// Of the given community
note.event?.isTaggedAddressableNote(this.note.idHex) == true
(noteEvent is TextNoteEvent || noteEvent is CommentEvent || noteEvent is CommunityPostApprovalEvent) &&
noteEvent.pubKey in moderators &&
noteEvent.isForCommunity(this.communityDefNote.idHex)
) {
// if it is my post, bring on
if (note.author?.pubkeyHex == myPubKey && note.isNewThread()) {
listOf(note)
if (noteEvent is CommunityPostApprovalEvent) {
note.replyTo?.filter {
it.isNewThread()
}
} else {
// brings the actual posts, not the approvals
note.replyTo?.filter { it.isNewThread() }
if (note.isNewThread()) {
listOf(note)
} else {
null
}
}
} else {
null
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,126 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
class CommunityModerationFeedFilter(
val communityDefNote: AddressableNote,
val account: Account,
) : AdditiveFeedFilter<Note>() {
val approvedFilter = CommunityFeedFilter(communityDefNote, account)
val communityDefEvent = communityDefNote.event as? CommunityDefinitionEvent
val moderators = communityDefEvent?.moderatorKeys()?.toSet() ?: emptySet()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + communityDefNote.idHex
override fun feed(): List<Note> {
if (communityDefEvent == null) return emptyList()
val result =
LocalCache.notes.mapFlattenIntoSet { _, it ->
filterMap(it)
}
return sort(result)
}
override fun updateListWith(
oldList: List<Note>,
newItems: Set<Note>,
): List<Note> {
val approved = approvedFilter.applyFilter(newItems)
val newOldList =
if (approved.isNotEmpty()) {
oldList - approved
} else {
oldList
}
val newItemsToBeAdded = applyFilter(newItems)
return if (newItemsToBeAdded.isNotEmpty()) {
val newList = newOldList.toSet() + newItemsToBeAdded
sort(newList).take(limit())
} else {
newOldList
}
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
if (communityDefEvent == null) return emptySet()
return collection
.mapNotNull {
filterMap(it)
}.flatten()
.toSet()
}
private fun wasApprovedByCommunity(note: Note): Boolean =
note.boosts.any {
val approvalEvent = it.event
(approvalEvent is CommunityPostApprovalEvent || approvalEvent is RepostEvent || approvalEvent is GenericRepostEvent) &&
approvalEvent.pubKey in moderators &&
approvalEvent.isForCommunity(this.communityDefNote.idHex)
}
private fun filterMap(note: Note): List<Note>? {
val noteEvent = note.event ?: return null
return if (
(noteEvent is TextNoteEvent || noteEvent is CommentEvent || noteEvent is CommunityPostApprovalEvent) &&
noteEvent.isForCommunity(this.communityDefNote.idHex) &&
noteEvent.pubKey !in moderators
) {
if (noteEvent is CommunityPostApprovalEvent) {
note.replyTo?.filter {
it.isNewThread() && !wasApprovedByCommunity(it)
}
} else {
if (note.isNewThread() && !wasApprovedByCommunity(note)) {
listOf(note)
} else {
null
}
}
} else {
null
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
class CommunityModerationFeedViewModel(
val note: AddressableNote,
val account: Account,
) : FeedViewModel(CommunityModerationFeedFilter(note, account)) {
class Factory(
val note: AddressableNote,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = CommunityModerationFeedViewModel(note, account) as T
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.mapOfSet
class CommunityFeedFilterSubAssembler(
client: NostrClient,
@@ -40,14 +41,34 @@ class CommunityFeedFilterSubAssembler(
return keys.flatMap {
val commEvent = it.community.event
if (commEvent is CommunityDefinitionEvent) {
val relays =
commEvent.relayUrls().ifEmpty { null }
?: LocalCache.relayHints.hintsForAddress(commEvent.addressTag()).ifEmpty { null }
?: it.community.relayUrls()
val communityRelays =
(
commEvent.relayUrls().ifEmpty {
LocalCache.relayHints.hintsForAddress(commEvent.addressTag())
} + it.community.relayUrls()
).toSet()
relays.toSet().map {
filterCommunityPosts(it, commEvent, since?.get(it)?.time)
}
val moderatorsOutbox =
mapOfSet {
commEvent.moderatorKeys().forEach { modKey ->
// hits the relay of each moderator for approvals
LocalCache.checkGetOrCreateUser(modKey)?.outboxRelays()?.forEach { relay ->
add(relay, modKey)
}
// static relay hints from this community
communityRelays.forEach { relay ->
add(relay, modKey)
}
}
}
moderatorsOutbox.map { (relay, authors) ->
filterCommunityPostsFromModerators(relay, authors, commEvent, since?.get(relay)?.time)
} +
communityRelays.map { relay ->
filterCommunityPostsFromEverybody(relay, commEvent, since?.get(relay)?.time)
}
} else {
emptyList()
}
@@ -20,11 +20,29 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
val CommunityPostKinds =
listOf(
TextNoteEvent.KIND,
CommentEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
)
fun filterCommunityPosts(
relay: NormalizedRelayUrl,
@@ -35,10 +53,44 @@ fun filterCommunityPosts(
relay = relay,
filter =
Filter(
authors = community.moderators().map { it.pubKey }.plus(community.pubKey),
authors = community.moderatorKeys(),
tags = mapOf("a" to listOf(community.addressTag())),
kinds = CommunityPostApprovalEvent.KIND_LIST,
kinds = CommunityPostKinds,
limit = 500,
since = since,
),
)
fun filterCommunityPostsFromModerators(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
community: CommunityDefinitionEvent,
since: Long?,
): RelayBasedFilter =
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors.sorted(),
tags = mapOf("a" to listOf(community.addressTag())),
kinds = CommunityPostKinds,
limit = 100,
since = since,
),
)
fun filterCommunityPostsFromEverybody(
relay: NormalizedRelayUrl,
community: CommunityDefinitionEvent,
since: Long?,
): RelayBasedFilter =
RelayBasedFilter(
relay = relay,
filter =
Filter(
tags = mapOf("a" to listOf(community.addressTag())),
kinds = CommunityPostKinds,
limit = 100,
since = since,
),
)
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -136,6 +137,14 @@ fun NewProductScreen(
) {
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.runIOCatching {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
}
Scaffold(
topBar = {
PostingTopBar(
@@ -297,7 +297,7 @@ open class NewProductViewModel :
cancel()
accountViewModel.account.signAndSendPrivatelyOrBroadcast(template, relayList = { relayList })
accountViewModel.viewModelScope.launch {
accountViewModel.viewModelScope.launch(Dispatchers.Default) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import android.content.Intent
import android.net.Uri
import android.os.Parcelable
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -164,6 +165,14 @@ private fun NewPostScreenInner(
) {
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.runIOCatching {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
}
Scaffold(
topBar = {
PostingTopBar(
@@ -483,7 +483,7 @@ open class ShortNotePostViewModel :
cancel()
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
accountViewModel.viewModelScope.launch {
accountViewModel.viewModelScope.launch(Dispatchers.Default) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
@@ -340,6 +340,9 @@ class CardFeedContentState(
}
}
fun deleteFromFeed(deletedNotes: Set<Note>) {
}
private fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
@@ -33,8 +33,10 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
@@ -50,6 +52,8 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
@@ -157,7 +161,26 @@ class NotificationFeedFilter(
}
if (event is BaseThreadedEvent) {
if (note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) return true
if (note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) {
return true
}
if ((event is TextNoteEvent || event is CommentEvent)) {
val community =
event
.communityAddress()
?.let {
LocalCache.getAddressableNoteIfExists(it)
}?.event as? CommunityDefinitionEvent
if (community != null) {
val moderators = community.moderatorKeys().toSet()
val isModerator = moderators.contains(authorHex)
if (isModerator && event.pubKey !in moderators) {
return true
}
}
}
val isAuthoredPostCited = event.findCitations().any { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex }
val isAuthorDirectlyCited = event.citedUsers().contains(authorHex)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
@@ -128,6 +129,14 @@ fun NewPublicMessageScreen(
WatchAndLoadMyEmojiList(accountViewModel)
BackHandler {
accountViewModel.runIOCatching {
postViewModel.sendDraftSync()
postViewModel.cancel()
}
nav.popBack()
}
Scaffold(
topBar = {
PostingTopBar(
@@ -321,7 +321,7 @@ class NewPublicMessageViewModel :
cancel()
accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast)
accountViewModel.viewModelScope.launch {
accountViewModel.viewModelScope.launch(Dispatchers.Default) {
accountViewModel.account.deleteDraftIgnoreErrors(version)
}
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
@@ -70,6 +71,7 @@ class UserProfileNewThreadFeedFilter(
it.author == user &&
(
it.event is TextNoteEvent ||
it.event is CommentEvent ||
it.event is ClassifiedsEvent ||
it.event is RepostEvent ||
it.event is GenericRepostEvent ||
@@ -49,6 +49,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.LoadNote
@@ -96,7 +98,9 @@ import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.RenderApproveButton
import com.vitorpamplona.amethyst.ui.note.RenderDraft
import com.vitorpamplona.amethyst.ui.note.RenderRepost
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
@@ -168,6 +172,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -189,7 +194,6 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableKind
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull
@@ -227,7 +231,8 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
@@ -435,6 +440,7 @@ private fun FullBleedNoteCompose(
) {
val noteEvent = baseNote.event ?: return
val isDraft = baseNote.isDraft()
val backgroundColor =
calculateBackgroundColor(
baseNote.createdAt(),
@@ -467,15 +473,28 @@ private fun FullBleedNoteCompose(
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
val isCommunityPost =
remember(baseNote) {
baseNote.event?.isTaggedAddressableKind(CommunityDefinitionEvent.KIND) == true
if (isDraft) {
ObserveDraftEvent(baseNote, accountViewModel) { draftNote ->
val isCommunityPost by remember(draftNote) {
derivedStateOf { draftNote.event?.isACommunityPost() == true }
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(draftNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(draftNote, accountViewModel, nav)
}
}
} else {
val isCommunityPost by remember(baseNote) {
derivedStateOf { baseNote.event?.isACommunityPost() == true }
}
if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
if (isCommunityPost) {
DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav)
} else {
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
}
CheckAndDisplayEditStatus(editState)
@@ -508,7 +527,7 @@ private fun FullBleedNoteCompose(
DisplayPoW(pow)
}
if (baseNote.isDraft()) {
if (isDraft) {
DisplayDraft()
}
@@ -527,9 +546,7 @@ private fun FullBleedNoteCompose(
}
Row(
modifier =
modifier
.padding(horizontal = 12.dp),
modifier = PaddingHorizontal12Modifier,
) {
Column {
if (noteEvent is ChannelCreateEvent) {
@@ -753,17 +770,43 @@ private fun FullBleedNoteCompose(
val zapSplits = remember(noteEvent) { noteEvent.hasZapSplitSetup() }
if (zapSplits) {
Spacer(modifier = DoubleVertSpacer)
Row(
modifier = Modifier.padding(horizontal = 12.dp),
) {
Row(PaddingHorizontal12Modifier) {
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
}
}
RenderApprovalIfNeeded(baseNote, accountViewModel, nav)
ReactionsRow(baseNote, showReactionDetail = true, addPadding = true, editState = editState, accountViewModel = accountViewModel, nav = nav)
}
}
@Composable
private fun RenderApprovalIfNeeded(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (baseNote.isNewThread()) {
val communityAddress =
remember(baseNote) {
baseNote.event?.communityAddress()
}
communityAddress?.let {
LoadAddressableNote(it, accountViewModel) { community ->
if (community != null) {
val showApproveButton by observeCommunityApprovalNeedStatus(baseNote, community, accountViewModel)
if (showApproveButton == true) {
Row(PaddingHorizontal12Modifier) {
RenderApproveButton(baseNote, community, accountViewModel)
}
}
}
}
}
}
}
@Composable
private fun RenderClassifiedsReaderForThread(
noteEvent: ClassifiedsEvent,
@@ -358,3 +358,5 @@ val BadgePictureModifier = Modifier.size(35.dp).clip(shape = CutCornerShape(20))
val MaxWidthPaddingTop5dp = Modifier.fillMaxWidth().padding(top = 5.dp)
val VoiceHeightModifier = Modifier.fillMaxWidth().height(100.dp)
val PaddingHorizontal12Modifier = Modifier.padding(horizontal = 12.dp)
+18 -12
View File
@@ -101,7 +101,7 @@
<string name="error_parsing_preview_for">"Błąd analizowania podglądu dla %1$s : %2$s"</string>
<string name="preview_card_image_for">"Podgląd obrazu karty dla %1$s"</string>
<string name="new_channel">Nowy kanał</string>
<string name="channel_name">Nazwa Grupy</string>
<string name="channel_name">Nazwa</string>
<string name="my_awesome_group">Nazwa Grupy</string>
<string name="picture_url">Adres Url obrazka</string>
<string name="description">Opis</string>
@@ -159,10 +159,11 @@
<string name="edits_the_channel_metadata">Edytuje metadane kanału</string>
<string name="join">Dołącz</string>
<string name="known">Znajomi</string>
<string name="new_requests">Nowe Kontakty</string>
<string name="new_requests">Nowe</string>
<string name="blocked_users">Zablokowani użytkownicy</string>
<string name="new_threads">Nowe Wpisy</string>
<string name="conversations">Komentarze</string>
<string name="mod_queue">Moderacja</string>
<string name="notes">Wpisy</string>
<string name="replies">Odpowiedzi</string>
<string name="mutual">Twoje</string>
@@ -263,9 +264,9 @@
<string name="report_hateful_speech">Zgłoś mowę nienawiści</string>
<string name="report_nudity_porn">Zgłoś Nagość / Pornografię</string>
<string name="others">inne</string>
<string name="mark_all_known_as_read">Oznacz wszystkie popularne jako przeczytane</string>
<string name="mark_all_new_as_read">Oznacz wszystkie nowe jako przeczytane</string>
<string name="mark_all_as_read">Oznacz wszystkie jako przeczytane</string>
<string name="mark_all_known_as_read">Zaznacz wszystkie popularne jako przeczytane</string>
<string name="mark_all_new_as_read">Zaznacz wszystkie nowe jako przeczytane</string>
<string name="mark_all_as_read">Zaznacz wszystkie jako przeczytane</string>
<string name="backup_keys">Klucze kopii zapasowej</string>
<string name="account_backup_tips2_md" tools:ignore="Typos"> ## Porady dotyczące kopii zapasowej i bezpieczeństwa
\n\nTwoje konto jest zabezpieczone tajnym kluczem. Kluczem jest długa sekwencja znaków, zaczynająca się od **nsec1**. Każdy, kto ma dostęp do tego tajnego klucza może publikować i zmieniać Twoją tożsamość.
@@ -456,7 +457,7 @@
<string name="tor_use_onion_address_explainer">Użyj Tor dla dowolnego adresu .onion</string>
<string name="tor_use_dm_relays">Transmitery DM</string>
<string name="tor_use_dm_relays_explainer">Zmuś Tor do wysyłania i odbierania DM</string>
<string name="tor_use_new_relays">Niepewne Transmitery</string>
<string name="tor_use_new_relays">Niewiarygodne Transmitery</string>
<string name="tor_use_new_relays_explainer">Wymuś Tor na transmiterach skrzynki odbiorczej/nadawczej</string>
<string name="tor_use_trusted_relays">Zaufane Transmitery</string>
<string name="tor_use_trusted_relays_explainer">Wymuś Tor na wszystkich transmiterach na listach</string>
@@ -471,10 +472,10 @@
<string name="tor_use_money_operations">Operacje finansowe</string>
<string name="tor_use_money_operations_explainer">Wymuś Tor przy zapsach, transferach lightning and cashu</string>
<string name="tor_use_nip05_verification">Weryfikacja adresu Nostr</string>
<string name="tor_use_nip05_verification_explainer">Wymuś Tor przy weryfikacji adresów NIP-05</string>
<string name="tor_use_nip05_verification_explainer">Wymuś Tor podczas weryfikacji adresów NIP-05</string>
<string name="tor_use_nip96_uploads">Wgrywanie mediów</string>
<string name="tor_use_nip96_uploads_explainer">Wymuś Tor podczas wgrywania zawartości</string>
<string name="tor_internal">Wewnętrzny</string>
<string name="tor_internal">Wewnętrznie</string>
<string name="tor_external">Orbot</string>
<string name="tor_off">Wył.</string>
<string name="tor_when_needed">Podstawowe</string>
@@ -484,7 +485,7 @@
<string name="tor_custom">Niestandardowy</string>
<string name="tor_when_needed_explainer">Użyj Tor, gdy jest wymagany przez serwer</string>
<string name="tor_default_explainer">Ukryj swój adres IP przed przypadkowymi transmiterami</string>
<string name="tor_small_payloads_explainer">Ukryj swój adres IP od wszystkiego oprócz zdjęć i filmów</string>
<string name="tor_small_payloads_explainer">Ukryj swój adres IP wszędzie oprócz zdjęć i filmów</string>
<string name="tor_full_privacy_explainer">Ukryj swój adres IP przy wszystkich połączeniach</string>
<string name="tor_custom_explainer">Stwórz swój własny</string>
<string name="invalid_port_number">Nieprawidłowy numer portu</string>
@@ -520,7 +521,7 @@
<string name="warn_when_posts_have_reports_from_your_follows_title">Ostrzeżenie o zgłoszeniach</string>
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Wyświetla komunikat ostrzegawczy, gdy posty zostały zgłoszone przez 5 lub więcej obserwowanych osób</string>
<string name="show_sensitive_content_title">Pokaż wrażliwe treści</string>
<string name="show_sensitive_content_explainer">Pokazuje komunikat ostrzegawczy, gdy autor wpisu oznaczył go jako wrażliwy</string>
<string name="show_sensitive_content_explainer">Pokazuje komunikat ostrzegawczy, gdy autor oznaczył wpis jako wrażliwy</string>
<string name="new_reaction_symbol">Nowy Symbol Odzewu</string>
<string name="no_reaction_type_setup_long_press_to_change">Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić</string>
<string name="zapraiser">Zapraiser</string>
@@ -645,7 +646,7 @@
<string name="messages_new_message_to">Do</string>
<string name="messages_new_message_subject">Temat</string>
<string name="messages_new_message_subject_caption">Temat dyskusji</string>
<string name="messages_new_message_to_caption">"\@Użytkownik1, @Użytkownik2, @Użytkownik3"</string>
<string name="messages_new_message_to_caption">"\@Użytkownik1, @Użytkownik2"</string>
<string name="messages_cant_upload_title">Nie można wysłać</string>
<string name="messages_cant_upload_explainer">Najpierw podaj adresata wiadomości</string>
<string name="messages_group_descriptor">Członkowie tej grupy</string>
@@ -669,6 +670,9 @@
<string name="media_added_to_profile_gallery">Pliki dodane do Twojej galerii profilu</string>
<string name="created_at">Utworzono</string>
<string name="rules">Zasady</string>
<string name="community_description">O nas</string>
<string name="guidelines">Wskazówki</string>
<string name="moderators">Moderatorzy</string>
<string name="login_with_external_signer">Zaloguj się za pomocą Amber</string>
<string name="status_update">Zaktualizuj swój status</string>
<string name="lightning_wallets_not_found">Błąd podczas analizowania komunikatu o błędzie</string>
@@ -903,7 +907,7 @@
<string name="trusted_section_explainer">Transmitery, którym ufasz, nie potrzebujesz połączenia Tor</string>
<string name="proxy_relays_title">Transmitery Proxy</string>
<string name="proxy_section">Transmitery Proxy</string>
<string name="proxy_section_explainer">Transmitery agregatora, z których aplikacja musi pobierać kanały, takie jak filter.nostr.wine. To zastępuje model skrzynki nadawczej i sprawia, że aplikacja łączy się tylko z transmiterami z listy.</string>
<string name="proxy_section_explainer">Transmitery agregatora, z których aplikacja musi pobierać kanały, takie jak filter.nostr.wine. To zastępuje model skrzynki nadawczej i sprawia, że aplikacja łączy się tylko z transmiterami z Twojej listy.</string>
<string name="broadcast_relays_title">Transmitery Nadawcze</string>
<string name="broadcast_section">Transmitery Nadawania</string>
<string name="broadcast_section_explainer">Transmitery, które specjalizują się w przesyłaniu wpisów do wszystkich innych transmiterów, takich jak sendit.nosflare.com. Amethyst doda ten transmiter do wszystkich nowych wydarzeń utworzonych przez użytkownika</string>
@@ -1011,4 +1015,6 @@
<string name="dont_translate_from_description">Języki wyświetlane tutaj nie będą tłumaczone. Wybierz język, aby usunąć go z listy języków nietłumaczonych.</string>
<string name="pause">Pauza</string>
<string name="play">Odtwórz</string>
<string name="open_dropdown_menu">Otwórz rozwijane menu</string>
<string name="option_of">Opcja %1$s dla %2$s</string>
</resources>
@@ -163,6 +163,8 @@
<string name="blocked_users">已隐藏用户</string>
<string name="new_threads">新动态</string>
<string name="conversations">会话</string>
<string name="feed"></string>
<string name="mod_queue">Mod 队列</string>
<string name="notes">动态</string>
<string name="replies">回复</string>
<string name="mutual">你的</string>
@@ -672,6 +674,9 @@
<string name="media_added_to_profile_gallery">媒体已添加到个人资料相册中</string>
<string name="created_at">创建于</string>
<string name="rules">规则</string>
<string name="community_description">关于我们</string>
<string name="guidelines">指南</string>
<string name="moderators">审核者</string>
<string name="login_with_external_signer">使用 Amber 登录</string>
<string name="status_update">更新你的状态</string>
<string name="lightning_wallets_not_found">错误解析错误消息</string>
@@ -1014,4 +1019,8 @@
<string name="dont_translate_from_description">此处显示的语言不会被翻译,请选择一种目标语言重新翻译并去除这个提示。</string>
<string name="pause">暂停</string>
<string name="play">播放</string>
<string name="open_dropdown_menu">打开下拉菜单</string>
<string name="option_of">第 %1$s 个选项,共 %2$s 个</string>
<string name="feed_filter_selected">源过滤器,选中了 %1$s</string>
<string name="feed_filter_select_an_option">源过滤器,%1$s</string>
</resources>
+6
View File
@@ -175,6 +175,8 @@
<string name="blocked_users">Blocked Users</string>
<string name="new_threads">New Threads</string>
<string name="conversations">Conversations</string>
<string name="feed">Feed</string>
<string name="mod_queue">Mod Queue</string>
<string name="notes">Notes</string>
<string name="replies">Replies</string>
<string name="mutual">Yours</string>
@@ -806,6 +808,10 @@
<string name="created_at">Created at</string>
<string name="rules">Rules</string>
<string name="community_description">About Us</string>
<string name="guidelines">Guidelines</string>
<string name="moderators">Moderators</string>
<string name="login_with_external_signer">Login with Amber</string>
<string name="status_update">Update your status</string>
@@ -81,50 +81,13 @@ fun TranslatableRichTextViewer(
accountViewModel: AccountViewModel,
nav: INav,
) {
var translatedTextState by translateAndWatchLanguageChanges(content, id, accountViewModel)
CrossfadeIfEnabled(targetState = translatedTextState, accountViewModel = accountViewModel) {
RenderText(
translatedTextState = it,
content = content,
canPreview = canPreview,
quotesLeft = quotesLeft,
modifier = modifier,
tags = tags,
backgroundColor = backgroundColor,
id = id,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun RenderText(
translatedTextState: TranslationConfig,
content: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
id: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
var showOriginal by
remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) }
val toBeViewed by
remember(translatedTextState) {
derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content }
}
Column {
TranslatableRichTextViewer(
content = content,
id = id,
accountViewModel = accountViewModel,
) {
ExpandableRichTextViewer(
toBeViewed,
it,
canPreview,
quotesLeft,
modifier,
@@ -135,6 +98,45 @@ private fun RenderText(
accountViewModel,
nav,
)
}
}
@Composable
fun TranslatableRichTextViewer(
content: String,
id: String,
accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit,
) {
var translatedTextState by translateAndWatchLanguageChanges(content, id, accountViewModel)
CrossfadeIfEnabled(targetState = translatedTextState, accountViewModel = accountViewModel) {
RenderTextWithTranslateOptions(
translatedTextState = it,
content = content,
accountViewModel = accountViewModel,
displayText = displayText,
)
}
}
@Composable
private fun RenderTextWithTranslateOptions(
translatedTextState: TranslationConfig,
content: String,
accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit,
) {
var showOriginal by
remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) }
val toBeViewed by
remember(translatedTextState) {
derivedStateOf { if (showOriginal) content else translatedTextState.result ?: content }
}
Column {
displayText(toBeViewed)
if (
translatedTextState.sourceLang != null &&
@@ -325,7 +327,7 @@ fun translateAndWatchLanguageChanges(
id: String,
accountViewModel: AccountViewModel,
): MutableState<TranslationConfig> {
var translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) }
val translatedTextState = remember(id) { mutableStateOf(TranslationsCache.get(content)) }
TranslateAndWatchLanguageChanges(
content,
+2 -2
View File
@@ -54,10 +54,10 @@ dependencies {
implementation libs.androidx.core.ktx
// Compose's @Immutable and @Stable classes
implementation libs.androidx.runtime.runtime
implementation libs.androidx.compose.runtime
implementation project(path: ':quartz')
testImplementation libs.junit
androidTestImplementation libs.androidx.junit
+1 -1
View File
@@ -24,7 +24,7 @@ android {
targetCompatibility JavaVersion.VERSION_21
}
sourceSets.androidTest.assets.srcDirs += ["../quartz/src/androidTest/assets"]
sourceSets.androidTest.resources.srcDirs += ["../quartz/src/androidDeviceTest/resources"]
testBuildType = "benchmark"
buildTypes {
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.benchmark
import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -35,9 +34,8 @@ import java.nio.charset.Charset
@RunWith(AndroidJUnit4::class)
class MetaTagsParserBenchmark {
private val html =
getInstrumentation()
.context.assets
.open("github_amethyst.html")
javaClass.classLoader!!
.getResourceAsStream("github_amethyst.html")
.readBytes()
.toString(Charset.forName("utf-8"))
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.quartz.benchmark
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -34,7 +33,7 @@ open class BaseLargeCacheBenchmark {
companion object {
fun getEventDB(): List<Event> {
// This file includes duplicates
val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json")
val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json")
return JsonMapper.mapper.readValue<ArrayList<Event>>(
GZIPInputStream(fullDBInputStream),
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.benchmark
import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -42,7 +41,7 @@ import java.util.zip.GZIPInputStream
open class BaseCacheBenchmark {
fun getEventDB(): List<Event> {
// This file includes duplicates
val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json")
val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json")
return JsonMapper.mapper.readValue<ArrayList<Event>>(
GZIPInputStream(fullDBInputStream),
@@ -26,6 +26,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.generateId
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.verifyId
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertTrue
@@ -44,8 +45,8 @@ class EventCmdHasherBenchmark {
@get:Rule val benchmarkRule = BenchmarkRule()
@Test
fun checkIDHashKind1WihtoutTags() {
val event = JsonMapper.fromJson(JsonMapper.mapper.readTree(reqResponseEvent))
fun checkIDHashKind1WithoutTags() {
val event = EventMessage.parse(JsonMapper.mapper.readTree(reqResponseEvent)).event
benchmarkRule.measureRepeated {
// Should pass
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.benchmark
import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
@@ -47,9 +46,8 @@ class HintIndexerBenchmark {
}
val relays =
getInstrumentation()
.context.assets
.open("relayDB.txt")
javaClass.classLoader
?.getResourceAsStream("relayDB.txt")!!
.readBytes()
.toString(Charset.forName("utf-8"))
.split("\n")
+13 -2
View File
@@ -7,6 +7,8 @@ plugins {
alias(libs.plugins.diffplugSpotless)
alias(libs.plugins.googleServices) apply false
alias(libs.plugins.jetbrainsComposeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
}
allprojects {
@@ -39,9 +41,18 @@ allprojects {
subprojects {
afterEvaluate {
tasks.named("preBuild") {
dependsOn("spotlessApply")
try {
tasks.named("preBuild") {
dependsOn("spotlessApply")
}
} catch (UnknownTaskException ignored) {
tasks.matching {
it.name.startsWith("pre") && it.name.endsWith("Build")
}.configureEach {
dependsOn("spotlessApply")
}
}
}
}
-1
View File
@@ -51,7 +51,6 @@ composeCompiler {
dependencies {
implementation project(path: ':quartz')
// Import @Immutable and @Stable
implementation platform(libs.androidx.compose.bom)
implementation libs.androidx.ui
+3 -1
View File
@@ -20,4 +20,6 @@
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class com.vitorpamplona.amethyst.commons.** { *; }
-keep class com.vitorpamplona.quartz.** { *; }
-keep class com.vitorpamplona.amethyst.** { *; }
-keep class com.vitorpamplona.ammolite.** { *; }
+15 -3
View File
@@ -54,6 +54,9 @@ zxing = "3.5.3"
zxingAndroidEmbedded = "4.3.0"
windowCoreAndroid = "1.4.0"
androidxCamera = "1.4.2"
kotlinStdlib = "2.2.10"
kotlinTest = "2.2.10"
core = "1.7.0"
[libraries]
abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" }
@@ -69,11 +72,12 @@ androidx-camera-extensions = { module = "androidx.camera:camera-extensions", ver
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxJunit" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
@@ -89,7 +93,6 @@ androidx-media3-session = { group = "androidx.media3", name = "media3-session",
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
androidx-runtime-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-security-crypto-ktx = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "securityCryptoKtx" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
@@ -121,8 +124,12 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" }
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm-linux = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm-linux", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm-darwin = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm-darwin", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm-mingw = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm-mingw", version.ref = "secp256k1KmpJniAndroid" }
tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" }
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
url-detector = { group = "io.github.url-detector", name = "url-detector", version.ref = "urlDetector" }
@@ -135,6 +142,9 @@ zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zooma
zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
androidx-window-core-android = { group = "androidx.window", name = "window-core-android", version.ref = "windowCoreAndroid" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinTest" }
androidx-core = { group = "androidx.test", name = "core", version.ref = "core" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
@@ -146,4 +156,6 @@ jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "k
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' }
parcelize = { id = "kotlin-parcelize" }
parcelize = { id = "kotlin-parcelize" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
-88
View File
@@ -1,88 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.parcelize)
}
android {
namespace = 'com.vitorpamplona.quartz'
compileSdk = libs.versions.android.compileSdk.get().toInteger()
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled = true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
create("benchmark") {
initWith(getByName("release"))
signingConfig = signingConfigs.debug
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
packagingOptions {
resources {
excludes += ['**/libscrypt.dylib']
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
freeCompilerArgs.add("-Xstring-concat=inline")
}
}
dependencies {
implementation libs.androidx.core.ktx
implementation platform(libs.androidx.compose.bom)
// @Immutable and @Stable
implementation libs.androidx.runtime.runtime
// Bitcoin secp256k1 bindings to Android
api libs.secp256k1.kmp.jni.android
// LibSodium for ChaCha encryption (NIP-44)
// Wait for @aar support in version catalogs
// implementation 'com.goterl:lazysodium-android:5.2.0@aar'
// implementation 'net.java.dev.jna:jna:5.17.0@aar'
implementation variantOf(libs.lazysodium.android) { artifactType("aar") }
implementation variantOf(libs.jna) { artifactType("aar") }
// Performant Parser of JSONs into Events
api libs.jackson.module.kotlin
// immutable collections to avoid recomposition
api libs.kotlinx.collections.immutable
// Parses URLs from Text:
api libs.url.detector
// Normalizes URLs
api libs.rfc3986.normalizer
// Websockets API
implementation libs.okhttp
testImplementation libs.junit
testImplementation libs.secp256k1.kmp.jni.jvm
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
}
+167
View File
@@ -0,0 +1,167 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
}
kotlin {
jvm()
// Target declarations - add or remove as needed below. These define
// which platforms this KMP module supports.
// See: https://kotlinlang.org/docs/multiplatform-discover-project.html#targets
androidLibrary {
namespace = "com.vitorpamplona.quartz"
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
withHostTestBuilder {
}
withDeviceTestBuilder {
sourceSetTreeName = "test"
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
optimization {
consumerKeepRules.publish = true
consumerKeepRules.files.add(File(project.projectDir, "proguard-rules.pro"))
}
packaging {
resources {
excludes.add("**/libscrypt.dylib")
}
}
compilerOptions {
freeCompilerArgs.add("-Xstring-concat=inline")
}
}
// For iOS targets, this is also where you should
// configure native binary output. For more information, see:
// https://kotlinlang.org/docs/multiplatform-build-native-binaries.html#build-xcframeworks
// A step-by-step guide on how to include this library in an XCode
// project can be found here:
// https://developer.android.com/kotlin/multiplatform/migrate
val xcfName = "quartz-kmpKit"
iosX64 {
binaries.framework {
baseName = xcfName
}
}
iosArm64 {
binaries.framework {
baseName = xcfName
}
}
iosSimulatorArm64 {
binaries.framework {
baseName = xcfName
}
}
// Source set declarations.
// Declaring a target automatically creates a source set with the same name. By default, the
// Kotlin Gradle Plugin creates additional source sets that depend on each other, since it is
// common to share sources between related targets.
// See: https://kotlinlang.org/docs/multiplatform-hierarchy.html
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlin.stdlib)
implementation(project.dependencies.platform(libs.androidx.compose.bom))
// @Immutable and @Stable
implementation(libs.androidx.compose.runtime)
api(libs.secp256k1.kmp.common)
}
}
jvmMain {
dependencies {
// Bitcoin secp256k1 bindings
api(libs.secp256k1.kmp.jni.jvm)
// Performant Parser of JSONs into Events
api(libs.jackson.module.kotlin)
// immutable collections to avoid recomposition
api(libs.kotlinx.collections.immutable)
// Parses URLs from Text:
api(libs.url.detector)
// Normalizes URLs
api(libs.rfc3986.normalizer)
// Websockets API
implementation(libs.okhttp)
}
}
commonTest {
dependencies {
implementation(libs.kotlin.test)
// Bitcoin secp256k1 bindings
api(libs.secp256k1.kmp.jni.jvm)
}
}
androidMain {
dependencies {
implementation(libs.androidx.core.ktx)
// @Immutable and @Stable
implementation(libs.androidx.compose.runtime)
// Bitcoin secp256k1 bindings to Android
api(libs.secp256k1.kmp.jni.android)
// LibSodium for ChaCha encryption (NIP-44)
implementation ("com.goterl:lazysodium-android:5.2.0@aar")
implementation ("net.java.dev.jna:jna:5.17.0@aar")
// Performant Parser of JSONs into Events
api(libs.jackson.module.kotlin)
// immutable collections to avoid recomposition
api(libs.kotlinx.collections.immutable)
// Parses URLs from Text:
api(libs.url.detector)
// Normalizes URLs
api(libs.rfc3986.normalizer)
// Websockets API
implementation(libs.okhttp)
}
}
getByName("androidDeviceTest") {
dependencies {
implementation(libs.androidx.runner)
implementation(libs.androidx.core)
implementation(libs.androidx.junit)
implementation(libs.androidx.espresso.core)
}
}
iosMain {
dependencies {
// Add iOS-specific dependencies here. This a source set created by Kotlin Gradle
// Plugin (KGP) that each specific iOS target (e.g., iosX64) depends on as
// part of KMPs default source set hierarchy. Note that this source set depends
// on common by default and will correctly pull the iOS artifacts of any
// KMP dependencies declared in commonMain.
}
}
}
}
-147
View File
@@ -1,147 +0,0 @@
{
"cells": [
{
"metadata": {},
"cell_type": "markdown",
"source": "## Creating a new Kind 1 post"
},
{
"metadata": {
"jupyter": {
"is_executing": true
}
},
"cell_type": "code",
"source": [
"USE {\n",
" dependencies(\"fr.acinq.secp256k1:secp256k1-kmp-jni-jvm:0.17.3\")\n",
" dependencies(\"com.goterl:lazysodium-java:5.1.4\")\n",
" dependencies(\"net.java.dev.jna:jna:5.17.0\")\n",
"\n",
" import(\"com.vitorpamplona.quartz.*\")\n",
"}"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {
"collapsed": true,
"ExecuteTime": {
"end_time": "2025-06-03T14:42:40.051027Z",
"start_time": "2025-06-03T14:42:40.023052Z"
}
},
"source": [
"import fr.acinq.secp256k1.Secp256k1\n",
"\n",
"import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair\n",
"import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal\n",
"import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync\n",
"import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent\n",
"\n",
"val signer = NostrSignerSync()\n",
"val kind1Template = TextNoteEvent.build(\"New kind 1 Post\")\n",
"val signedEvent = signer.sign(kind1Template)\n",
"\n",
"println(signedEvent)"
],
"outputs": [
{
"ename": "org.jetbrains.kotlinx.jupyter.exceptions.ReplCompilerException",
"evalue": "at Cell In[5], line 3, column 43: Unresolved reference: crypto\nat Cell In[5], line 4, column 43: Unresolved reference: signers\nat Cell In[5], line 5, column 43: Unresolved reference: signers\nat Cell In[5], line 6, column 33: Unresolved reference: nip10Notes\nat Cell In[5], line 8, column 14: Unresolved reference: NostrSignerSync\nat Cell In[5], line 9, column 21: Unresolved reference: TextNoteEvent\nat Cell In[5], line 12, column 1: Overload resolution ambiguity: \npublic inline fun println(message: Any?): Unit defined in kotlin.io\npublic inline fun println(message: Boolean): Unit defined in kotlin.io\npublic inline fun println(message: Byte): Unit defined in kotlin.io\npublic inline fun println(message: Char): Unit defined in kotlin.io\npublic inline fun println(message: CharArray): Unit defined in kotlin.io\npublic inline fun println(message: Double): Unit defined in kotlin.io\npublic inline fun println(message: Float): Unit defined in kotlin.io\npublic inline fun println(message: Int): Unit defined in kotlin.io\npublic inline fun println(message: Long): Unit defined in kotlin.io\npublic inline fun println(message: Short): Unit defined in kotlin.io",
"output_type": "error",
"traceback": [
"org.jetbrains.kotlinx.jupyter.exceptions.ReplCompilerException: at Cell In[5], line 3, column 43: Unresolved reference: crypto",
"at Cell In[5], line 4, column 43: Unresolved reference: signers",
"at Cell In[5], line 5, column 43: Unresolved reference: signers",
"at Cell In[5], line 6, column 33: Unresolved reference: nip10Notes",
"at Cell In[5], line 8, column 14: Unresolved reference: NostrSignerSync",
"at Cell In[5], line 9, column 21: Unresolved reference: TextNoteEvent",
"at Cell In[5], line 12, column 1: Overload resolution ambiguity: ",
"public inline fun println(message: Any?): Unit defined in kotlin.io",
"public inline fun println(message: Boolean): Unit defined in kotlin.io",
"public inline fun println(message: Byte): Unit defined in kotlin.io",
"public inline fun println(message: Char): Unit defined in kotlin.io",
"public inline fun println(message: CharArray): Unit defined in kotlin.io",
"public inline fun println(message: Double): Unit defined in kotlin.io",
"public inline fun println(message: Float): Unit defined in kotlin.io",
"public inline fun println(message: Int): Unit defined in kotlin.io",
"public inline fun println(message: Long): Unit defined in kotlin.io",
"public inline fun println(message: Short): Unit defined in kotlin.io",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.JupyterCompilerImpl.compileSync(JupyterCompilerImpl.kt:208)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.InternalEvaluatorImpl.eval(InternalEvaluatorImpl.kt:126)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl$execute$1$result$1.invoke(CellExecutorImpl.kt:80)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl$execute$1$result$1.invoke(CellExecutorImpl.kt:78)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.withHost(ReplForJupyterImpl.kt:791)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl.execute-L4Nmkdk(CellExecutorImpl.kt:78)",
"\tat org.jetbrains.kotlinx.jupyter.repl.execution.CellExecutor$DefaultImpls.execute-L4Nmkdk$default(CellExecutor.kt:13)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evaluateUserCode-wNURfNM(ReplForJupyterImpl.kt:613)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evalExImpl(ReplForJupyterImpl.kt:471)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.access$evalExImpl(ReplForJupyterImpl.kt:143)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl$evalEx$1.invoke(ReplForJupyterImpl.kt:464)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl$evalEx$1.invoke(ReplForJupyterImpl.kt:463)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.withEvalContext(ReplForJupyterImpl.kt:444)",
"\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evalEx(ReplForJupyterImpl.kt:463)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:159)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:158)",
"\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)",
"\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedIn(IdeCompatibleMessageRequestProcessor.kt:335)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.access$withForkedIn(IdeCompatibleMessageRequestProcessor.kt:54)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$evalWithIO$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:349)",
"\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)",
"\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedErr(IdeCompatibleMessageRequestProcessor.kt:324)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.access$withForkedErr(IdeCompatibleMessageRequestProcessor.kt:54)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$evalWithIO$1.invoke(IdeCompatibleMessageRequestProcessor.kt:348)",
"\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)",
"\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedOut(IdeCompatibleMessageRequestProcessor.kt:316)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.evalWithIO(IdeCompatibleMessageRequestProcessor.kt:347)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1.invoke(IdeCompatibleMessageRequestProcessor.kt:158)",
"\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1.invoke(IdeCompatibleMessageRequestProcessor.kt:157)",
"\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$Task.execute(JupyterExecutorImpl.kt:41)",
"\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$executorThread$1.invoke(JupyterExecutorImpl.kt:83)",
"\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$executorThread$1.invoke(JupyterExecutorImpl.kt:80)",
"\tat kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)",
""
]
}
],
"execution_count": 5
},
{
"metadata": {},
"cell_type": "code",
"source": "",
"outputs": [],
"execution_count": null
}
],
"metadata": {
"kernelspec": {
"display_name": "Kotlin",
"language": "kotlin",
"name": "kotlin"
},
"language_info": {
"name": "kotlin",
"version": "1.9.23",
"mimetype": "text/x-kotlin",
"file_extension": ".kt",
"pygments_lexer": "kotlin",
"codemirror_mode": "text/x-kotlin",
"nbconvert_exporter": ""
},
"ktnbPluginMetadata": {
"projectDependencies": [
"Amethyst.amethyst.unitTest"
],
"projectLibraries": false
}
},
"nbformat": 4,
"nbformat_minor": 0
}
+2 -1
View File
@@ -58,4 +58,5 @@
}
# JSON parsing
-keep class com.vitorpamplona.quartz.** { *; }
-keep class com.vitorpamplona.quartz.** { *; }
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
@@ -39,7 +38,7 @@ class LargeDBSignatureCheck {
@Test
fun insertDatabaseSample() =
runBlocking {
val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_short.json")
val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_short.json")
val eventArray =
JsonMapper.mapper.readValue<ArrayList<Event>>(
@@ -59,7 +58,7 @@ class LargeDBSignatureCheck {
fun insertStartupDatabase() =
runBlocking {
// This file includes duplicates
val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json")
val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json")
val eventArray =
JsonMapper.mapper.readValue<ArrayList<Event>>(
@@ -25,7 +25,6 @@ import android.database.sqlite.SQLiteException
import android.util.Log
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
@@ -42,7 +41,7 @@ class LargeDBTests {
companion object {
fun getEventDB(): List<Event> {
// This file includes duplicates
val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json")
val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json")
return JsonMapper.mapper.readValue<ArrayList<Event>>(
GZIPInputStream(fullDBInputStream),
@@ -29,10 +29,7 @@ import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class OtsTest {
val resolver = OtsResolver()
@@ -75,12 +72,8 @@ class OtsTest {
val signer = NostrSignerInternal(KeyPair())
val countDownLatch = CountDownLatch(1)
val newOts = runBlocking { signer.sign(OtsEvent.build(eventId, upgraded!!)) }
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
println(newOts.toJson())
println(resolver.info(newOts.otsByteArray()))
@@ -91,15 +84,11 @@ class OtsTest {
fun createOTSEventAndVerify() {
val signer = NostrSignerInternal(KeyPair())
val countDownLatch = CountDownLatch(1)
val ots =
runBlocking {
signer.sign(OtsEvent.build(otsEvent2Digest, OtsEvent.stamp(otsEvent2Digest, resolver)))
}
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
println(ots.toJson())
println(resolver.info(ots.otsByteArray()))
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip06KeyDerivation
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Hex
@@ -36,12 +35,7 @@ import org.junit.runner.RunWith
class Bip39MnemonicsTest {
private val tests =
jacksonObjectMapper()
.readTree(
InstrumentationRegistry
.getInstrumentation()
.context.assets
.open("bip39.vectors.json"),
)
.readTree(javaClass.classLoader?.getResourceAsStream("bip39.vectors.json"))
@Test
fun toSeed() {
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip17Dm
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM
import junit.framework.TestCase.assertEquals
@@ -46,9 +45,9 @@ class AESGCMTest {
@Test
fun imageTest() {
val image =
getInstrumentation().context.assets.open("ovxxk2vz.jpg").use {
javaClass.classLoader?.getResourceAsStream("ovxxk2vz.jpg")?.use {
it.readAllBytes()
}
}!!
val decrypted = cipher.decrypt(image)
@@ -64,9 +63,9 @@ class AESGCMTest {
)
val encrypted =
getInstrumentation().context.assets.open("trouble_video").use {
javaClass.classLoader?.getResourceAsStream("trouble_video")?.use {
it.readAllBytes()
}
}!!
val decrypted = myCipher.decrypt(encrypted)

Some files were not shown because too many files have changed in this diff Show More