Merge branch 'main' into new_package_relay

This commit is contained in:
greenart7c3
2024-06-24 11:16:03 -03:00
33 changed files with 772 additions and 489 deletions
@@ -178,9 +178,7 @@ object Client : RelayPool.Listener {
subscriptions = subscriptions.minus(subscriptionId) subscriptions = subscriptions.minus(subscriptionId)
} }
fun isActive(subscriptionId: String): Boolean { fun isActive(subscriptionId: String): Boolean = subscriptions.contains(subscriptionId)
return subscriptions.contains(subscriptionId)
}
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onEvent( override fun onEvent(
@@ -204,9 +202,9 @@ object Client : RelayPool.Listener {
) { ) {
// Releases the Web thread for the new payload. // Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly. // May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) { // GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) } listeners.forEach { it.onRelayStateChange(type, relay, channel) }
} // }
} }
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
@@ -281,21 +279,15 @@ object Client : RelayPool.Listener {
listeners = listeners.plus(listener) listeners = listeners.plus(listener)
} }
fun isSubscribed(listener: Listener): Boolean { fun isSubscribed(listener: Listener): Boolean = listeners.contains(listener)
return listeners.contains(listener)
}
fun unsubscribe(listener: Listener) { fun unsubscribe(listener: Listener) {
listeners = listeners.minus(listener) listeners = listeners.minus(listener)
} }
fun allSubscriptions(): Map<String, List<TypedFilter>> { fun allSubscriptions(): Map<String, List<TypedFilter>> = subscriptions
return subscriptions
}
fun getSubscriptionFilters(subId: String): List<TypedFilter> { fun getSubscriptionFilters(subId: String): List<TypedFilter> = subscriptions[subId] ?: emptyList()
return subscriptions[subId] ?: emptyList()
}
abstract class Listener { abstract class Listener {
/** A new message was received */ /** A new message was received */
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 34 targetSdk 34
versionCode 379 versionCode 381
versionName "0.88.2" versionName "0.88.4"
buildConfigField "String", "RELEASE_NOTES_ID", "\"2a34cbadd03212c8162e1ff896ba12641821088a2ec8d5e40d54aa80c0510800\"" buildConfigField "String", "RELEASE_NOTES_ID", "\"2a34cbadd03212c8162e1ff896ba12641821088a2ec8d5e40d54aa80c0510800\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -328,10 +328,10 @@ object LocalPreferences {
) )
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion) putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion)
if (account.showSensitiveContent == null) { if (account.showSensitiveContent.value == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT) remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else { } else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!) putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent.value!!)
} }
putString( putString(
@@ -597,7 +597,7 @@ object LocalPreferences {
backupContactList = latestContactList, backupContactList = latestContactList,
proxy = proxy, proxy = proxy,
proxyPort = proxyPort, proxyPort = proxyPort,
showSensitiveContent = showSensitiveContent, showSensitiveContent = MutableStateFlow(showSensitiveContent),
warnAboutPostsWithReports = warnAboutReports, warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam, filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute, lastReadPerRoute = lastReadPerRoute,
@@ -26,7 +26,6 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap import androidx.lifecycle.switchMap
@@ -195,7 +194,7 @@ class Account(
var backupContactList: ContactListEvent? = null, var backupContactList: ContactListEvent? = null,
var proxy: Proxy? = null, var proxy: Proxy? = null,
var proxyPort: Int = 9050, var proxyPort: Int = 9050,
var showSensitiveContent: Boolean? = null, var showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
var warnAboutPostsWithReports: Boolean = true, var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true, var filterSpamFromStrangers: Boolean = true,
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(), var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
@@ -203,7 +202,7 @@ class Account(
var pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()), var pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
val scope: CoroutineScope = Amethyst.instance.applicationIOScope, val scope: CoroutineScope = Amethyst.instance.applicationIOScope,
) { ) {
var transientHiddenUsers: Set<String> = setOf() var transientHiddenUsers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf())
data class PaymentRequest( data class PaymentRequest(
val relayUrl: String, val relayUrl: String,
@@ -239,6 +238,8 @@ class Account(
getPrivateOutboxRelayListFlow(), getPrivateOutboxRelayListFlow(),
userProfile().flow().relays.stateFlow, userProfile().flow().relays.stateFlow,
) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile -> ) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile ->
checkNotInMainThread()
val baseRelaySet = activeRelays() ?: convertLocalRelays() val baseRelaySet = activeRelays() ?: convertLocalRelays()
val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
val searchRelaySet = (searchRelayList.note.event as? SearchRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: Constants.defaultSearchRelaySet val searchRelaySet = (searchRelayList.note.event as? SearchRelayListEvent)?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: Constants.defaultSearchRelaySet
@@ -395,6 +396,7 @@ class Account(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<LiveFollowLists> = val liveKind3FollowsFlow: Flow<LiveFollowLists> =
userProfile().flow().follows.stateFlow.transformLatest { userProfile().flow().follows.stateFlow.transformLatest {
checkNotInMainThread()
emit( emit(
LiveFollowLists( LiveFollowLists(
it.user.cachedFollowingKeySet(), it.user.cachedFollowingKeySet(),
@@ -431,6 +433,7 @@ class Account(
peopleListFollowsSource: Flow<ListNameNotePair>, peopleListFollowsSource: Flow<ListNameNotePair>,
): Flow<LiveFollowLists?> = ): Flow<LiveFollowLists?> =
combineTransform(kind3FollowsSource, peopleListFollowsSource) { kind3Follows, peopleListFollows -> combineTransform(kind3FollowsSource, peopleListFollowsSource) { kind3Follows, peopleListFollows ->
checkNotInMainThread()
if (peopleListFollows.listName == GLOBAL_FOLLOWS) { if (peopleListFollows.listName == GLOBAL_FOLLOWS) {
emit(null) emit(null)
} else if (peopleListFollows.listName == KIND3_FOLLOWS) { } else if (peopleListFollows.listName == KIND3_FOLLOWS) {
@@ -537,10 +540,11 @@ class Account(
val flowHiddenUsers: StateFlow<LiveHiddenUsers> by lazy { val flowHiddenUsers: StateFlow<LiveHiddenUsers> by lazy {
combineTransform( combineTransform(
live.asFlow(), transientHiddenUsers,
showSensitiveContent,
getBlockListNote().flow().metadata.stateFlow, getBlockListNote().flow().metadata.stateFlow,
getMuteListNote().flow().metadata.stateFlow, getMuteListNote().flow().metadata.stateFlow,
) { localLive, blockList, muteList -> ) { transientHiddenUsers, showSensitiveContent, blockList, muteList ->
checkNotInMainThread() checkNotInMainThread()
val resultBlockList = val resultBlockList =
@@ -569,8 +573,8 @@ class Account(
LiveHiddenUsers( LiveHiddenUsers(
hiddenUsers = (resultBlockList.users + resultMuteList.users), hiddenUsers = (resultBlockList.users + resultMuteList.users),
hiddenWords = hiddenWords, hiddenWords = hiddenWords,
spammers = localLive.account.transientHiddenUsers, spammers = transientHiddenUsers,
showSensitiveContent = localLive.account.showSensitiveContent, showSensitiveContent = showSensitiveContent,
), ),
) )
}.stateIn( }.stateIn(
@@ -579,8 +583,8 @@ class Account(
LiveHiddenUsers( LiveHiddenUsers(
hiddenUsers = setOf(), hiddenUsers = setOf(),
hiddenWords = setOf(), hiddenWords = setOf(),
spammers = transientHiddenUsers, spammers = transientHiddenUsers.value,
showSensitiveContent = showSensitiveContent, showSensitiveContent = showSensitiveContent.value,
), ),
) )
} }
@@ -633,7 +637,9 @@ class Account(
filterSpamFromStrangers = filterSpam filterSpamFromStrangers = filterSpam
LocalCache.antiSpam.active = filterSpamFromStrangers LocalCache.antiSpam.active = filterSpamFromStrangers
if (!filterSpamFromStrangers) { if (!filterSpamFromStrangers) {
transientHiddenUsers = setOf() transientHiddenUsers.update {
emptySet()
}
} }
live.invalidateData() live.invalidateData()
saveable.invalidateData() saveable.invalidateData()
@@ -2414,7 +2420,9 @@ class Account(
} }
} }
transientHiddenUsers = (transientHiddenUsers - pubkeyHex) transientHiddenUsers.update {
it - pubkeyHex
}
live.invalidateData() live.invalidateData()
saveable.invalidateData() saveable.invalidateData()
} }
@@ -2926,7 +2934,9 @@ class Account(
} }
fun updateShowSensitiveContent(show: Boolean?) { fun updateShowSensitiveContent(show: Boolean?) {
showSensitiveContent = show showSensitiveContent.update {
show
}
saveable.invalidateData() saveable.invalidateData()
live.invalidateData() live.invalidateData()
} }
@@ -2965,10 +2975,12 @@ class Account(
// imports transient blocks due to spam. // imports transient blocks due to spam.
LocalCache.antiSpam.liveSpam.observeForever { LocalCache.antiSpam.liveSpam.observeForever {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
it.cache.spamMessages.snapshot().values.forEach { it.cache.spamMessages.snapshot().values.forEach { spammer ->
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { if (spammer.pubkeyHex !in transientHiddenUsers.value && spammer.duplicatedMessages.size >= 5) {
if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) { if (spammer.pubkeyHex != userProfile().pubkeyHex && spammer.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = (transientHiddenUsers + it.pubkeyHex) transientHiddenUsers.update {
it + spammer.pubkeyHex
}
live.invalidateData() live.invalidateData()
} }
} }
@@ -241,33 +241,25 @@ object LocalCache {
return users.get(key) return users.get(key)
} }
fun getAddressableNoteIfExists(key: String): AddressableNote? { fun getAddressableNoteIfExists(key: String): AddressableNote? = addressables.get(key)
return addressables.get(key)
}
fun getNoteIfExists(key: String): Note? { fun getNoteIfExists(key: String): Note? = addressables.get(key) ?: notes.get(key)
return addressables.get(key) ?: notes.get(key)
}
fun getChannelIfExists(key: String): Channel? { fun getChannelIfExists(key: String): Channel? = channels.get(key)
return channels.get(key)
}
fun getNoteIfExists(event: Event): Note? { fun getNoteIfExists(event: Event): Note? =
return if (event is AddressableEvent) { if (event is AddressableEvent) {
getAddressableNoteIfExists(event.addressTag()) getAddressableNoteIfExists(event.addressTag())
} else { } else {
getNoteIfExists(event.id) getNoteIfExists(event.id)
} }
}
fun getOrCreateNote(event: Event): Note { fun getOrCreateNote(event: Event): Note =
return if (event is AddressableEvent) { if (event is AddressableEvent) {
getOrCreateAddressableNote(event.address()) getOrCreateAddressableNote(event.address())
} else { } else {
getOrCreateNote(event.id) getOrCreateNote(event.id)
} }
}
fun checkGetOrCreateNote(key: String): Note? { fun checkGetOrCreateNote(key: String): Note? {
checkNotInMainThread() checkNotInMainThread()
@@ -348,8 +340,8 @@ object LocalCache {
return HexValidator.isHex(key) return HexValidator.isHex(key)
} }
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? { fun checkGetOrCreateAddressableNote(key: String): AddressableNote? =
return try { try {
val addr = ATag.parse(key, null) // relay doesn't matter for the index. val addr = ATag.parse(key, null) // relay doesn't matter for the index.
if (addr != null) { if (addr != null) {
getOrCreateAddressableNote(addr) getOrCreateAddressableNote(addr)
@@ -360,7 +352,6 @@ object LocalCache {
Log.e("LocalCache", "Invalid Key to create channel: $key", e) Log.e("LocalCache", "Invalid Key to create channel: $key", e)
null null
} }
}
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote { fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
// checkNotInMainThread() // checkNotInMainThread()
@@ -395,6 +386,10 @@ object LocalCache {
val newUserMetadata = event.contactMetaData() val newUserMetadata = event.contactMetaData()
if (newUserMetadata != null) { if (newUserMetadata != null) {
oldUser.updateUserInfo(newUserMetadata, event) oldUser.updateUserInfo(newUserMetadata, event)
if (relay != null) {
oldUser.addRelayBeingUsed(relay, event.createdAt)
oldUser.latestMetadataRelay = relay.url
}
} }
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex()} ${oldUser.toBestDisplayName()} from ${relay?.url}") // Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex()} ${oldUser.toBestDisplayName()} from ${relay?.url}")
} else { } else {
@@ -428,11 +423,11 @@ object LocalCache {
} }
} }
fun formattedDateTime(timestamp: Long): String { fun formattedDateTime(timestamp: Long): String =
return Instant.ofEpochSecond(timestamp) Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
}
fun consume( fun consume(
event: TextNoteEvent, event: TextNoteEvent,
@@ -755,8 +750,8 @@ object LocalCache {
} }
} }
fun computeReplyTo(event: Event): List<Note> { fun computeReplyTo(event: Event): List<Note> =
return when (event) { when (event) {
is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is WikiNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is WikiNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
@@ -805,7 +800,6 @@ object LocalCache {
else -> emptyList<Note>() else -> emptyList<Note>()
} }
}
fun consume( fun consume(
event: PollNoteEvent, event: PollNoteEvent,
@@ -1218,7 +1212,8 @@ object LocalCache {
if (deletionIndex.add(event)) { if (deletionIndex.add(event)) {
var deletedAtLeastOne = false var deletedAtLeastOne = false
event.deleteEvents() event
.deleteEvents()
.mapNotNull { getNoteIfExists(it) } .mapNotNull { getNoteIfExists(it) }
.forEach { deleteNote -> .forEach { deleteNote ->
// must be the same author // must be the same author
@@ -2030,16 +2025,16 @@ object LocalCache {
suspend fun findStatusesForUser(user: User): ImmutableList<AddressableNote> { suspend fun findStatusesForUser(user: User): ImmutableList<AddressableNote> {
checkNotInMainThread() checkNotInMainThread()
return addressables.filter { _, it -> return addressables
val noteEvent = it.event .filter { _, it ->
( val noteEvent = it.event
noteEvent is StatusEvent && (
noteEvent.pubKey == user.pubkeyHex && noteEvent is StatusEvent &&
!noteEvent.isExpired() && noteEvent.pubKey == user.pubkeyHex &&
noteEvent.content.isNotBlank() !noteEvent.isExpired() &&
) noteEvent.content.isNotBlank()
} )
.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex })) }.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex }))
.reversed() .reversed()
.toImmutableList() .toImmutableList()
} }
@@ -2066,9 +2061,7 @@ object LocalCache {
val modificationCache = LruCache<HexKey, List<Note>>(20) val modificationCache = LruCache<HexKey, List<Note>>(20)
fun cachedModificationEventsForNote(note: Note): List<Note>? { fun cachedModificationEventsForNote(note: Note): List<Note>? = modificationCache[note.idHex]
return modificationCache[note.idHex]
}
suspend fun findLatestModificationForNote(note: Note): List<Note> { suspend fun findLatestModificationForNote(note: Note): List<Note> {
checkNotInMainThread() checkNotInMainThread()
@@ -2082,11 +2075,12 @@ object LocalCache {
val time = TimeUtils.now() val time = TimeUtils.now()
val newNotes = val newNotes =
notes.filter { _, item -> notes
val noteEvent = item.event .filter { _, item ->
val noteEvent = item.event
noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) }.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
modificationCache.put(note.idHex, newNotes) modificationCache.put(note.idHex, newNotes)
@@ -2210,9 +2204,11 @@ object LocalCache {
note.event is GenericRepostEvent note.event is GenericRepostEvent
) && ) &&
note.replyTo?.any { it.liveSet?.isInUse() == true } != true && note.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
note.liveSet?.isInUse() != true && // don't delete if observing. note.liveSet?.isInUse() != true &&
// don't delete if observing.
note.author?.pubkeyHex !in note.author?.pubkeyHex !in
accounts && // don't delete if it is the logged in account accounts &&
// don't delete if it is the logged in account
note.event?.isTaggedUsers(accounts) != note.event?.isTaggedUsers(accounts) !=
true // don't delete if it's a notification to the logged in user true // don't delete if it's a notification to the logged in user
} }
@@ -2308,8 +2304,7 @@ object LocalCache {
?.hiddenUsers ?.hiddenUsers
?.map { userHex -> ?.map { userHex ->
(notes.filter { _, it -> it.event?.pubKey() == userHex } + addressables.filter { _, it -> it.event?.pubKey() == userHex }).toSet() (notes.filter { _, it -> it.event?.pubKey() == userHex } + addressables.filter { _, it -> it.event?.pubKey() == userHex }).toSet()
} }?.flatten()
?.flatten()
?: emptyList() ?: emptyList()
toBeRemoved.forEach { toBeRemoved.forEach {
@@ -2596,8 +2591,8 @@ object LocalCache {
} }
} }
fun hasConsumed(notificationEvent: Event): Boolean { fun hasConsumed(notificationEvent: Event): Boolean =
return if (notificationEvent is AddressableEvent) { if (notificationEvent is AddressableEvent) {
val note = addressables.get(notificationEvent.addressTag()) val note = addressables.get(notificationEvent.addressTag())
val noteEvent = note?.event val noteEvent = note?.event
noteEvent != null && notificationEvent.createdAt <= noteEvent.createdAt() noteEvent != null && notificationEvent.createdAt <= noteEvent.createdAt()
@@ -2605,7 +2600,6 @@ object LocalCache {
val note = notes.get(notificationEvent.id) val note = notes.get(notificationEvent.id)
note?.event != null note?.event != null
} }
}
} }
@Stable @Stable
@@ -2617,8 +2611,7 @@ class LocalCacheLiveData {
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO) private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
fun invalidateData(newNote: Note) { fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundler.invalidateList(newNote) { bundledNewNotes ->
bundledNewNotes ->
_newEventBundles.emit(bundledNewNotes) _newEventBundles.emit(bundledNewNotes)
} }
} }
@@ -62,7 +62,7 @@ enum class ConnectivityType(
val resourceId: Int, val resourceId: Int,
) { ) {
ALWAYS(null, 0, R.string.connectivity_type_always), ALWAYS(null, 0, R.string.connectivity_type_always),
WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only), WIFI_ONLY(true, 1, R.string.connectivity_type_unmetered_wifi_only),
NEVER(false, 2, R.string.connectivity_type_never), NEVER(false, 2, R.string.connectivity_type_never),
} }
@@ -34,7 +34,9 @@ import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Lud06 import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.ContactListEvent
@@ -49,10 +51,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal import java.math.BigDecimal
@Stable @Stable
class User(val pubkeyHex: String) { class User(
val pubkeyHex: String,
) {
var info: UserMetadata? = null var info: UserMetadata? = null
var latestMetadata: MetadataEvent? = null var latestMetadata: MetadataEvent? = null
var latestMetadataRelay: String? = null
var latestContactList: ContactListEvent? = null var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null var latestBookmarkList: BookmarkListEvent? = null
@@ -76,7 +81,16 @@ class User(val pubkeyHex: String) {
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
fun toNostrUri() = "nostr:${pubkeyNpub()}" fun toNProfile(): String {
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent)?.writeRelays()
return Nip19Bech32.createNProfile(
pubkeyHex,
relayList?.take(3) ?: listOfNotNull(latestMetadataRelay),
)
}
fun toNostrUri() = "nostr:${toNProfile()}"
override fun toString(): String = pubkeyHex override fun toString(): String = pubkeyHex
@@ -96,17 +110,11 @@ class User(val pubkeyHex: String) {
return firstName return firstName
} }
fun toBestDisplayName(): String { fun toBestDisplayName(): String = info?.bestName() ?: pubkeyDisplayHex()
return info?.bestName() ?: pubkeyDisplayHex()
}
fun nip05(): String? { fun nip05(): String? = info?.nip05
return info?.nip05
}
fun profilePicture(): String? { fun profilePicture(): String? = info?.picture
return info?.picture
}
fun updateBookmark(event: BookmarkListEvent) { fun updateBookmark(event: BookmarkListEvent) {
if (event.id == latestBookmarkList?.id) return if (event.id == latestBookmarkList?.id) return
@@ -132,10 +140,18 @@ class User(val pubkeyHex: String) {
// Update Followers of the past user list // Update Followers of the past user list
// Update Followers of the new contact list // Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData() LocalCache
.getUserIfExists(it)
?.liveSet
?.innerFollowers
?.invalidateData()
} }
(latestContactList)?.unverifiedFollowKeySet()?.forEach { (latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData() LocalCache
.getUserIfExists(it)
?.liveSet
?.innerFollowers
?.invalidateData()
} }
liveSet?.innerRelays?.invalidateData() liveSet?.innerRelays?.invalidateData()
@@ -198,25 +214,19 @@ class User(val pubkeyHex: String) {
return amount return amount
} }
fun reportsBy(user: User): Set<Note> { fun reportsBy(user: User): Set<Note> = reports[user] ?: emptySet()
return reports[user] ?: emptySet()
}
fun countReportAuthorsBy(users: Set<HexKey>): Int { fun countReportAuthorsBy(users: Set<HexKey>): Int = reports.count { it.key.pubkeyHex in users }
return reports.count { it.key.pubkeyHex in users }
}
fun reportsBy(users: Set<HexKey>): List<Note> { fun reportsBy(users: Set<HexKey>): List<Note> =
return reports reports
.mapNotNull { .mapNotNull {
if (it.key.pubkeyHex in users) { if (it.key.pubkeyHex in users) {
it.value it.value
} else { } else {
null null
} }
} }.flatten()
.flatten()
}
@Synchronized @Synchronized
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom { private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
@@ -235,9 +245,7 @@ class User(val pubkeyHex: String) {
return getOrCreatePrivateChatroom(key) return getOrCreatePrivateChatroom(key)
} }
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom { private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom = privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
}
fun addMessage( fun addMessage(
room: ChatroomKey, room: ChatroomKey,
@@ -326,13 +334,9 @@ class User(val pubkeyHex: String) {
liveSet?.innerMetadata?.invalidateData() liveSet?.innerMetadata?.invalidateData()
} }
fun isFollowing(user: User): Boolean { fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
}
fun isFollowingHashtag(tag: String): Boolean { fun isFollowingHashtag(tag: String): Boolean = latestContactList?.isTaggedHash(tag) ?: false
return latestContactList?.isTaggedHash(tag) ?: false
}
fun isFollowingHashtagCached(tag: String): Boolean { fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let { return latestContactList?.verifiedFollowTagSet?.let {
@@ -362,37 +366,21 @@ class User(val pubkeyHex: String) {
?: false ?: false
} }
fun transientFollowCount(): Int? { fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
return latestContactList?.unverifiedFollowKeySet()?.size
}
suspend fun transientFollowerCount(): Int { suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> { fun cachedFollowingKeySet(): Set<HexKey> = latestContactList?.verifiedFollowKeySet ?: emptySet()
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowingTagSet(): Set<String> { fun cachedFollowingTagSet(): Set<String> = latestContactList?.verifiedFollowTagSet ?: emptySet()
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
fun cachedFollowingGeohashSet(): Set<HexKey> { fun cachedFollowingGeohashSet(): Set<HexKey> = latestContactList?.verifiedFollowGeohashSet ?: emptySet()
return latestContactList?.verifiedFollowGeohashSet ?: emptySet()
}
fun cachedFollowingCommunitiesSet(): Set<HexKey> { fun cachedFollowingCommunitiesSet(): Set<HexKey> = latestContactList?.verifiedFollowCommunitySet ?: emptySet()
return latestContactList?.verifiedFollowCommunitySet ?: emptySet()
}
fun cachedFollowCount(): Int? { fun cachedFollowCount(): Int? = latestContactList?.verifiedFollowKeySet?.size
return latestContactList?.verifiedFollowKeySet?.size
}
suspend fun cachedFollowerCount(): Int { suspend fun cachedFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(key: ChatroomKey?): Boolean { fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false val messagesToUser = privateChatrooms[key] ?: return false
@@ -403,16 +391,13 @@ class User(val pubkeyHex: String) {
fun hasReport( fun hasReport(
loggedIn: User, loggedIn: User,
type: ReportEvent.ReportType, type: ReportEvent.ReportType,
): Boolean { ): Boolean =
return reports[loggedIn]?.firstOrNull { reports[loggedIn]?.firstOrNull {
it.event is ReportEvent && it.event is ReportEvent &&
(it.event as ReportEvent).reportedAuthor().any { it.reportType == type } (it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
} != null } != null
}
fun anyNameStartsWith(username: String): Boolean { fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
return info?.anyNameStartsWith(username) ?: false
}
var liveSet: UserLiveSet? = null var liveSet: UserLiveSet? = null
var flowSet: UserFlowSet? = null var flowSet: UserFlowSet? = null
@@ -473,14 +458,14 @@ class User(val pubkeyHex: String) {
} }
@Stable @Stable
class UserFlowSet(u: User) { class UserFlowSet(
u: User,
) {
// Observers line up here. // Observers line up here.
val follows = UserBundledRefresherFlow(u) val follows = UserBundledRefresherFlow(u)
val relays = UserBundledRefresherFlow(u) val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean { fun isInUse(): Boolean = relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
return relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
}
fun destroy() { fun destroy() {
relays.destroy() relays.destroy()
@@ -489,7 +474,9 @@ class UserFlowSet(u: User) {
} }
@Stable @Stable
class UserLiveSet(u: User) { class UserLiveSet(
u: User,
) {
val innerMetadata = UserBundledRefresherLiveData(u) val innerMetadata = UserBundledRefresherLiveData(u)
// UI Observers line up here. // UI Observers line up here.
@@ -521,8 +508,8 @@ class UserLiveSet(u: User) {
val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged() val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged()
fun isInUse(): Boolean { fun isInUse(): Boolean =
return metadata.hasObservers() || metadata.hasObservers() ||
follows.hasObservers() || follows.hasObservers() ||
followers.hasObservers() || followers.hasObservers() ||
reports.hasObservers() || reports.hasObservers() ||
@@ -535,7 +522,6 @@ class UserLiveSet(u: User) {
profilePictureChanges.hasObservers() || profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() || nip05Changes.hasObservers() ||
userMetadataInfo.hasObservers() userMetadataInfo.hasObservers()
}
fun destroy() { fun destroy() {
innerMetadata.destroy() innerMetadata.destroy()
@@ -558,7 +544,9 @@ data class RelayInfo(
var counter: Long, var counter: Long,
) )
class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserState(user)) { class UserBundledRefresherLiveData(
val user: User,
) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO) private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -585,7 +573,9 @@ class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserSta
} }
@Stable @Stable
class UserBundledRefresherFlow(val user: User) { class UserBundledRefresherFlow(
val user: User,
) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO) private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(UserState(user)) val stateFlow = MutableStateFlow(UserState(user))
@@ -605,7 +595,10 @@ class UserBundledRefresherFlow(val user: User) {
} }
} }
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) { class UserLoadingLiveData<Y>(
val user: User,
initialValue: Y?,
) : MediatorLiveData<Y>(initialValue) {
override fun onActive() { override fun onActive() {
super.onActive() super.onActive()
NostrSingleUserDataSource.add(user) NostrSingleUserDataSource.add(user)
@@ -617,4 +610,6 @@ class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveDat
} }
} }
@Immutable class UserState(val user: User) @Immutable class UserState(
val user: User,
)
@@ -186,8 +186,10 @@ class MainActivity : AppCompatActivity() {
} }
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean { fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean {
val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
val isOnMobileData = !unmetered || networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val isOnWifi = unmetered && networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
var changedNetwork = false var changedNetwork = false
@@ -20,12 +20,22 @@
*/ */
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.animation.Crossfade import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.util.fastForEach
import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -33,16 +43,86 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
fun <T> CrossfadeIfEnabled( fun <T> CrossfadeIfEnabled(
targetState: T, targetState: T,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(), animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade", label: String = "Crossfade",
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
content: @Composable (T) -> Unit, content: @Composable (T) -> Unit,
) { ) {
if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) { if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) {
Box(modifier) { Box(modifier, contentAlignment) {
content(targetState) content(targetState)
} }
} else { } else {
Crossfade(targetState, modifier, animationSpec, label, content) MyCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun <T> MyCrossfade(
targetState: T,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
content: @Composable (T) -> Unit,
) {
val transition = updateTransition(targetState, label)
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
}
@ExperimentalAnimationApi
@Composable
fun <T> Transition<T>.MyCrossfade(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
contentKey: (targetState: T) -> Any? = { it },
content: @Composable (targetState: T) -> Unit,
) {
val currentlyVisible = remember { mutableStateListOf<T>().apply { add(currentState) } }
val contentMap =
remember {
mutableMapOf<T, @Composable () -> Unit>()
}
if (currentState == targetState) {
// If not animating, just display the current state
if (currentlyVisible.size != 1 || currentlyVisible[0] != targetState) {
// Remove all the intermediate items from the list once the animation is finished.
currentlyVisible.removeAll { it != targetState }
contentMap.clear()
}
}
if (!contentMap.contains(targetState)) {
// Replace target with the same key if any
val replacementId =
currentlyVisible.indexOfFirst {
contentKey(it) == contentKey(targetState)
}
if (replacementId == -1) {
currentlyVisible.add(targetState)
} else {
currentlyVisible[replacementId] = targetState
}
contentMap.clear()
currentlyVisible.fastForEach { stateForContent ->
contentMap[stateForContent] = {
val alpha by animateFloat(
transitionSpec = { animationSpec },
) { if (it == stateForContent) 1f else 0f }
Box(Modifier.graphicsLayer { this.alpha = alpha }, contentAlignment) {
content(stateForContent)
}
}
}
}
Box(modifier, contentAlignment) {
currentlyVisible.fastForEach {
key(contentKey(it)) {
contentMap[it]?.invoke()
}
}
} }
} }
@@ -100,10 +100,10 @@ class NewMessageTagger(
val results = parseDirtyWordForKey(word) val results = parseDirtyWordForKey(word)
when (val entity = results?.key?.entity) { when (val entity = results?.key?.entity) {
is Nip19Bech32.NPub -> { is Nip19Bech32.NPub -> {
getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord) getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord)
} }
is Nip19Bech32.NProfile -> { is Nip19Bech32.NProfile -> {
getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord) getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord)
} }
is Nip19Bech32.Note -> { is Nip19Bech32.Note -> {
@@ -138,17 +138,15 @@ class NewMessageTagger(
word word
} }
} }
} }.joinToString(" ")
.joinToString(" ") }.joinToString("\n")
}
.joinToString("\n")
} }
fun getNostrAddress( fun getNostrAddress(
bechAddress: String, bechAddress: String,
restOfTheWord: String?, restOfTheWord: String?,
): String { ): String =
return if (restOfTheWord.isNullOrEmpty()) { if (restOfTheWord.isNullOrEmpty()) {
"nostr:$bechAddress" "nostr:$bechAddress"
} else { } else {
if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) { if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) {
@@ -157,9 +155,11 @@ class NewMessageTagger(
"nostr:${bechAddress}$restOfTheWord" "nostr:${bechAddress}$restOfTheWord"
} }
} }
}
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String?) @Immutable data class DirtyKeyInfo(
val key: Nip19Bech32.ParseReturn,
val restOfWord: String?,
)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
var key = mightBeAKey var key = mightBeAKey
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent
@@ -88,7 +89,7 @@ enum class UserSuggestionAnchor {
} }
@Stable @Stable
open class NewPostViewModel() : ViewModel() { open class NewPostViewModel : ViewModel() {
var draftTag: String by mutableStateOf(UUID.randomUUID().toString()) var draftTag: String by mutableStateOf(UUID.randomUUID().toString())
var accountViewModel: AccountViewModel? = null var accountViewModel: AccountViewModel? = null
@@ -175,17 +176,11 @@ open class NewPostViewModel() : ViewModel() {
val draftTextChanges = Channel<String>(Channel.CONFLATED) val draftTextChanges = Channel<String>(Channel.CONFLATED)
fun lnAddress(): String? { fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress()
return account?.userProfile()?.info?.lnAddress()
}
fun hasLnAddress(): Boolean { fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null
return account?.userProfile()?.info?.lnAddress() != null
}
fun user(): User? { fun user(): User? = account?.userProfile()
return account?.userProfile()
}
open fun load( open fun load(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -368,15 +363,21 @@ open class NewPostViewModel() : ViewModel() {
} }
originalNote = originalNote =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" }.map { draftEvent
LocalCache.checkGetOrCreateNote(it[1]) .tags()
}.firstOrNull() .filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" }
.map {
LocalCache.checkGetOrCreateNote(it[1])
}.firstOrNull()
if (originalNote == null) { if (originalNote == null) {
originalNote = originalNote =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" }.map { draftEvent
LocalCache.checkGetOrCreateNote(it[1]) .tags()
}.firstOrNull() .filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" }
.map {
LocalCache.checkGetOrCreateNote(it[1])
}.firstOrNull()
} }
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
@@ -403,12 +404,45 @@ open class NewPostViewModel() : ViewModel() {
wantsProduct = draftEvent.kind() == 30402 wantsProduct = draftEvent.kind() == 30402
title = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "title" }.map { it[1] }?.firstOrNull() ?: "") title =
price = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "price" }.map { it[1] }?.firstOrNull() ?: "") TextFieldValue(
category = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "t" }.map { it[1] }?.firstOrNull() ?: "") draftEvent
locationText = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "location" }.map { it[1] }?.firstOrNull() ?: "") .tags()
.filter { it.size > 1 && it[0] == "title" }
.map { it[1] }
?.firstOrNull() ?: "",
)
price =
TextFieldValue(
draftEvent
.tags()
.filter { it.size > 1 && it[0] == "price" }
.map { it[1] }
?.firstOrNull() ?: "",
)
category =
TextFieldValue(
draftEvent
.tags()
.filter { it.size > 1 && it[0] == "t" }
.map { it[1] }
?.firstOrNull() ?: "",
)
locationText =
TextFieldValue(
draftEvent
.tags()
.filter { it.size > 1 && it[0] == "location" }
.map { it[1] }
?.firstOrNull() ?: "",
)
condition = ClassifiedsEvent.CONDITION.entries.firstOrNull { condition = ClassifiedsEvent.CONDITION.entries.firstOrNull {
it.value == draftEvent.tags().filter { it.size > 1 && it[0] == "condition" }.map { it[1] }.firstOrNull() it.value ==
draftEvent
.tags()
.filter { it.size > 1 && it[0] == "condition" }
.map { it[1] }
.firstOrNull()
} ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW } ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent
@@ -479,7 +513,8 @@ open class NewPostViewModel() : ViewModel() {
if (split.percentage > 0.00001) { if (split.percentage > 0.00001) {
val homeRelay = val homeRelay =
accountViewModel?.getRelayListFor(split.key)?.writeRelays()?.firstOrNull() accountViewModel?.getRelayListFor(split.key)?.writeRelays()?.firstOrNull()
?: split.key.relaysBeingUsed.keys.firstOrNull { !it.contains("localhost") } ?: split.key.relaysBeingUsed.keys
.firstOrNull { !it.contains("localhost") }
ZapSplitSetup( ZapSplitSetup(
lnAddressOrPubKeyHex = split.key.pubkeyHex, lnAddressOrPubKeyHex = split.key.pubkeyHex,
@@ -851,9 +886,7 @@ open class NewPostViewModel() : ViewModel() {
} }
} }
open fun findUrlInMessage(): String? { open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull()
return RichTextParser().parseValidUrls(message.text).firstOrNull()
}
open fun removeFromReplyList(userToRemove: User) { open fun removeFromReplyList(userToRemove: User) {
pTags = pTags?.filter { it != userToRemove } pTags = pTags?.filter { it != userToRemove }
@@ -869,14 +902,18 @@ open class NewPostViewModel() : ViewModel() {
if (it.selection.collapsed) { if (it.selection.collapsed) {
val lastWord = val lastWord =
it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") it.text
.substring(0, it.selection.end)
.substringAfterLast("\n")
.substringAfterLast(" ")
userSuggestionAnchor = it.selection userSuggestionAnchor = it.selection
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
if (lastWord.startsWith("@") && lastWord.length > 2) { if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
userSuggestions = userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) LocalCache
.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed() .reversed()
} }
@@ -894,14 +931,18 @@ open class NewPostViewModel() : ViewModel() {
if (it.selection.collapsed) { if (it.selection.collapsed) {
val lastWord = val lastWord =
it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") it.text
.substring(0, it.selection.end)
.substringAfterLast("\n")
.substringAfterLast(" ")
userSuggestionAnchor = it.selection userSuggestionAnchor = it.selection
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
if (lastWord.startsWith("@") && lastWord.length > 2) { if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
userSuggestions = userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) LocalCache
.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex })) .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed() .reversed()
} }
@@ -928,15 +969,15 @@ open class NewPostViewModel() : ViewModel() {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
userSuggestions = userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) LocalCache
.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith( .sortedWith(
compareBy( compareBy(
{ account?.isFollowing(it) }, { account?.isFollowing(it) },
{ it.toBestDisplayName() }, { it.toBestDisplayName() },
{ it.pubkeyHex }, { it.pubkeyHex },
), ),
) ).reversed()
.reversed()
} }
} else { } else {
NostrSearchEventOrUserDataSource.clear() NostrSearchEventOrUserDataSource.clear()
@@ -949,7 +990,10 @@ open class NewPostViewModel() : ViewModel() {
userSuggestionAnchor?.let { userSuggestionAnchor?.let {
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = val lastWord =
message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") message.text
.substring(0, it.end)
.substringAfterLast("\n")
.substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}" val wordToInsert = "@${item.pubkeyNpub()}"
@@ -963,7 +1007,10 @@ open class NewPostViewModel() : ViewModel() {
forwardZapToEditting = TextFieldValue("") forwardZapToEditting = TextFieldValue("")
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = val lastWord =
toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") toUsers.text
.substring(0, it.end)
.substringAfterLast("\n")
.substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}" val wordToInsert = "@${item.pubkeyNpub()}"
@@ -972,6 +1019,9 @@ open class NewPostViewModel() : ViewModel() {
toUsers.text.replaceRange(lastWordStart, it.end, wordToInsert), toUsers.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length),
) )
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelays()
nip17 = relayList != null
} }
userSuggestionAnchor = null userSuggestionAnchor = null
@@ -982,12 +1032,10 @@ open class NewPostViewModel() : ViewModel() {
saveDraft() saveDraft()
} }
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> { private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> = mutableStateMapOf(Pair(0, ""), Pair(1, ""))
return mutableStateMapOf(Pair(0, ""), Pair(1, ""))
}
fun canPost(): Boolean { fun canPost(): Boolean =
return message.text.isNotBlank() && message.text.isNotBlank() &&
!isUploadingImage && !isUploadingImage &&
!wantsInvoice && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) && (!wantsZapraiser || zapRaiserAmount != null) &&
@@ -1009,7 +1057,6 @@ open class NewPostViewModel() : ViewModel() {
) )
) && ) &&
contentToAddUrl == null contentToAddUrl == null
}
suspend fun createNIP94Record( suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent, uploadingResult: Nip96Uploader.PartialEvent,
@@ -1020,11 +1067,20 @@ open class NewPostViewModel() : ViewModel() {
// Images don't seem to be ready immediately after upload // Images don't seem to be ready immediately after upload
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType = val remoteMimeType =
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "m" }
?.get(1)
?.ifBlank { null }
val originalHash = val originalHash =
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "ox" }
?.get(1)
?.ifBlank { null }
val dim = val dim =
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "dim" }
?.get(1)
?.ifBlank { null }
val magnet = val magnet =
uploadingResult.tags uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" } ?.firstOrNull { it.size > 1 && it[0] == "magnet" }
@@ -1270,7 +1326,9 @@ open class NewPostViewModel() : ViewModel() {
} }
} }
enum class GeohashPrecision(val digits: Int) { enum class GeohashPrecision(
val digits: Int,
) {
KM_5000_X_5000(1), // 5,000km × 5,000km KM_5000_X_5000(1), // 5,000km × 5,000km
KM_1250_X_625(2), // 1,250km × 625km KM_1250_X_625(2), // 1,250km × 625km
KM_156_X_156(3), // 156km × 156km KM_156_X_156(3), // 156km × 156km
@@ -20,7 +20,9 @@
*/ */
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
@@ -33,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size24dp
@Composable @Composable
fun ClickableBox( fun ClickableBox(
modifier: Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
@@ -49,3 +51,25 @@ fun ClickableBox(
content() content()
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableBox(
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
content: @Composable () -> Unit,
) {
Box(
modifier.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = Size24dp),
onClick = onClick,
onLongClick = onLongClick,
),
contentAlignment = Alignment.Center,
) {
content()
}
}
@@ -39,7 +39,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf 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
@@ -50,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@@ -97,10 +97,9 @@ fun SensitivityWarning(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState = accountViewModel.account.showSensitiveContent.collectAsStateWithLifecycle()
var showContentWarningNote by var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) }
remember(accountState) { mutableStateOf(accountState?.account?.showSensitiveContent != true) }
CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) { CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) {
if (it) { if (it) {
@@ -118,18 +117,26 @@ fun ContentWarningNote(onDismiss: () -> Unit) {
Column(modifier = Modifier.padding(start = 10.dp)) { Column(modifier = Modifier.padding(start = 10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Box( Box(
Modifier.height(80.dp).width(90.dp), Modifier
.height(80.dp)
.width(90.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Default.Visibility, imageVector = Icons.Default.Visibility,
contentDescription = stringRes(R.string.content_warning), contentDescription = stringRes(R.string.content_warning),
modifier = Modifier.size(70.dp).align(Alignment.BottomStart), modifier =
Modifier
.size(70.dp)
.align(Alignment.BottomStart),
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
) )
Icon( Icon(
imageVector = Icons.Rounded.Warning, imageVector = Icons.Rounded.Warning,
contentDescription = stringRes(R.string.content_warning), contentDescription = stringRes(R.string.content_warning),
modifier = Modifier.size(30.dp).align(Alignment.TopEnd), modifier =
Modifier
.size(30.dp)
.align(Alignment.TopEnd),
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
) )
} }
@@ -77,6 +77,7 @@ fun TextSpinner(
readOnly = true, readOnly = true,
label = { label?.let { Text(it) } }, label = { label?.let { Text(it) } },
modifier = modifier, modifier = modifier,
singleLine = true,
) )
} }
} }
@@ -200,4 +201,7 @@ fun <T> SpinnerSelectionDialog(
} }
} }
@Immutable data class TitleExplainer(val title: String, val explainer: String? = null) @Immutable data class TitleExplainer(
val title: String,
val explainer: String? = null,
)
@@ -26,17 +26,15 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() { class HiddenAccountsFeedFilter(
override fun feedKey(): String { val account: Account,
return account.userProfile().pubkeyHex ) : FeedFilter<User>() {
} override fun feedKey(): String = account.userProfile().pubkeyHex
override fun showHiddenKey(): Boolean { override fun showHiddenKey(): Boolean = true
return true
}
override fun feed(): List<User> { override fun feed(): List<User> =
return account.flowHiddenUsers.value.hiddenUsers.reversed().mapNotNull { account.flowHiddenUsers.value.hiddenUsers.reversed().mapNotNull {
try { try {
LocalCache.getOrCreateUser(it) LocalCache.getOrCreateUser(it)
} catch (e: Exception) { } catch (e: Exception) {
@@ -45,33 +43,26 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
null null
} }
} }
}
} }
class HiddenWordsFeedFilter(val account: Account) : FeedFilter<String>() { class HiddenWordsFeedFilter(
override fun feedKey(): String { val account: Account,
return account.userProfile().pubkeyHex ) : FeedFilter<String>() {
} override fun feedKey(): String = account.userProfile().pubkeyHex
override fun showHiddenKey(): Boolean { override fun showHiddenKey(): Boolean = true
return true
}
override fun feed(): List<String> { override fun feed(): List<String> =
return account.flowHiddenUsers.value.hiddenWords.toList() account.flowHiddenUsers.value.hiddenWords
} .toList()
} }
class SpammerAccountsFeedFilter(val account: Account) : FeedFilter<User>() { class SpammerAccountsFeedFilter(
override fun feedKey(): String { val account: Account,
return account.userProfile().pubkeyHex ) : FeedFilter<User>() {
} override fun feedKey(): String = account.userProfile().pubkeyHex
override fun showHiddenKey(): Boolean { override fun showHiddenKey(): Boolean = true
return true
}
override fun feed(): List<User> { override fun feed(): List<User> = account.transientHiddenUsers.value.map { LocalCache.getOrCreateUser(it) }
return (account.transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
}
} }
@@ -145,10 +145,12 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
@@ -758,16 +760,18 @@ class FollowListViewModel(
private val _kind3GlobalPeopleRoutes = private val _kind3GlobalPeopleRoutes =
combineTransform(livePeopleListsFlow, liveKind3FollowsFlow) { myLivePeopleListsFlow, myLiveKind3FollowsFlow -> combineTransform(livePeopleListsFlow, liveKind3FollowsFlow) { myLivePeopleListsFlow, myLiveKind3FollowsFlow ->
checkNotInMainThread()
emit( emit(
listOf(listOf(kind3Follow, globalFollow), myLivePeopleListsFlow, myLiveKind3FollowsFlow, listOf(muteListFollow)) listOf(listOf(kind3Follow, globalFollow), myLivePeopleListsFlow, myLiveKind3FollowsFlow, listOf(muteListFollow))
.flatten() .flatten()
.toImmutableList(), .toImmutableList(),
) )
} }
val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists)
private val _kind3GlobalPeople = private val _kind3GlobalPeople =
combineTransform(livePeopleListsFlow, liveKind3FollowsFlow) { myLivePeopleListsFlow, myLiveKind3FollowsFlow -> combineTransform(livePeopleListsFlow, liveKind3FollowsFlow) { myLivePeopleListsFlow, myLiveKind3FollowsFlow ->
checkNotInMainThread()
emit( emit(
listOf(listOf(kind3Follow, globalFollow), myLivePeopleListsFlow, listOf(muteListFollow)) listOf(listOf(kind3Follow, globalFollow), myLivePeopleListsFlow, listOf(muteListFollow))
.flatten() .flatten()
@@ -775,7 +779,7 @@ class FollowListViewModel(
) )
} }
val kind3GlobalPeople = _kind3GlobalPeople.stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) val kind3GlobalPeople = _kind3GlobalPeople.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists)
class Factory( class Factory(
val account: Account, val account: Account,
@@ -168,8 +168,7 @@ fun DrawerContent(
BottomContent( BottomContent(
accountViewModel.account.userProfile(), accountViewModel.account.userProfile(),
drawerState, drawerState,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, accountViewModel,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
nav, nav,
) )
} }
@@ -741,8 +740,7 @@ fun IconRowRelays(
fun BottomContent( fun BottomContent(
user: User, user: User,
drawerState: DrawerState, drawerState: DrawerState,
loadProfilePicture: Boolean, accountViewModel: AccountViewModel,
loadRobohash: Boolean,
nav: (String) -> Unit, nav: (String) -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -800,8 +798,7 @@ fun BottomContent(
if (dialogOpen) { if (dialogOpen) {
ShowQRDialog( ShowQRDialog(
user, user,
loadProfilePicture = loadProfilePicture, accountViewModel,
loadRobohash = loadRobohash,
onScan = { onScan = {
dialogOpen = false dialogOpen = false
coroutineScope.launch { drawerState.close() } coroutineScope.launch { drawerState.close() }
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.automirrored.filled.VolumeOff import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.AddReaction
import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
@@ -44,6 +45,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material.icons.outlined.ArrowForwardIos import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.PlayCircle
@@ -164,7 +166,20 @@ fun LikedIcon(
) { ) {
Icon( Icon(
imageVector = Liked, imageVector = Liked,
stringRes(id = R.string.like_description), contentDescription = stringRes(id = R.string.like_description),
modifier = modifier,
tint = tint,
)
}
@Composable
fun ChangeReactionIcon(
modifier: Modifier,
tint: Color = Color.Unspecified,
) {
Icon(
imageVector = Icons.Outlined.AddReaction,
contentDescription = stringRes(id = R.string.change_reaction),
modifier = modifier, modifier = modifier,
tint = tint, tint = tint,
) )
@@ -310,7 +310,7 @@ fun DisplayStatus(
} }
@Composable @Composable
private fun DisplayNIP05( fun DisplayNIP05(
nip05: String, nip05: String,
nip05Verified: MutableState<Boolean?>, nip05Verified: MutableState<Boolean?>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -84,6 +84,7 @@ import androidx.core.graphics.ColorUtils
import com.vitorpamplona.amethyst.R 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.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -95,7 +96,6 @@ import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
import com.vitorpamplona.quartz.events.AudioTrackEvent import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private fun lightenColor( private fun lightenColor(
@@ -110,6 +110,10 @@ private fun lightenColor(
return Color(argb) return Color(argb)
} }
val externalLinkForUser = { user: User ->
"https://njump.me/${user.toNProfile()}"
}
val externalLinkForNote = { note: Note -> val externalLinkForNote = { note: Note ->
if (note is AddressableNote) { if (note is AddressableNote) {
if (note.event?.getReward() != null) { if (note.event?.getReward() != null) {
@@ -298,10 +302,12 @@ private fun RenderMainPopup(
Icons.Default.AlternateEmail, Icons.Default.AlternateEmail,
stringRes(R.string.quick_action_copy_user_id), stringRes(R.string.quick_action_copy_user_id),
) { ) {
scope.launch(Dispatchers.IO) { note.author?.let {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) scope.launch {
showToast(R.string.copied_user_id_to_clipboard) clipboardManager.setText(AnnotatedString(it.toNostrUri()))
onDismiss() showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
} }
} }
VerticalDivider(color = primaryLight) VerticalDivider(color = primaryLight)
@@ -309,8 +315,8 @@ private fun RenderMainPopup(
Icons.Default.FormatQuote, Icons.Default.FormatQuote,
stringRes(R.string.quick_action_copy_note_id), stringRes(R.string.quick_action_copy_note_id),
) { ) {
scope.launch(Dispatchers.IO) { scope.launch {
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}")) clipboardManager.setText(AnnotatedString(note.toNostrUri()))
showToast(R.string.copied_note_id_to_clipboard) showToast(R.string.copied_note_id_to_clipboard)
onDismiss() onDismiss()
} }
@@ -35,7 +35,6 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -49,7 +48,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -85,10 +83,12 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
@@ -112,6 +112,7 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DarkerGreen import com.vitorpamplona.amethyst.ui.theme.DarkerGreen
import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders
import com.vitorpamplona.amethyst.ui.theme.ReactionRowExpandButton import com.vitorpamplona.amethyst.ui.theme.ReactionRowExpandButton
@@ -127,14 +128,20 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size28Modifier
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.TinyBorders import com.vitorpamplona.amethyst.ui.theme.TinyBorders
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -817,7 +824,6 @@ fun BoostText(
SlidingAnimationCount(boostState, grayTint, accountViewModel) SlidingAnimationCount(boostState, grayTint, accountViewModel)
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun LikeReaction( fun LikeReaction(
baseNote: Note, baseNote: Note,
@@ -831,29 +837,21 @@ fun LikeReaction(
var wantsToChangeReactionSymbol by remember { mutableStateOf(false) } var wantsToChangeReactionSymbol by remember { mutableStateOf(false) }
var wantsToReact by remember { mutableStateOf(false) } var wantsToReact by remember { mutableStateOf(false) }
Box( ClickableBox(
contentAlignment = Center, onClick = {
modifier = likeClick(
Modifier accountViewModel,
.combinedClickable( baseNote,
role = Role.Button, onMultipleChoices = { wantsToReact = true },
interactionSource = remember { MutableInteractionSource() }, onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) },
indication = rememberRipple(bounded = false, radius = Size24dp), )
onClick = { },
likeClick( onLongClick = { wantsToChangeReactionSymbol = true },
accountViewModel,
baseNote,
onMultipleChoices = { wantsToReact = true },
onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) },
)
},
onLongClick = { wantsToChangeReactionSymbol = true },
),
) { ) {
ObserveLikeIcon(baseNote, accountViewModel) { reactionType -> ObserveLikeIcon(baseNote, accountViewModel) { reactionType ->
CrossfadeIfEnabled(targetState = reactionType, label = "LikeIcon", accountViewModel = accountViewModel) { CrossfadeIfEnabled(targetState = reactionType, contentAlignment = Center, label = "LikeIcon", accountViewModel = accountViewModel) {
if (it != null) { if (reactionType != null) {
RenderReactionType(it, heartSizeModifier, iconFontSize) RenderReactionType(reactionType, heartSizeModifier, iconFontSize)
} else { } else {
LikeIcon(heartSizeModifier, grayTint) LikeIcon(heartSizeModifier, grayTint)
} }
@@ -1307,7 +1305,6 @@ private fun BoostTypeChoicePopup(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun ReactionChoicePopup( fun ReactionChoicePopup(
baseNote: Note, baseNote: Note,
@@ -1320,6 +1317,7 @@ fun ReactionChoicePopup(
val account = accountState?.account ?: return val account = accountState?.account ?: return
val toRemove = remember { baseNote.reactedBy(account.userProfile()).toImmutableSet() } val toRemove = remember { baseNote.reactedBy(account.userProfile()).toImmutableSet() }
val reactions = remember { account.reactionChoices.toImmutableList() }
val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() }
@@ -1328,108 +1326,139 @@ fun ReactionChoicePopup(
offset = IntOffset(0, iconSizePx), offset = IntOffset(0, iconSizePx),
onDismissRequest = { onDismiss() }, onDismissRequest = { onDismiss() },
) { ) {
ReactionChoicePopupContent(
reactions,
toRemove = toRemove,
onClick = { reactionType ->
accountViewModel.reactToOrDelete(
baseNote,
reactionType,
)
onDismiss()
},
onChangeAmount,
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReactionChoicePopupContent(
listOfReactions: ImmutableList<String>,
toRemove: ImmutableSet<String>,
onClick: (reactionType: String) -> Unit,
onChangeAmount: () -> Unit,
) {
Box(HalfPadding, contentAlignment = Center) {
ElevatedCard( ElevatedCard(
Modifier shape = SmallBorder,
.border(width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = RoundedCornerShape(5.dp)),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) { ) {
Box(modifier = Modifier.padding(5.dp)) { FlowRow(
FlowRow(horizontalArrangement = Arrangement.Center) { modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
account.reactionChoices.forEach { reactionType -> horizontalArrangement = Arrangement.Center,
ActionableReactionButton( verticalArrangement = Arrangement.Center,
baseNote, ) {
reactionType, listOfReactions.forEach { reactionType ->
accountViewModel, ActionableReactionButton(
onDismiss, reactionType = reactionType,
onChangeAmount, onClick = { onClick(reactionType) },
toRemove, onChangeAmount = onChangeAmount,
) toRemove = toRemove,
} )
}
ClickableBox(modifier = reactionBox, onClick = onChangeAmount) {
ChangeReactionIcon(modifier = Size28Modifier, MaterialTheme.colorScheme.placeholderText)
} }
} }
} }
} }
} }
@Preview()
@Composable
fun ReactionChoicePopupPeeview() {
ThemeComparisonColumn {
ReactionChoicePopupContent(
persistentListOf(
"\uD83D\uDE80",
"\uD83E\uDEC2",
"\uD83D\uDC40",
"\uD83D\uDE02",
"\uD83C\uDF89",
"\uD83E\uDD14",
"\uD83D\uDE31",
"\uD83E\uDD14",
"\uD83D\uDE31",
"+",
"-",
"\uD83C\uDF89",
"\uD83E\uDD14",
"\uD83D\uDE31",
"\uD83E\uDD14",
"\uD83D\uDE31",
),
onClick = {},
onChangeAmount = {},
toRemove = persistentSetOf("\uD83D\uDE80", "\uD83E\uDD14", "\uD83D\uDE31"),
)
}
}
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class)
private fun ActionableReactionButton( private fun ActionableReactionButton(
baseNote: Note,
reactionType: String, reactionType: String,
accountViewModel: AccountViewModel, onClick: () -> Unit,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit, onChangeAmount: () -> Unit,
toRemove: ImmutableSet<String>, toRemove: ImmutableSet<String>,
) { ) {
val thisModifier = ClickableBox(
remember(reactionType) { modifier = if (reactionType in toRemove) MaterialTheme.colorScheme.selectedReactionBoxModifier else reactionBox,
Modifier onClick = onClick,
.padding(horizontal = 3.dp) onLongClick = onChangeAmount,
.combinedClickable( ) {
onClick = { RenderReaction(reactionType)
accountViewModel.reactToOrDelete( }
baseNote, }
reactionType,
)
onDismiss()
},
onLongClick = { onChangeAmount() },
)
.padding(5.dp)
}
val removeSymbol =
remember(reactionType) {
if (reactionType in toRemove) {
""
} else {
""
}
}
@Composable
fun RenderReaction(reactionType: String) {
if (reactionType.startsWith(":")) { if (reactionType.startsWith(":")) {
val noStartColon = reactionType.removePrefix(":") val noStartColon = reactionType.removePrefix(":")
val url = noStartColon.substringAfter(":") val url = noStartColon.substringAfter(":")
val renderable = InLineIconRenderer(
persistentListOf( persistentListOf(
Nip30CustomEmoji.ImageUrlType(url), Nip30CustomEmoji.ImageUrlType(url),
Nip30CustomEmoji.TextType(removeSymbol), ),
) style = SpanStyle(color = MaterialTheme.colorScheme.onBackground),
InLineIconRenderer(
renderable,
style = SpanStyle(color = Color.White),
maxLines = 1, maxLines = 1,
modifier = thisModifier, fontSize = 22.sp,
) )
} else { } else {
when (reactionType) { when (reactionType) {
"+" -> { "+" -> {
LikedIcon(modifier = thisModifier.size(16.dp), tint = Color.White) LikedIcon(modifier = Size28Modifier)
}
"-" -> {
Text( Text(
text = removeSymbol, text = "\uD83D\uDC4E",
color = Color.White, color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center, maxLines = 1,
modifier = thisModifier, fontSize = 22.sp,
) )
} }
"-" -> else -> {
Text( Text(
text = "\uD83D\uDC4E$removeSymbol", reactionType,
color = Color.White, color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center, maxLines = 1,
modifier = thisModifier, fontSize = 22.sp,
)
else ->
Text(
"$reactionType$removeSymbol",
color = Color.White,
textAlign = TextAlign.Center,
modifier = thisModifier,
) )
}
} }
} }
} }
@@ -277,8 +277,7 @@ private fun RenderReactionOption(
width = 1.dp, width = 1.dp,
color = MaterialTheme.colorScheme.surfaceDim, color = MaterialTheme.colorScheme.surfaceDim,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
) ).padding(8.dp),
.padding(8.dp),
) { ) {
if (reactionType.startsWith(":")) { if (reactionType.startsWith(":")) {
val noStartColon = reactionType.removePrefix(":") val noStartColon = reactionType.removePrefix(":")
@@ -298,13 +297,15 @@ private fun RenderReactionOption(
} else { } else {
when (reactionType) { when (reactionType) {
"+" -> { "+" -> {
LikedIcon(modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onBackground) Row {
LikedIcon(modifier = Modifier.size(20.dp), tint = Color.Unspecified)
Text( Text(
text = "", text = "",
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
}
} }
"-" -> "-" ->
Text( Text(
@@ -197,9 +197,11 @@ fun NoteDropDownMenu(
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringRes(R.string.copy_user_pubkey)) }, text = { Text(stringRes(R.string.copy_user_pubkey)) },
onClick = { onClick = {
scope.launch(Dispatchers.IO) { note.author?.let {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) scope.launch(Dispatchers.IO) {
onDismiss() clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
onDismiss()
}
} }
}, },
) )
@@ -207,7 +209,7 @@ fun NoteDropDownMenu(
text = { Text(stringRes(R.string.copy_note_id)) }, text = { Text(stringRes(R.string.copy_note_id)) },
onClick = { onClick = {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) clipboardManager.setText(AnnotatedString(note.toNostrUri()))
onDismiss() onDismiss()
} }
}, },
@@ -412,7 +414,7 @@ fun WatchBookmarksFollowsAndAccount(
isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note),
isLoggedUser = accountViewModel.isLoggedUser(note.author), isLoggedUser = accountViewModel.isLoggedUser(note.author),
isSensitive = note.event?.isSensitive() ?: false, isSensitive = note.event?.isSensitive() ?: false,
showSensitiveContent = showSensitiveContent, showSensitiveContent = showSensitiveContent.value,
) )
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
@@ -45,16 +45,22 @@ import androidx.compose.ui.Alignment
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.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNIP05
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.components.nip05VerificationAsAState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.UserMetadata
@@ -62,21 +68,20 @@ import com.vitorpamplona.quartz.events.UserMetadata
@Preview @Preview
@Composable @Composable
fun ShowQRDialogPreview() { fun ShowQRDialogPreview() {
val user = User("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") val accountViewModel = mockAccountViewModel()
accountViewModel.userProfile().info =
user.info =
UserMetadata().apply { UserMetadata().apply {
name = "My Name" name = "My Name"
picture = "Picture" picture = "Picture"
nip05 = null
banner = "http://banner.com/test" banner = "http://banner.com/test"
website = "http://mywebsite.com/test" website = "http://mywebsite.com/test"
about = "This is the about me" about = "This is the about me"
} }
ShowQRDialog( ShowQRDialog(
user = user, user = accountViewModel.userProfile(),
loadProfilePicture = false, accountViewModel = accountViewModel,
loadRobohash = false,
onScan = {}, onScan = {},
onClose = {}, onClose = {},
) )
@@ -85,8 +90,7 @@ fun ShowQRDialogPreview() {
@Composable @Composable
fun ShowQRDialog( fun ShowQRDialog(
user: User, user: User,
loadProfilePicture: Boolean, accountViewModel: AccountViewModel,
loadRobohash: Boolean,
onScan: (String) -> Unit, onScan: (String) -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
@@ -126,8 +130,8 @@ fun ShowQRDialog(
.height(100.dp) .height(100.dp)
.clip(shape = CircleShape) .clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colorScheme.background, CircleShape), .border(3.dp, MaterialTheme.colorScheme.background, CircleShape),
loadProfilePicture = loadProfilePicture, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = loadRobohash, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
) )
} }
Row( Row(
@@ -141,13 +145,34 @@ fun ShowQRDialog(
fontSize = 18.sp, fontSize = 18.sp,
) )
} }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
val nip05 = user.nip05()
if (nip05 != null) {
val nip05Verified =
nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel)
DisplayNIP05(nip05, nip05Verified, accountViewModel)
} else {
Text(
text = user.pubkeyDisplayHex(),
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
} }
Row( Row(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = Size35dp), modifier = Modifier.fillMaxWidth().padding(horizontal = Size35dp),
) { ) {
QrCodeDrawer("nostr:${user.pubkeyNpub()}") QrCodeDrawer(user.toNostrUri())
} }
Row(modifier = Modifier.padding(horizontal = 30.dp)) { Row(modifier = Modifier.padding(horizontal = 30.dp)) {
@@ -111,6 +111,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
@@ -308,22 +309,19 @@ class AccountViewModel(
noteIsHiddenFlows.get(note) noteIsHiddenFlows.get(note)
?: combineTransform( ?: combineTransform(
account.flowHiddenUsers, account.flowHiddenUsers,
account.liveKind3FollowsFlow, account.liveKind3Follows,
note.flow().metadata.stateFlow, note.flow().metadata.stateFlow,
note.flow().reports.stateFlow, note.flow().reports.stateFlow,
) { hiddenUsers, followingUsers, metadata, reports -> ) { hiddenUsers, followingUsers, metadata, reports ->
val isAcceptable = emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.users))
withContext(Dispatchers.IO) { }.flowOn(Dispatchers.Default)
isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.users) .stateIn(
} viewModelScope,
emit(isAcceptable) SharingStarted.Eagerly,
}.stateIn( NoteComposeReportState(),
viewModelScope, ).also {
SharingStarted.Eagerly, noteIsHiddenFlows.put(note, it)
NoteComposeReportState(), }
).also {
noteIsHiddenFlows.put(note, it)
}
private val noteMustShowExpandButtonFlows = LruCache<Note, StateFlow<Boolean>>(300) private val noteMustShowExpandButtonFlows = LruCache<Note, StateFlow<Boolean>>(300)
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Intent
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@@ -103,6 +104,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -136,6 +138,7 @@ import com.vitorpamplona.amethyst.ui.note.DrawPlayName
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.LightningAddressIcon import com.vitorpamplona.amethyst.ui.note.LightningAddressIcon
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.externalLinkForUser
import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.qrcode.ShowQRDialog import com.vitorpamplona.amethyst.ui.qrcode.ShowQRDialog
import com.vitorpamplona.amethyst.ui.screen.FeedState import com.vitorpamplona.amethyst.ui.screen.FeedState
@@ -996,9 +999,8 @@ private fun DrawAdditionalInfo(
if (dialogOpen) { if (dialogOpen) {
ShowQRDialog( ShowQRDialog(
user, user = user,
accountViewModel.settings.showProfilePictures.value, accountViewModel = accountViewModel,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onScan = { onScan = {
dialogOpen = false dialogOpen = false
nav(it) nav(it)
@@ -1870,6 +1872,32 @@ fun UserProfileDropDownMenu(
}, },
) )
val actContext = LocalContext.current
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForUser(user),
)
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)
onDismiss()
},
)
if (accountViewModel.userProfile() != user) { if (accountViewModel.userProfile() != user) {
HorizontalDivider(thickness = DividerThickness) HorizontalDivider(thickness = DividerThickness)
if (accountViewModel.account.isHidden(user)) { if (accountViewModel.account.isHidden(user)) {
@@ -135,6 +135,7 @@ val Size22Modifier = Modifier.size(22.dp)
val Size24Modifier = Modifier.size(24.dp) val Size24Modifier = Modifier.size(24.dp)
val Size25Modifier = Modifier.size(25.dp) val Size25Modifier = Modifier.size(25.dp)
val Size26Modifier = Modifier.size(26.dp) val Size26Modifier = Modifier.size(26.dp)
val Size28Modifier = Modifier.size(28.dp)
val Size30Modifier = Modifier.size(30.dp) val Size30Modifier = Modifier.size(30.dp)
val Size35Modifier = Modifier.size(35.dp) val Size35Modifier = Modifier.size(35.dp)
val Size39Modifier = Modifier.size(39.dp) val Size39Modifier = Modifier.size(39.dp)
@@ -268,3 +269,9 @@ val hashVerifierMark = Modifier.width(40.dp).height(40.dp).padding(10.dp)
val noteComposeRelayBox = Modifier.width(55.dp).heightIn(min = 17.dp).padding(start = 2.dp, end = 1.dp) val noteComposeRelayBox = Modifier.width(55.dp).heightIn(min = 17.dp).padding(start = 2.dp, end = 1.dp)
val previewCardImageModifier = Modifier.fillMaxWidth().heightIn(max = 200.dp).padding(bottom = 5.dp) val previewCardImageModifier = Modifier.fillMaxWidth().heightIn(max = 200.dp).padding(bottom = 5.dp)
val reactionBox =
Modifier
.padding(horizontal = 6.dp, vertical = 6.dp)
.size(Size40dp)
.padding(5.dp)
@@ -94,18 +94,12 @@ private val LightLessImportantLink = LightColorPalette.primary.copy(alpha = 0.52
private val DarkMediumImportantLink = DarkColorPalette.primary.copy(alpha = 0.32f) private val DarkMediumImportantLink = DarkColorPalette.primary.copy(alpha = 0.32f)
private val LightMediumImportantLink = LightColorPalette.primary.copy(alpha = 0.32f) private val LightMediumImportantLink = LightColorPalette.primary.copy(alpha = 0.32f)
private val DarkVeryImportantLink = DarkColorPalette.primary.copy(alpha = 0.12f)
private val LightVeryImportantLink = LightColorPalette.primary.copy(alpha = 0.12f)
private val DarkGrayText = DarkColorPalette.onSurface.copy(alpha = 0.52f) private val DarkGrayText = DarkColorPalette.onSurface.copy(alpha = 0.52f)
private val LightGrayText = LightColorPalette.onSurface.copy(alpha = 0.52f) private val LightGrayText = LightColorPalette.onSurface.copy(alpha = 0.52f)
private val DarkPlaceholderText = DarkColorPalette.onSurface.copy(alpha = 0.32f) private val DarkPlaceholderText = DarkColorPalette.onSurface.copy(alpha = 0.32f)
private val LightPlaceholderText = LightColorPalette.onSurface.copy(alpha = 0.32f) private val LightPlaceholderText = LightColorPalette.onSurface.copy(alpha = 0.32f)
private val DarkPlaceholderTextColorFilter = ColorFilter.tint(DarkPlaceholderText)
private val LightPlaceholderTextColorFilter = ColorFilter.tint(LightPlaceholderText)
private val DarkOnBackgroundColorFilter = ColorFilter.tint(DarkColorPalette.onBackground) private val DarkOnBackgroundColorFilter = ColorFilter.tint(DarkColorPalette.onBackground)
private val LightOnBackgroundColorFilter = ColorFilter.tint(LightColorPalette.onBackground) private val LightOnBackgroundColorFilter = ColorFilter.tint(LightColorPalette.onBackground)
@@ -115,20 +109,9 @@ private val LightSubtleButton = LightColorPalette.onSurface.copy(alpha = 0.22f)
private val DarkSubtleBorder = DarkColorPalette.onSurface.copy(alpha = 0.12f) private val DarkSubtleBorder = DarkColorPalette.onSurface.copy(alpha = 0.12f)
private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.12f) private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.12f)
private val DarkReplyItemBackground = DarkColorPalette.onSurface.copy(alpha = 0.05f)
private val LightReplyItemBackground = LightColorPalette.onSurface.copy(alpha = 0.05f)
private val DarkZapraiserBackground =
BitcoinOrange.copy(0.52f).compositeOver(DarkColorPalette.background)
private val LightZapraiserBackground =
BitcoinOrange.copy(0.52f).compositeOver(LightColorPalette.background)
private val DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f) private val DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f)
private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f) private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f)
val RepostPictureBorderDark = Modifier.border(2.dp, DarkColorPalette.background, CircleShape)
val RepostPictureBorderLight = Modifier.border(2.dp, LightColorPalette.background, CircleShape)
val DarkImageModifier = val DarkImageModifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -179,6 +162,22 @@ val LightInnerPostBorderModifier =
.clip(shape = QuoteBorder) .clip(shape = QuoteBorder)
.border(1.dp, LightSubtleBorder, QuoteBorder) .border(1.dp, LightSubtleBorder, QuoteBorder)
val DarkSelectedReactionBoxModifier =
Modifier
.padding(horizontal = 5.dp, vertical = 5.dp)
.size(Size40dp)
.clip(shape = SmallBorder)
.background(DarkColorPalette.secondaryContainer)
.padding(5.dp)
val LightSelectedReactionBoxModifier =
Modifier
.padding(horizontal = 5.dp, vertical = 5.dp)
.size(Size40dp)
.clip(shape = SmallBorder)
.background(LightColorPalette.secondaryContainer)
.padding(5.dp)
val DarkChannelNotePictureModifier = val DarkChannelNotePictureModifier =
Modifier Modifier
.size(30.dp) .size(30.dp)
@@ -225,16 +224,6 @@ val DarkLargeRelayIconModifier =
.size(Size55dp) .size(Size55dp)
.clip(shape = CircleShape) .clip(shape = CircleShape)
val LightBottomIconModifier =
Modifier
.size(Size10dp)
.clip(shape = CircleShape)
val DarkBottomIconModifier =
Modifier
.size(Size10dp)
.clip(shape = CircleShape)
val RichTextDefaults = RichTextStyle().resolveDefaults() val RichTextDefaults = RichTextStyle().resolveDefaults()
val MarkDownStyleOnDark = val MarkDownStyleOnDark =
@@ -319,9 +308,6 @@ val ColorScheme.isLight: Boolean
val ColorScheme.newItemBackgroundColor: Color val ColorScheme.newItemBackgroundColor: Color
get() = if (isLight) LightNewItemBackground else DarkNewItemBackground get() = if (isLight) LightNewItemBackground else DarkNewItemBackground
val ColorScheme.replyBackground: Color
get() = if (isLight) LightReplyItemBackground else DarkReplyItemBackground
val ColorScheme.selectedNote: Color val ColorScheme.selectedNote: Color
get() = if (isLight) LightSelectedNote else DarkSelectedNote get() = if (isLight) LightSelectedNote else DarkSelectedNote
@@ -331,13 +317,8 @@ val ColorScheme.secondaryButtonBackground: Color
val ColorScheme.lessImportantLink: Color val ColorScheme.lessImportantLink: Color
get() = if (isLight) LightLessImportantLink else DarkLessImportantLink get() = if (isLight) LightLessImportantLink else DarkLessImportantLink
val ColorScheme.zapraiserBackground: Color
get() = if (isLight) LightZapraiserBackground else DarkZapraiserBackground
val ColorScheme.mediumImportanceLink: Color val ColorScheme.mediumImportanceLink: Color
get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink
val ColorScheme.veryImportantLink: Color
get() = if (isLight) LightVeryImportantLink else DarkVeryImportantLink
val ColorScheme.placeholderText: Color val ColorScheme.placeholderText: Color
get() = if (isLight) LightPlaceholderText else DarkPlaceholderText get() = if (isLight) LightPlaceholderText else DarkPlaceholderText
@@ -345,9 +326,6 @@ val ColorScheme.placeholderText: Color
val ColorScheme.nip05: Color val ColorScheme.nip05: Color
get() = if (isLight) Nip05EmailColorLight else Nip05EmailColorDark get() = if (isLight) Nip05EmailColorLight else Nip05EmailColorDark
val ColorScheme.placeholderTextColorFilter: ColorFilter
get() = if (isLight) LightPlaceholderTextColorFilter else DarkPlaceholderTextColorFilter
val ColorScheme.onBackgroundColorFilter: ColorFilter val ColorScheme.onBackgroundColorFilter: ColorFilter
get() = if (isLight) LightOnBackgroundColorFilter else DarkOnBackgroundColorFilter get() = if (isLight) LightOnBackgroundColorFilter else DarkOnBackgroundColorFilter
@@ -375,9 +353,6 @@ val ColorScheme.allGoodColor: Color
val ColorScheme.markdownStyle: RichTextStyle val ColorScheme.markdownStyle: RichTextStyle
get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark
val ColorScheme.repostProfileBorder: Modifier
get() = if (isLight) RepostPictureBorderLight else RepostPictureBorderDark
val ColorScheme.imageModifier: Modifier val ColorScheme.imageModifier: Modifier
get() = if (isLight) LightImageModifier else DarkImageModifier get() = if (isLight) LightImageModifier else DarkImageModifier
@@ -402,8 +377,8 @@ val ColorScheme.relayIconModifier: Modifier
val ColorScheme.largeRelayIconModifier: Modifier val ColorScheme.largeRelayIconModifier: Modifier
get() = if (isLight) LightLargeRelayIconModifier else DarkLargeRelayIconModifier get() = if (isLight) LightLargeRelayIconModifier else DarkLargeRelayIconModifier
val ColorScheme.bottomIconModifier: Modifier val ColorScheme.selectedReactionBoxModifier: Modifier
get() = if (isLight) LightBottomIconModifier else DarkBottomIconModifier get() = if (isLight) LightSelectedReactionBoxModifier else DarkSelectedReactionBoxModifier
val ColorScheme.chartStyle: ChartStyle val ColorScheme.chartStyle: ChartStyle
get() { get() {
+1 -1
View File
@@ -451,7 +451,7 @@
<string name="connectivity_type_never">कभी नहीं</string> <string name="connectivity_type_never">कभी नहीं</string>
<string name="ui_feature_set_type_complete">सम्पूर्ण</string> <string name="ui_feature_set_type_complete">सम्पूर्ण</string>
<string name="ui_feature_set_type_simplified">सरलीकृत</string> <string name="ui_feature_set_type_simplified">सरलीकृत</string>
<string name="ui_feature_set_type_performance">प्रदर्श</string> <string name="ui_feature_set_type_performance">वेगवा</string>
<string name="system">यन्त्रव्यवस्था</string> <string name="system">यन्त्रव्यवस्था</string>
<string name="light">प्रकाशवान</string> <string name="light">प्रकाशवान</string>
<string name="dark">अन्धकारमय</string> <string name="dark">अन्धकारमय</string>
@@ -451,6 +451,7 @@
<string name="connectivity_type_never">从不</string> <string name="connectivity_type_never">从不</string>
<string name="ui_feature_set_type_complete">完整版</string> <string name="ui_feature_set_type_complete">完整版</string>
<string name="ui_feature_set_type_simplified">简化版</string> <string name="ui_feature_set_type_simplified">简化版</string>
<string name="ui_feature_set_type_performance">极速版</string>
<string name="system">系统</string> <string name="system">系统</string>
<string name="light">浅色</string> <string name="light">浅色</string>
<string name="dark">深色</string> <string name="dark">深色</string>
+3
View File
@@ -511,6 +511,7 @@
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="connectivity_type_always">Always</string> <string name="connectivity_type_always">Always</string>
<string name="connectivity_type_wifi_only">Wifi-only</string> <string name="connectivity_type_wifi_only">Wifi-only</string>
<string name="connectivity_type_unmetered_wifi_only">Unmetered WiFi</string>
<string name="connectivity_type_never">Never</string> <string name="connectivity_type_never">Never</string>
<string name="ui_feature_set_type_complete">Complete</string> <string name="ui_feature_set_type_complete">Complete</string>
@@ -785,6 +786,8 @@
<string name="like_description">Like</string> <string name="like_description">Like</string>
<string name="zap_description">Zap</string> <string name="zap_description">Zap</string>
<string name="change_reaction">Change Quick Reactions</string>
<string name="profile_image_of_user">Profile Picture of %1$s</string> <string name="profile_image_of_user">Profile Picture of %1$s</string>
<string name="relay_info">Relay %1$s</string> <string name="relay_info">Relay %1$s</string>
<string name="expand_relay_list">Expand relay list</string> <string name="expand_relay_list">Expand relay list</string>
+1 -1
View File
@@ -14,7 +14,7 @@ coil = "2.6.0"
composeBom = "2024.06.00" composeBom = "2024.06.00"
coreKtx = "1.13.1" coreKtx = "1.13.1"
espressoCore = "3.5.1" espressoCore = "3.5.1"
firebaseBom = "33.1.0" firebaseBom = "33.1.1"
fragmentKtx = "1.8.0" fragmentKtx = "1.8.0"
gms = "4.4.1" gms = "4.4.1"
jacksonModuleKotlin = "2.17.1" jacksonModuleKotlin = "2.17.1"
@@ -39,7 +39,9 @@ object Nip19Bech32 {
ADDRESS, ADDRESS,
} }
enum class TlvTypes(val id: Byte) { enum class TlvTypes(
val id: Byte,
) {
SPECIAL(0), SPECIAL(0),
RELAY(1), RELAY(1),
AUTHOR(2), AUTHOR(2),
@@ -53,33 +55,59 @@ object Nip19Bech32 {
) )
@Immutable @Immutable
data class ParseReturn(val entity: Entity, val additionalChars: String? = null) data class ParseReturn(
val entity: Entity,
val additionalChars: String? = null,
)
interface Entity interface Entity
@Immutable @Immutable
data class NSec(val hex: String) : Entity data class NSec(
val hex: String,
) : Entity
@Immutable @Immutable
data class NPub(val hex: String) : Entity data class NPub(
val hex: String,
) : Entity
@Immutable @Immutable
data class Note(val hex: String) : Entity data class Note(
val hex: String,
) : Entity
@Immutable @Immutable
data class NProfile(val hex: String, val relay: List<String>) : Entity data class NProfile(
val hex: String,
val relay: List<String>,
) : Entity
@Immutable @Immutable
data class NEvent(val hex: String, val relay: List<String>, val author: String?, val kind: Int?) : Entity data class NEvent(
val hex: String,
val relay: List<String>,
val author: String?,
val kind: Int?,
) : Entity
@Immutable @Immutable
data class NAddress(val atag: String, val relay: List<String>, val author: String, val kind: Int) : Entity data class NAddress(
val atag: String,
val relay: List<String>,
val author: String,
val kind: Int,
) : Entity
@Immutable @Immutable
data class NRelay(val relay: List<String>) : Entity data class NRelay(
val relay: List<String>,
) : Entity
@Immutable @Immutable
data class NEmbed(val event: Event) : Entity data class NEmbed(
val event: Event,
) : Entity
fun uriToRoute(uri: String?): ParseReturn? { fun uriToRoute(uri: String?): ParseReturn? {
if (uri == null) return null if (uri == null) return null
@@ -108,8 +136,8 @@ object Nip19Bech32 {
type: String, type: String,
key: String?, key: String?,
additionalChars: String?, additionalChars: String?,
): ParseReturn? { ): ParseReturn? =
return try { try {
val bytes = (type + key).bechToBytes() val bytes = (type + key).bechToBytes()
when (type.lowercase()) { when (type.lowercase()) {
@@ -129,7 +157,6 @@ object Nip19Bech32 {
Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e) Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
null null
} }
}
private fun nembed(bytes: ByteArray): NEmbed? { private fun nembed(bytes: ByteArray): NEmbed? {
if (bytes.isEmpty()) return null if (bytes.isEmpty()) return null
@@ -205,21 +232,30 @@ object Nip19Bech32 {
author: String?, author: String?,
kind: Int?, kind: Int?,
relay: String?, relay: String?,
): String { ): String =
return TlvBuilder() TlvBuilder()
.apply { .apply {
addHex(TlvTypes.SPECIAL, idHex) addHex(TlvTypes.SPECIAL, idHex)
addStringIfNotNull(TlvTypes.RELAY, relay) addStringIfNotNull(TlvTypes.RELAY, relay)
addHexIfNotNull(TlvTypes.AUTHOR, author) addHexIfNotNull(TlvTypes.AUTHOR, author)
addIntIfNotNull(TlvTypes.KIND, kind) addIntIfNotNull(TlvTypes.KIND, kind)
} }.build()
.build()
.toNEvent() .toNEvent()
}
fun createNEmbed(event: Event): String { fun createNProfile(
return gzip(event.toJson()).toNEmbed() authorPubKeyHex: String,
} relay: List<String>,
): String =
TlvBuilder()
.apply {
addHex(TlvTypes.SPECIAL, authorPubKeyHex)
relay.forEach {
addStringIfNotNull(TlvTypes.RELAY, it)
}
}.build()
.toNProfile()
fun createNEmbed(event: Event): String = gzip(event.toJson()).toNEmbed()
fun gzip(content: String): ByteArray { fun gzip(content: String): ByteArray {
val bos = ByteArrayOutputStream() val bos = ByteArrayOutputStream()
@@ -239,23 +275,24 @@ fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.
fun ByteArray.toNEvent() = Bech32.encodeBytes(hrp = "nevent", this, Bech32.Encoding.Bech32) fun ByteArray.toNEvent() = Bech32.encodeBytes(hrp = "nevent", this, Bech32.Encoding.Bech32)
fun ByteArray.toNProfile() = Bech32.encodeBytes(hrp = "nprofile", this, Bech32.Encoding.Bech32)
fun ByteArray.toNAddress() = Bech32.encodeBytes(hrp = "naddr", this, Bech32.Encoding.Bech32) fun ByteArray.toNAddress() = Bech32.encodeBytes(hrp = "naddr", this, Bech32.Encoding.Bech32)
fun ByteArray.toLnUrl() = Bech32.encodeBytes(hrp = "lnurl", this, Bech32.Encoding.Bech32) fun ByteArray.toLnUrl() = Bech32.encodeBytes(hrp = "lnurl", this, Bech32.Encoding.Bech32)
fun ByteArray.toNEmbed() = Bech32.encodeBytes(hrp = "nembed", this, Bech32.Encoding.Bech32) fun ByteArray.toNEmbed() = Bech32.encodeBytes(hrp = "nembed", this, Bech32.Encoding.Bech32)
fun decodePublicKey(key: String): ByteArray { fun decodePublicKey(key: String): ByteArray =
return when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) {
is Nip19Bech32.NSec -> KeyPair(privKey = key.bechToBytes()).pubKey is Nip19Bech32.NSec -> KeyPair(privKey = key.bechToBytes()).pubKey
is Nip19Bech32.NPub -> parsed.hex.hexToByteArray() is Nip19Bech32.NPub -> parsed.hex.hexToByteArray()
is Nip19Bech32.NProfile -> parsed.hex.hexToByteArray() is Nip19Bech32.NProfile -> parsed.hex.hexToByteArray()
else -> Hex.decode(key) // crashes on purpose else -> Hex.decode(key) // crashes on purpose
} }
}
fun decodePrivateKeyAsHexOrNull(key: String): HexKey? { fun decodePrivateKeyAsHexOrNull(key: String): HexKey? =
return try { try {
when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) {
is Nip19Bech32.NSec -> parsed.hex is Nip19Bech32.NSec -> parsed.hex
is Nip19Bech32.NPub -> null is Nip19Bech32.NPub -> null
@@ -271,10 +308,9 @@ fun decodePrivateKeyAsHexOrNull(key: String): HexKey? {
if (e is CancellationException) throw e if (e is CancellationException) throw e
null null
} }
}
fun decodePublicKeyAsHexOrNull(key: String): HexKey? { fun decodePublicKeyAsHexOrNull(key: String): HexKey? =
return try { try {
when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) {
is Nip19Bech32.NSec -> KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey() is Nip19Bech32.NSec -> KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey()
is Nip19Bech32.NPub -> parsed.hex is Nip19Bech32.NPub -> parsed.hex
@@ -290,10 +326,9 @@ fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
if (e is CancellationException) throw e if (e is CancellationException) throw e
null null
} }
}
fun decodeEventIdAsHexOrNull(key: String): HexKey? { fun decodeEventIdAsHexOrNull(key: String): HexKey? =
return try { try {
when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) { when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) {
is Nip19Bech32.NSec -> null is Nip19Bech32.NSec -> null
is Nip19Bech32.NPub -> null is Nip19Bech32.NPub -> null
@@ -309,7 +344,6 @@ fun decodeEventIdAsHexOrNull(key: String): HexKey? {
if (e is CancellationException) throw e if (e is CancellationException) throw e
null null
} }
}
fun TlvBuilder.addString( fun TlvBuilder.addString(
type: Nip19Bech32.TlvTypes, type: Nip19Bech32.TlvTypes,