Merge branch 'main' of https://github.com/vitorpamplona/amethyst into upstream-main

# Conflicts:
#	quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt
This commit is contained in:
KotlinGeekDev
2026-03-24 16:11:04 +01:00
20 changed files with 206 additions and 118 deletions
+1
View File
@@ -268,6 +268,7 @@ Updated translations:
- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t - Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
- Chinese by hypnotichemionus4 - Chinese by hypnotichemionus4
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 - Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
- Russian by Anton Zhao
<a id="v1.05.1"></a> <a id="v1.05.1"></a>
# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08 # [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
@@ -47,7 +47,7 @@ fun VideoViewInner(
authorName: String? = null, authorName: String? = null,
nostrUriCallback: String? = null, nostrUriCallback: String? = null,
automaticallyStartPlayback: Boolean, automaticallyStartPlayback: Boolean,
controllerVisible: MutableState<Boolean> = mutableStateOf(true), controllerVisible: MutableState<Boolean> = mutableStateOf(false),
onZoom: (() -> Unit)? = null, onZoom: (() -> Unit)? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -30,7 +31,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -103,21 +106,43 @@ private fun HorizontalLinearProgressIndicator(
scrubberColor: Color = playedColor, scrubberColor: Color = playedColor,
rectHeightDp: Dp = 4.dp, rectHeightDp: Dp = 4.dp,
) { ) {
var isDragging by remember { mutableStateOf(false) }
var dragProgress by remember { mutableFloatStateOf(0f) }
Canvas( Canvas(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = rectHeightDp * 2.5f) .padding(horizontal = rectHeightDp * 2.5f)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures { offset -> detectTapGestures { offset ->
// Capture the exact position
onSeek(offset.x / this.size.width.toFloat()) onSeek(offset.x / this.size.width.toFloat())
} }
}.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
isDragging = true
dragProgress = (offset.x / this.size.width.toFloat()).coerceIn(0f, 1f)
},
onDrag = { change, _ ->
change.consume()
dragProgress = (change.position.x / this.size.width.toFloat()).coerceIn(0f, 1f)
},
onDragEnd = {
onSeek(dragProgress)
isDragging = false
},
onDragCancel = {
isDragging = false
},
)
}.padding(vertical = rectHeightDp * 2) }.padding(vertical = rectHeightDp * 2)
.height(rectHeightDp) .height(rectHeightDp)
.onSizeChanged { (w, _) -> onLayoutWidthChanged(w) }, .onSizeChanged { (w, _) -> onLayoutWidthChanged(w) },
) { ) {
val positionX = (currentPositionProgress() * size.width).coerceAtLeast(0f) val displayProgress = if (isDragging) dragProgress else currentPositionProgress()
val positionX = (displayProgress * size.width).coerceAtLeast(0f)
val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f) val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f)
val scrubberRadius = if (isDragging) size.height * 3f else size.height * 2f
drawRect(unplayedColor, size = Size(size.width, size.height)) drawRect(unplayedColor, size = Size(size.width, size.height))
drawRect(bufferedColor, size = Size(bufferX, size.height)) drawRect(bufferedColor, size = Size(bufferX, size.height))
@@ -125,7 +150,7 @@ private fun HorizontalLinearProgressIndicator(
drawCircle( drawCircle(
color = scrubberColor, color = scrubberColor,
radius = size.height * 2f, radius = scrubberRadius,
center = Offset(x = positionX, y = size.height / 2.0f), center = Offset(x = positionX, y = size.height / 2.0f),
) )
} }
@@ -124,7 +124,6 @@ import okhttp3.coroutines.executeAsync
import okio.sink import okio.sink
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import kotlin.time.Duration.Companion.seconds
// Delay before cleaning up shared video temp files. // Delay before cleaning up shared video temp files.
// Allows time for receiving app to copy the file after user confirms share. // Allows time for receiving app to copy the file after user confirms share.
@@ -224,12 +223,7 @@ fun TwoSecondController(
content: BaseMediaContent, content: BaseMediaContent,
inner: @Composable (controllerVisible: MutableState<Boolean>) -> Unit, inner: @Composable (controllerVisible: MutableState<Boolean>) -> Unit,
) { ) {
val controllerVisible = remember(content) { mutableStateOf(true) } val controllerVisible = remember(content) { mutableStateOf(false) }
LaunchedEffect(content) {
delay(2.seconds)
controllerVisible.value = false
}
inner(controllerVisible) inner(controllerVisible)
} }
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.creators.polls
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -32,6 +34,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -47,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
@Composable @Composable
@@ -55,6 +59,13 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
PollTypeSelector(
selectedType = postViewModel.pollType,
onTypeSelected = { postViewModel.pollType = it },
)
Spacer(Modifier.height(4.dp))
optionsList.forEach { option -> optionsList.forEach { option ->
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -118,6 +129,27 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) {
} }
} }
@Composable
fun PollTypeSelector(
selectedType: PollType,
onTypeSelected: (PollType) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = selectedType == PollType.SINGLE_CHOICE,
onClick = { onTypeSelected(PollType.SINGLE_CHOICE) },
label = { Text(stringRes(R.string.poll_single_choice)) },
)
FilterChip(
selected = selectedType == PollType.MULTI_CHOICE,
onClick = { onTypeSelected(PollType.MULTI_CHOICE) },
label = { Text(stringRes(R.string.poll_multiple_choice)) },
)
}
}
@SuppressLint("ViewModelConstructorInComposable") @SuppressLint("ViewModelConstructorInComposable")
@Preview @Preview
@Composable @Composable
@@ -30,23 +30,33 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.WatchAndDisplayNip05Row
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.nip05
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -145,3 +155,51 @@ fun UserLine(
}, },
) )
} }
@Composable
private fun WatchAndDisplayNip05Row(
user: User,
accountViewModel: AccountViewModel,
) {
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
when (val nip05State = nip05StateMetadata) {
is Nip05State.Exists -> {
NonClickableObserveAndDisplayNIP05(nip05State, accountViewModel)
}
else -> {
Text(
text = user.pubkeyDisplayHex(),
fontSize = Font14SP,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun NonClickableObserveAndDisplayNIP05(
nip05State: Nip05State.Exists,
accountViewModel: AccountViewModel,
) {
if (nip05State.nip05.name != "_") {
Text(
text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) },
fontSize = Font14SP,
color = MaterialTheme.colorScheme.nip05,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel)
Text(
text = nip05State.nip05.domain,
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP),
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
@@ -412,8 +412,15 @@ private fun RenderResults(
resultContent: @Composable RowScope.(user: User) -> Unit, resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit),
) { ) {
val showGallery =
remember {
card.options.all {
it.label.length < 50
}
}
card.options.forEach { pollItem -> card.options.forEach { pollItem ->
RenderClosedItem(pollItem, resultContent) { RenderClosedItem(pollItem, showGallery, resultContent) {
labelContent(pollItem.code, pollItem.label) labelContent(pollItem.code, pollItem.label)
} }
} }
@@ -422,18 +429,20 @@ private fun RenderResults(
@Composable @Composable
private fun RenderClosedItem( private fun RenderClosedItem(
item: PollItemCard, item: PollItemCard,
showGallery: Boolean,
resultContent: @Composable RowScope.(user: User) -> Unit, resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable ColumnScope.() -> Unit, labelContent: @Composable ColumnScope.() -> Unit,
) { ) {
val tally by item.results.collectAsStateWithLifecycle(item.currentResults()) val tally by item.results.collectAsStateWithLifecycle(item.currentResults())
RenderClosedItem(tally, item.label, resultContent, labelContent) RenderClosedItem(tally, item.label, showGallery, resultContent, labelContent)
} }
@Composable @Composable
private fun RenderClosedItem( private fun RenderClosedItem(
tally: TallyResults, tally: TallyResults,
label: String, label: String,
showGallery: Boolean,
resultContent: @Composable RowScope.(user: User) -> Unit, resultContent: @Composable RowScope.(user: User) -> Unit,
labelContent: @Composable ColumnScope.() -> Unit, labelContent: @Composable ColumnScope.() -> Unit,
) { ) {
@@ -496,7 +505,7 @@ private fun RenderClosedItem(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (label.length < 30) { if (showGallery) {
UserGallery(tally, resultContent) UserGallery(tally, resultContent)
} }
@@ -106,7 +106,7 @@ fun ArticleList(
contentPadding = FeedPadding, contentPadding = FeedPadding,
state = listState, state = listState,
) { ) {
itemsIndexed(articles, key = { _, item -> item.toNAddr() }) { _, item -> itemsIndexed(articles, key = { _, item -> item.address }) { _, item ->
NoteCompose( NoteCompose(
baseNote = item, baseNote = item,
modifier = Modifier.animateContentSize(), modifier = Modifier.animateContentSize(),
@@ -117,6 +117,7 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.alt import com.vitorpamplona.quartz.nip94FileMetadata.alt
@@ -238,6 +239,7 @@ open class ShortNotePostViewModel :
var canUsePoll by mutableStateOf(false) var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false)
var pollOptions: SnapshotStateMap<Int, OptionTag> = newStateMapPollOptions() var pollOptions: SnapshotStateMap<Int, OptionTag> = newStateMapPollOptions()
var pollType by mutableStateOf(PollType.SINGLE_CHOICE)
var closedAt by mutableLongStateOf(TimeUtils.oneDayAhead()) var closedAt by mutableLongStateOf(TimeUtils.oneDayAhead())
// ZapPolls // ZapPolls
@@ -336,6 +338,7 @@ open class ShortNotePostViewModel :
val currentMentions = val currentMentions =
(replyNote.event as? TextNoteEvent) (replyNote.event as? TextNoteEvent)
?.mentions() ?.mentions()
?.toSet()
?.map { LocalCache.getOrCreateUser(it.pubKey) } ?.map { LocalCache.getOrCreateUser(it.pubKey) }
?: emptyList() ?: emptyList()
@@ -490,8 +493,8 @@ open class ShortNotePostViewModel :
} }
pTags = pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.getOrCreateUser(it[1]) LocalCache.checkGetOrCreateUser(it[1])
} }
draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach { draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach {
@@ -576,8 +579,8 @@ open class ShortNotePostViewModel :
} }
pTags = pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.getOrCreateUser(it[1]) LocalCache.checkGetOrCreateUser(it[1])
} }
canUsePoll = originalNote == null canUsePoll = originalNote == null
@@ -596,6 +599,7 @@ open class ShortNotePostViewModel :
pollOptions[index] = tag pollOptions[index] = tag
} }
pollType = draftEvent.pollType() ?: PollType.SINGLE_CHOICE
closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead() closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead()
message = TextFieldValue(draftEvent.content) message = TextFieldValue(draftEvent.content)
@@ -647,8 +651,8 @@ open class ShortNotePostViewModel :
} }
pTags = pTags =
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
LocalCache.getOrCreateUser(it[1]) LocalCache.checkGetOrCreateUser(it[1])
} }
canUsePoll = originalNote == null canUsePoll = originalNote == null
@@ -828,7 +832,7 @@ open class ShortNotePostViewModel :
accountViewModel.account.nip65RelayList.outboxFlow.value accountViewModel.account.nip65RelayList.outboxFlow.value
.toList() .toList()
PollEvent.build(tagger.message, options, closedAt, relays) { PollEvent.build(tagger.message, options, closedAt, relays, pollType) {
pTags(tagger.directMentionsUsers.map { it.toPTag() }) pTags(tagger.directMentionsUsers.map { it.toPTag() })
quotes(quotes) quotes(quotes)
hashtags(findHashtags(tagger.message)) hashtags(findHashtags(tagger.message))
@@ -1054,6 +1058,7 @@ open class ShortNotePostViewModel :
wantsPoll = false wantsPoll = false
pollOptions = newStateMapPollOptions() pollOptions = newStateMapPollOptions()
pollType = PollType.SINGLE_CHOICE
closedAt = TimeUtils.oneDayAhead() closedAt = TimeUtils.oneDayAhead()
wantsZapPoll = false wantsZapPoll = false
@@ -125,6 +125,7 @@ fun DisplayFollowList(
val contactsState by observeNoteEventAndMap<ContactListEvent, ImmutableList<User>?>(contactListNote, accountViewModel) { contactList -> val contactsState by observeNoteEventAndMap<ContactListEvent, ImmutableList<User>?>(contactListNote, accountViewModel) { contactList ->
contactList contactList
?.unverifiedFollowKeySet() ?.unverifiedFollowKeySet()
?.toSet()
?.mapNotNull { ?.mapNotNull {
accountViewModel.checkGetOrCreateUser(it) accountViewModel.checkGetOrCreateUser(it)
}?.toPersistentList() }?.toPersistentList()
@@ -47,7 +47,7 @@ val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue val NIP05Verified = Color.Blue
val Nip05EmailColor = Color(0xFFb198ec) val Nip05EmailColor = Color(0xFFb198ec)
val Nip05EmailColorDark = Color(0xFF6e5490) val Nip05EmailColorDark = Color(0xFF765AA2)
val Nip05EmailColorLight = Color(0xFFa770f3) val Nip05EmailColorLight = Color(0xFFa770f3)
val DarkerGreen = Color.Green.copy(alpha = 0.32f) val DarkerGreen = Color.Green.copy(alpha = 0.32f)
+2
View File
@@ -483,6 +483,8 @@
<string name="poll_zap_value_max">Zap maximum</string> <string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Consensus</string> <string name="poll_consensus_threshold">Consensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string> <string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_single_choice">Single choice</string>
<string name="poll_multiple_choice">Multiple choice</string>
<string name="poll_closing_date_time">Poll Closing Date &amp; Time</string> <string name="poll_closing_date_time">Poll Closing Date &amp; Time</string>
<string name="poll_closing_in">Poll closes in %1$s</string> <string name="poll_closing_in">Poll closes in %1$s</string>
<string name="poll_closing_time">Close after</string> <string name="poll_closing_time">Close after</string>
@@ -72,6 +72,15 @@ class TagArrayBuilder<T : IEvent> {
return this return this
} }
fun addUniqueValueIfNew(tag: Array<String>): TagArrayBuilder<T> {
if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this
val list = tagList.getOrPut(tag[0], ::mutableListOf)
if (list.none { it.valueOrNull() == tag[1] }) {
list.add(tag)
}
return this
}
fun addAll(tag: List<Array<String>>): TagArrayBuilder<T> { fun addAll(tag: List<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::add) tag.forEach(::add)
return this return this
@@ -82,6 +91,16 @@ class TagArrayBuilder<T : IEvent> {
return this return this
} }
fun addAllUnique(tag: Array<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::addUnique)
return this
}
fun addAllUniqueValueIfNew(tag: List<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::addUniqueValueIfNew)
return this
}
fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray() fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray()
fun build() = toTypedArray() fun build() = toTypedArray()
@@ -52,6 +52,12 @@ class ReferenceTag {
fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url)) fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url))
fun assemble(urls: List<String>): List<Array<String>> = urls.mapTo(HashSet()) { HttpUrlFormatter.normalize(it) }.map { arrayOf(TAG_NAME, it) } fun assemble(urls: List<String>): List<Array<String>> =
urls
.mapTo(HashSet()) {
HttpUrlFormatter.normalize(it)
}.map {
arrayOf(TAG_NAME, it)
}
} }
} }
@@ -20,15 +20,25 @@
*/ */
package com.vitorpamplona.quartz.nip10Notes.content package com.vitorpamplona.quartz.nip10Notes.content
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.fastFindURLs import com.vitorpamplona.quartz.utils.startsWithAny
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
fun findURLs(text: String) = fastFindURLs(text) val rejectSchemes =
listOf(
DualCase("ftp:"),
DualCase("ftps:"),
DualCase("ws:"),
DualCase("wss:"),
DualCase("nostr:"),
DualCase("blossom:"),
)
fun buildUrlRefs(urls: List<String>): List<Array<String>> = fun findURLs(text: String) =
urls UrlDetector(text).detect().mapNotNull {
.mapTo(HashSet()) { url -> if (it.originalUrl.startsWithAny(rejectSchemes)) {
HttpUrlFormatter.normalize(url) null
}.map { } else {
arrayOf("r", it) it.originalUrl
} }
}
@@ -31,18 +31,18 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
fun <T : Event> TagArrayBuilder<T>.quote(tag: QTag) = add(tag.toTagArray()) fun <T : Event> TagArrayBuilder<T>.quote(tag: QTag) = addUniqueValueIfNew(tag.toTagArray())
fun <T : Event> TagArrayBuilder<T>.quotes(tag: List<QTag>) = addAll(tag.map { it.toTagArray() }) fun <T : Event> TagArrayBuilder<T>.quotes(tag: List<QTag>) = addAllUniqueValueIfNew(tag.map { it.toTagArray() })
fun <T : Event> TagArrayBuilder<T>.quote(entity: Entity) = fun <T : Event> TagArrayBuilder<T>.quote(entity: Entity) =
when (entity) { when (entity) {
is NNote -> add(entity.toQuoteTagArray()) is NNote -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NEvent -> add(entity.toQuoteTagArray()) is NEvent -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NAddress -> add(entity.toQuoteTagArray()) is NAddress -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NEmbed -> add(entity.toQuoteTagArray()) is NEmbed -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NPub -> add(entity.toQuoteTagArray()) is NPub -> addUniqueValueIfNew(entity.toQuoteTagArray())
is NProfile -> add(entity.toQuoteTagArray()) is NProfile -> addUniqueValueIfNew(entity.toQuoteTagArray())
else -> this else -> this
} }
@@ -1,23 +0,0 @@
/*
* 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.quartz.utils
expect fun fastFindURLs(text: String): List<String>
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip10Notes.urls package com.vitorpamplona.quartz.nip10Notes.urls
import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.utils.fastFindURLs
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContains import kotlin.test.assertContains
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -31,7 +30,7 @@ class UrlsDetectorTest {
@Test @Test
fun detectUrlNumber() { fun detectUrlNumber() {
val detectedLinks = fastFindURLs(testSentence) val detectedLinks = findURLs(testSentence)
assertEquals(2, detectedLinks.size) assertEquals(2, detectedLinks.size)
} }
@@ -48,7 +47,7 @@ class UrlsDetectorTest {
*/ */
@Test @Test
fun doesNotCrashOnJapaneseText() { fun doesNotCrashOnJapaneseText() {
val detectedLinks = fastFindURLs("今北産業") val detectedLinks = findURLs("今北産業")
assertEquals(0, detectedLinks.size) assertEquals(0, detectedLinks.size)
} }
} }
@@ -1,25 +0,0 @@
/*
* 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.quartz.utils
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
actual fun fastFindURLs(text: String): List<String> = UrlDetector(text).detect().map { it.originalUrl }
@@ -1,25 +0,0 @@
/*
* 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.quartz.utils
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
actual fun fastFindURLs(text: String): List<String> = UrlDetector(text).detect().map { it.originalUrl }