Merge branch 'vitorpamplona:main' into profiles-list-management

This commit is contained in:
KotlinGeekDev
2025-09-02 21:23:29 +00:00
committed by GitHub
9 changed files with 224 additions and 165 deletions
+2 -2
View File
@@ -44,8 +44,8 @@ android {
applicationId = "com.vitorpamplona.amethyst" applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger() minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 427 versionCode = 428
versionName = generateVersionName("1.02.1") versionName = generateVersionName("1.03.0")
buildConfigField "String", "RELEASE_NOTES_ID", "\"08abe267baf5d7ce14db7975866f929e2794cc23484171aef0816c60a2416597\"" buildConfigField "String", "RELEASE_NOTES_ID", "\"08abe267baf5d7ce14db7975866f929e2794cc23484171aef0816c60a2416597\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -48,6 +48,8 @@ abstract class Channel : NotesGatherer {
return new return new
} }
open fun participatingAuthors() = notes.mapNotNull { key, value -> value.author }
abstract fun toBestDisplayName(): String abstract fun toBestDisplayName(): String
open fun relays(): Set<NormalizedRelayUrl> = open fun relays(): Set<NormalizedRelayUrl> =
@@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ChannelState import com.vitorpamplona.amethyst.model.ChannelState
import com.vitorpamplona.amethyst.model.LocalCache.notes
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
@@ -69,16 +70,10 @@ fun observeChannelNoteAuthors(
.flow() .flow()
.notes.stateFlow .notes.stateFlow
.mapLatest { .mapLatest {
it.channel.notes channelToParticipatingUsers(it.channel, accountViewModel)
.mapNotNull { key, value -> value.author }
.toSet()
.toImmutableList()
}.onStart { }.onStart {
emit( emit(
baseChannel.notes channelToParticipatingUsers(baseChannel, accountViewModel),
.mapNotNull { key, value -> value.author }
.toSet()
.toImmutableList(),
) )
}.distinctUntilChanged() }.distinctUntilChanged()
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
@@ -87,6 +82,41 @@ fun observeChannelNoteAuthors(
return flow.collectAsStateWithLifecycle(persistentListOf()) return flow.collectAsStateWithLifecycle(persistentListOf())
} }
private fun channelToParticipatingUsers(
channel: Channel,
accountViewModel: AccountViewModel,
): ImmutableList<User> {
val users = mutableSetOf<User>()
channel.participatingAuthors().forEach {
users.add(it)
}
if (channel is LiveActivitiesChannel) {
val noteAuthor = channel.infoNote?.author
if (noteAuthor != null) {
users.add(noteAuthor)
}
val pKeys = channel.info?.participantKeys() ?: emptyList()
pKeys.forEach {
val u = accountViewModel.checkGetOrCreateUser(it)
if (u != null) {
users.add(u)
}
}
}
return users
.sortedWith(
compareBy(
{ !accountViewModel.isFollowing(it) },
{ it.pubkeyHex },
),
).toImmutableList()
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Composable @Composable
fun observeChannelPicture( fun observeChannelPicture(
@@ -34,7 +34,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AlternateEmail import androidx.compose.material.icons.filled.AlternateEmail
import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Block
@@ -65,7 +64,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -82,6 +80,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.painterRes
@@ -89,6 +88,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.LightRedColor import com.vitorpamplona.amethyst.ui.theme.LightRedColor
import com.vitorpamplona.amethyst.ui.theme.QuickActionPopupShadow
import com.vitorpamplona.amethyst.ui.theme.SmallestBorder
import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
@@ -231,20 +232,40 @@ private fun RenderMainPopup(
showDeleteAlertDialog: MutableState<Boolean>, showDeleteAlertDialog: MutableState<Boolean>,
showReportDialog: MutableState<Boolean>, showReportDialog: MutableState<Boolean>,
onWantsToEditDraft: () -> Unit, onWantsToEditDraft: () -> Unit,
) {
Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) {
val backgroundColor =
if (MaterialTheme.colorScheme.isLight) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondaryButtonBackground
}
Card(
modifier = QuickActionPopupShadow,
shape = SmallestBorder,
colors = CardDefaults.cardColors(containerColor = backgroundColor),
) {
CardBody(accountViewModel, note, onDismiss, showBlockAlertDialog, showDeleteAlertDialog, showReportDialog, onWantsToEditDraft)
}
}
}
@Composable
fun CardBody(
accountViewModel: AccountViewModel,
note: Note,
onDismiss: () -> Unit,
showBlockAlertDialog: MutableState<Boolean>,
showDeleteAlertDialog: MutableState<Boolean>,
showReportDialog: MutableState<Boolean>,
onWantsToEditDraft: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f) val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f)
val cardShape = RoundedCornerShape(5.dp)
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val backgroundColor =
if (MaterialTheme.colorScheme.isLight) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondaryButtonBackground
}
val showToast = { stringRes: Int -> val showToast = { stringRes: Int ->
scope.launch { scope.launch {
Toast Toast
@@ -259,157 +280,149 @@ private fun RenderMainPopup(
val isOwnNote = accountViewModel.isLoggedUser(note.author) val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) { Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Card( Row(modifier = Modifier.height(IntrinsicSize.Min)) {
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape), NoteQuickActionItem(
shape = cardShape, icon = Icons.Default.ContentCopy,
colors = CardDefaults.cardColors(containerColor = backgroundColor), label = stringRes(R.string.quick_action_copy_text),
) { ) {
Column(modifier = Modifier.width(IntrinsicSize.Min)) { accountViewModel.decrypt(note) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) { clipboardManager.setText(AnnotatedString(it))
NoteQuickActionItem( showToast(R.string.copied_note_text_to_clipboard)
icon = Icons.Default.ContentCopy, }
label = stringRes(R.string.quick_action_copy_text),
) {
accountViewModel.decrypt(note) {
clipboardManager.setText(AnnotatedString(it))
showToast(R.string.copied_note_text_to_clipboard)
}
onDismiss()
}
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.AlternateEmail,
stringRes(R.string.quick_action_copy_user_id),
) {
note.author?.let {
scope.launch {
clipboardManager.setText(AnnotatedString(it.toNostrUri()))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss() onDismiss()
} }
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.AlternateEmail,
stringRes(R.string.quick_action_copy_user_id),
) {
note.author?.let {
scope.launch {
clipboardManager.setText(AnnotatedString(it.toNostrUri()))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
}
}
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.FormatQuote,
stringRes(R.string.quick_action_copy_note_id),
) {
scope.launch {
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
}
if (!isOwnNote) {
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.Block,
stringRes(R.string.quick_action_block),
) {
if (accountViewModel.account.settings.hideBlockAlertDialog) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
} else {
showBlockAlertDialog.value = true
}
}
}
} }
HorizontalDivider( }
color = primaryLight, VerticalDivider(color = primaryLight)
) NoteQuickActionItem(
Row(modifier = Modifier.height(IntrinsicSize.Min)) { Icons.Default.FormatQuote,
if (isOwnNote) { stringRes(R.string.quick_action_copy_note_id),
NoteQuickActionItem( ) {
Icons.Default.Delete, scope.launch {
stringRes(R.string.quick_action_delete), clipboardManager.setText(AnnotatedString(note.toNostrUri()))
) { showToast(R.string.copied_note_id_to_clipboard)
if (accountViewModel.account.settings.hideDeleteRequestDialog) { onDismiss()
accountViewModel.delete(note) }
onDismiss() }
} else {
showDeleteAlertDialog.value = true
}
}
} else if (isFollowingUser) {
NoteQuickActionItem(
Icons.Default.PersonRemove,
stringRes(R.string.quick_action_unfollow),
) {
accountViewModel.unfollow(note.author!!)
onDismiss()
}
} else {
NoteQuickActionItem(
Icons.Default.PersonAdd,
stringRes(R.string.quick_action_follow),
) {
accountViewModel.follow(note.author!!)
onDismiss()
}
}
VerticalDivider(color = primaryLight) if (!isOwnNote) {
NoteQuickActionItem( VerticalDivider(color = primaryLight)
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringRes(R.string.broadcast), NoteQuickActionItem(
) { Icons.Default.Block,
accountViewModel.broadcast(note) stringRes(R.string.quick_action_block),
// showSelectTextDialog = true ) {
if (accountViewModel.account.settings.hideBlockAlertDialog) {
note.author?.let { accountViewModel.hide(it) }
onDismiss() onDismiss()
}
VerticalDivider(color = primaryLight)
if (isOwnNote && note.isDraft()) {
NoteQuickActionItem(
Icons.Default.Edit,
stringRes(R.string.edit_draft),
) {
onWantsToEditDraft()
}
} else { } else {
NoteQuickActionItem( showBlockAlertDialog.value = true
icon = Icons.Default.Share,
label = stringRes(R.string.quick_action_share),
) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(context, R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(
sendIntent,
stringRes(context, R.string.quick_action_share),
)
context.startActivity(shareIntent)
onDismiss()
}
} }
}
if (!isOwnNote) { }
VerticalDivider(color = primaryLight) }
HorizontalDivider(
NoteQuickActionItem( color = primaryLight,
Icons.Default.Report, )
stringRes(R.string.quick_action_report), Row(modifier = Modifier.height(IntrinsicSize.Min)) {
) { if (isOwnNote) {
showReportDialog.value = true NoteQuickActionItem(
} Icons.Default.Delete,
stringRes(R.string.quick_action_delete),
) {
if (accountViewModel.account.settings.hideDeleteRequestDialog) {
accountViewModel.delete(note)
onDismiss()
} else {
showDeleteAlertDialog.value = true
} }
} }
} else if (isFollowingUser) {
NoteQuickActionItem(
Icons.Default.PersonRemove,
stringRes(R.string.quick_action_unfollow),
) {
accountViewModel.unfollow(note.author!!)
onDismiss()
}
} else {
NoteQuickActionItem(
Icons.Default.PersonAdd,
stringRes(R.string.quick_action_follow),
) {
accountViewModel.follow(note.author!!)
onDismiss()
}
}
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringRes(R.string.broadcast),
) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
VerticalDivider(color = primaryLight)
if (isOwnNote && note.isDraft()) {
NoteQuickActionItem(
Icons.Default.Edit,
stringRes(R.string.edit_draft),
) {
onWantsToEditDraft()
}
} else {
NoteQuickActionItem(
icon = Icons.Default.Share,
label = stringRes(R.string.quick_action_share),
) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(context, R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(
sendIntent,
stringRes(context, R.string.quick_action_share),
)
context.startActivity(shareIntent)
onDismiss()
}
}
if (!isOwnNote) {
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.Report,
stringRes(R.string.quick_action_report),
) {
showReportDialog.value = true
}
} }
} }
} }
@@ -43,6 +43,7 @@ import androidx.compose.material3.Shapes
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -62,6 +63,7 @@ val RippleRadius45dp = 45.dp // Ripple should be +10.dp over the component size
val BottomTopHeight = Modifier.height(50.dp) val BottomTopHeight = Modifier.height(50.dp)
val TabRowHeight = Modifier val TabRowHeight = Modifier
val SmallestBorder = RoundedCornerShape(5.dp)
val SmallBorder = RoundedCornerShape(7.dp) val SmallBorder = RoundedCornerShape(7.dp)
val SmallishBorder = RoundedCornerShape(9.dp) val SmallishBorder = RoundedCornerShape(9.dp)
val QuoteBorder = RoundedCornerShape(15.dp) val QuoteBorder = RoundedCornerShape(15.dp)
@@ -360,3 +362,5 @@ val MaxWidthPaddingTop5dp = Modifier.fillMaxWidth().padding(top = 5.dp)
val VoiceHeightModifier = Modifier.fillMaxWidth().height(100.dp) val VoiceHeightModifier = Modifier.fillMaxWidth().height(100.dp)
val PaddingHorizontal12Modifier = Modifier.padding(horizontal = 12.dp) val PaddingHorizontal12Modifier = Modifier.padding(horizontal = 12.dp)
val QuickActionPopupShadow = Modifier.shadow(elevation = Size6dp, shape = SmallestBorder)
+6 -1
View File
@@ -163,7 +163,12 @@ kotlin {
} }
} }
androidUnitTest.configure { dependsOn(jvmTest.get()) } androidUnitTest.configure {
dependencies {
// Bitcoin secp256k1 bindings
implementation(libs.secp256k1.kmp.jni.jvm)
}
}
androidInstrumentedTest { androidInstrumentedTest {
dependencies { dependencies {
@@ -176,7 +176,7 @@ class MetadataEvent(
val tags = val tags =
latest.tags.builder { latest.tags.builder {
alt("User profile for ${currentMetadata.get("name").asText() ?: "Anonymous"}") alt("User profile for ${currentMetadata.get("name")?.asText() ?: "Anonymous"}")
// For https://github.com/nostr-protocol/nips/pull/1770 // For https://github.com/nostr-protocol/nips/pull/1770
currentMetadata.get(NameTag.TAG_NAME)?.asText()?.let { name(it) } ?: run { remove(NameTag.TAG_NAME) } currentMetadata.get(NameTag.TAG_NAME)?.asText()?.let { name(it) } ?: run { remove(NameTag.TAG_NAME) }
@@ -93,8 +93,10 @@ class RelayUrlNormalizer {
@OptIn(ExperimentalContracts::class) @OptIn(ExperimentalContracts::class)
fun fix(url: String): String? { fun fix(url: String): String? {
if (url.length < 3) return null if (url.length < 4) return null
if (url.length > 100) { if (url.length > 50) {
if (url.indexOf("%00") > -1) return null
// removes multiple urls in the same line // removes multiple urls in the same line
val schemeIdx = url.indexOf("://") val schemeIdx = url.indexOf("://")
val nextScheme = url.indexOf("://", schemeIdx + 3) val nextScheme = url.indexOf("://", schemeIdx + 3)
@@ -184,6 +186,7 @@ class RelayUrlNormalizer {
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
normalizedUrls.put(url, NormalizationResult.Error)
Log.w("NormalizedRelayUrl", "Rejected Error $url") Log.w("NormalizedRelayUrl", "Rejected Error $url")
null null
} }
@@ -89,6 +89,8 @@ class LiveActivitiesEvent(
fun totalParticipants() = tags.firstNotNullOfOrNull(TotalParticipantsTag::parse) fun totalParticipants() = tags.firstNotNullOfOrNull(TotalParticipantsTag::parse)
fun participantKeys(): List<HexKey> = tags.mapNotNull(ParticipantTag::parseKey)
fun participants() = tags.mapNotNull(ParticipantTag::parse) fun participants() = tags.mapNotNull(ParticipantTag::parse)
fun relays() = tags.mapNotNull(RelayListTag::parse) fun relays() = tags.mapNotNull(RelayListTag::parse)