Merge branch 'main' into new_package_relay

This commit is contained in:
greenart7c3 2024-06-24 11:16:03 -03:00
commit c281654e97
No known key found for this signature in database
GPG Key ID: 885822EED3A26A6D
33 changed files with 772 additions and 489 deletions

View File

@ -178,9 +178,7 @@ object Client : RelayPool.Listener {
subscriptions = subscriptions.minus(subscriptionId)
}
fun isActive(subscriptionId: String): Boolean {
return subscriptions.contains(subscriptionId)
}
fun isActive(subscriptionId: String): Boolean = subscriptions.contains(subscriptionId)
@OptIn(DelicateCoroutinesApi::class)
override fun onEvent(
@ -204,9 +202,9 @@ object Client : RelayPool.Listener {
) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
}
// GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
// }
}
@OptIn(DelicateCoroutinesApi::class)
@ -281,21 +279,15 @@ object Client : RelayPool.Listener {
listeners = listeners.plus(listener)
}
fun isSubscribed(listener: Listener): Boolean {
return listeners.contains(listener)
}
fun isSubscribed(listener: Listener): Boolean = listeners.contains(listener)
fun unsubscribe(listener: Listener) {
listeners = listeners.minus(listener)
}
fun allSubscriptions(): Map<String, List<TypedFilter>> {
return subscriptions
}
fun allSubscriptions(): Map<String, List<TypedFilter>> = subscriptions
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
return subscriptions[subId] ?: emptyList()
}
fun getSubscriptionFilters(subId: String): List<TypedFilter> = subscriptions[subId] ?: emptyList()
abstract class Listener {
/** A new message was received */

View File

@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 379
versionName "0.88.2"
versionCode 381
versionName "0.88.4"
buildConfigField "String", "RELEASE_NOTES_ID", "\"2a34cbadd03212c8162e1ff896ba12641821088a2ec8d5e40d54aa80c0510800\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

View File

@ -328,10 +328,10 @@ object LocalPreferences {
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion)
if (account.showSensitiveContent == null) {
if (account.showSensitiveContent.value == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent.value!!)
}
putString(
@ -597,7 +597,7 @@ object LocalPreferences {
backupContactList = latestContactList,
proxy = proxy,
proxyPort = proxyPort,
showSensitiveContent = showSensitiveContent,
showSensitiveContent = MutableStateFlow(showSensitiveContent),
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,

View File

@ -26,7 +26,6 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap
@ -195,7 +194,7 @@ class Account(
var backupContactList: ContactListEvent? = null,
var proxy: Proxy? = null,
var proxyPort: Int = 9050,
var showSensitiveContent: Boolean? = null,
var showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
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()),
val scope: CoroutineScope = Amethyst.instance.applicationIOScope,
) {
var transientHiddenUsers: Set<String> = setOf()
var transientHiddenUsers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf())
data class PaymentRequest(
val relayUrl: String,
@ -239,6 +238,8 @@ class Account(
getPrivateOutboxRelayListFlow(),
userProfile().flow().relays.stateFlow,
) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile ->
checkNotInMainThread()
val baseRelaySet = activeRelays() ?: convertLocalRelays()
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
@ -395,6 +396,7 @@ class Account(
@OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<LiveFollowLists> =
userProfile().flow().follows.stateFlow.transformLatest {
checkNotInMainThread()
emit(
LiveFollowLists(
it.user.cachedFollowingKeySet(),
@ -431,6 +433,7 @@ class Account(
peopleListFollowsSource: Flow<ListNameNotePair>,
): Flow<LiveFollowLists?> =
combineTransform(kind3FollowsSource, peopleListFollowsSource) { kind3Follows, peopleListFollows ->
checkNotInMainThread()
if (peopleListFollows.listName == GLOBAL_FOLLOWS) {
emit(null)
} else if (peopleListFollows.listName == KIND3_FOLLOWS) {
@ -537,10 +540,11 @@ class Account(
val flowHiddenUsers: StateFlow<LiveHiddenUsers> by lazy {
combineTransform(
live.asFlow(),
transientHiddenUsers,
showSensitiveContent,
getBlockListNote().flow().metadata.stateFlow,
getMuteListNote().flow().metadata.stateFlow,
) { localLive, blockList, muteList ->
) { transientHiddenUsers, showSensitiveContent, blockList, muteList ->
checkNotInMainThread()
val resultBlockList =
@ -569,8 +573,8 @@ class Account(
LiveHiddenUsers(
hiddenUsers = (resultBlockList.users + resultMuteList.users),
hiddenWords = hiddenWords,
spammers = localLive.account.transientHiddenUsers,
showSensitiveContent = localLive.account.showSensitiveContent,
spammers = transientHiddenUsers,
showSensitiveContent = showSensitiveContent,
),
)
}.stateIn(
@ -579,8 +583,8 @@ class Account(
LiveHiddenUsers(
hiddenUsers = setOf(),
hiddenWords = setOf(),
spammers = transientHiddenUsers,
showSensitiveContent = showSensitiveContent,
spammers = transientHiddenUsers.value,
showSensitiveContent = showSensitiveContent.value,
),
)
}
@ -633,7 +637,9 @@ class Account(
filterSpamFromStrangers = filterSpam
LocalCache.antiSpam.active = filterSpamFromStrangers
if (!filterSpamFromStrangers) {
transientHiddenUsers = setOf()
transientHiddenUsers.update {
emptySet()
}
}
live.invalidateData()
saveable.invalidateData()
@ -2414,7 +2420,9 @@ class Account(
}
}
transientHiddenUsers = (transientHiddenUsers - pubkeyHex)
transientHiddenUsers.update {
it - pubkeyHex
}
live.invalidateData()
saveable.invalidateData()
}
@ -2926,7 +2934,9 @@ class Account(
}
fun updateShowSensitiveContent(show: Boolean?) {
showSensitiveContent = show
showSensitiveContent.update {
show
}
saveable.invalidateData()
live.invalidateData()
}
@ -2965,10 +2975,12 @@ class Account(
// imports transient blocks due to spam.
LocalCache.antiSpam.liveSpam.observeForever {
GlobalScope.launch(Dispatchers.IO) {
it.cache.spamMessages.snapshot().values.forEach {
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = (transientHiddenUsers + it.pubkeyHex)
it.cache.spamMessages.snapshot().values.forEach { spammer ->
if (spammer.pubkeyHex !in transientHiddenUsers.value && spammer.duplicatedMessages.size >= 5) {
if (spammer.pubkeyHex != userProfile().pubkeyHex && spammer.pubkeyHex !in followingKeySet()) {
transientHiddenUsers.update {
it + spammer.pubkeyHex
}
live.invalidateData()
}
}

View File

@ -241,33 +241,25 @@ object LocalCache {
return users.get(key)
}
fun getAddressableNoteIfExists(key: String): AddressableNote? {
return addressables.get(key)
}
fun getAddressableNoteIfExists(key: String): AddressableNote? = addressables.get(key)
fun getNoteIfExists(key: String): Note? {
return addressables.get(key) ?: notes.get(key)
}
fun getNoteIfExists(key: String): Note? = addressables.get(key) ?: notes.get(key)
fun getChannelIfExists(key: String): Channel? {
return channels.get(key)
}
fun getChannelIfExists(key: String): Channel? = channels.get(key)
fun getNoteIfExists(event: Event): Note? {
return if (event is AddressableEvent) {
fun getNoteIfExists(event: Event): Note? =
if (event is AddressableEvent) {
getAddressableNoteIfExists(event.addressTag())
} else {
getNoteIfExists(event.id)
}
}
fun getOrCreateNote(event: Event): Note {
return if (event is AddressableEvent) {
fun getOrCreateNote(event: Event): Note =
if (event is AddressableEvent) {
getOrCreateAddressableNote(event.address())
} else {
getOrCreateNote(event.id)
}
}
fun checkGetOrCreateNote(key: String): Note? {
checkNotInMainThread()
@ -348,8 +340,8 @@ object LocalCache {
return HexValidator.isHex(key)
}
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? {
return try {
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? =
try {
val addr = ATag.parse(key, null) // relay doesn't matter for the index.
if (addr != null) {
getOrCreateAddressableNote(addr)
@ -360,7 +352,6 @@ object LocalCache {
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
null
}
}
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
// checkNotInMainThread()
@ -395,6 +386,10 @@ object LocalCache {
val newUserMetadata = event.contactMetaData()
if (newUserMetadata != null) {
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}")
} else {
@ -428,11 +423,11 @@ object LocalCache {
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp)
fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
}
fun consume(
event: TextNoteEvent,
@ -755,8 +750,8 @@ object LocalCache {
}
}
fun computeReplyTo(event: Event): List<Note> {
return when (event) {
fun computeReplyTo(event: Event): List<Note> =
when (event) {
is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is WikiNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
@ -805,7 +800,6 @@ object LocalCache {
else -> emptyList<Note>()
}
}
fun consume(
event: PollNoteEvent,
@ -1218,7 +1212,8 @@ object LocalCache {
if (deletionIndex.add(event)) {
var deletedAtLeastOne = false
event.deleteEvents()
event
.deleteEvents()
.mapNotNull { getNoteIfExists(it) }
.forEach { deleteNote ->
// must be the same author
@ -2030,16 +2025,16 @@ object LocalCache {
suspend fun findStatusesForUser(user: User): ImmutableList<AddressableNote> {
checkNotInMainThread()
return addressables.filter { _, it ->
val noteEvent = it.event
(
noteEvent is StatusEvent &&
noteEvent.pubKey == user.pubkeyHex &&
!noteEvent.isExpired() &&
noteEvent.content.isNotBlank()
)
}
.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex }))
return addressables
.filter { _, it ->
val noteEvent = it.event
(
noteEvent is StatusEvent &&
noteEvent.pubKey == user.pubkeyHex &&
!noteEvent.isExpired() &&
noteEvent.content.isNotBlank()
)
}.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex }))
.reversed()
.toImmutableList()
}
@ -2066,9 +2061,7 @@ object LocalCache {
val modificationCache = LruCache<HexKey, List<Note>>(20)
fun cachedModificationEventsForNote(note: Note): List<Note>? {
return modificationCache[note.idHex]
}
fun cachedModificationEventsForNote(note: Note): List<Note>? = modificationCache[note.idHex]
suspend fun findLatestModificationForNote(note: Note): List<Note> {
checkNotInMainThread()
@ -2082,11 +2075,12 @@ object LocalCache {
val time = TimeUtils.now()
val newNotes =
notes.filter { _, item ->
val noteEvent = item.event
notes
.filter { _, item ->
val noteEvent = item.event
noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
modificationCache.put(note.idHex, newNotes)
@ -2210,9 +2204,11 @@ object LocalCache {
note.event is GenericRepostEvent
) &&
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
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) !=
true // don't delete if it's a notification to the logged in user
}
@ -2308,8 +2304,7 @@ object LocalCache {
?.hiddenUsers
?.map { userHex ->
(notes.filter { _, it -> it.event?.pubKey() == userHex } + addressables.filter { _, it -> it.event?.pubKey() == userHex }).toSet()
}
?.flatten()
}?.flatten()
?: emptyList()
toBeRemoved.forEach {
@ -2596,8 +2591,8 @@ object LocalCache {
}
}
fun hasConsumed(notificationEvent: Event): Boolean {
return if (notificationEvent is AddressableEvent) {
fun hasConsumed(notificationEvent: Event): Boolean =
if (notificationEvent is AddressableEvent) {
val note = addressables.get(notificationEvent.addressTag())
val noteEvent = note?.event
noteEvent != null && notificationEvent.createdAt <= noteEvent.createdAt()
@ -2605,7 +2600,6 @@ object LocalCache {
val note = notes.get(notificationEvent.id)
note?.event != null
}
}
}
@Stable
@ -2617,8 +2611,7 @@ class LocalCacheLiveData {
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) {
bundledNewNotes ->
bundler.invalidateList(newNote) { bundledNewNotes ->
_newEventBundles.emit(bundledNewNotes)
}
}

View File

@ -62,7 +62,7 @@ enum class ConnectivityType(
val resourceId: Int,
) {
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),
}

View File

@ -34,7 +34,9 @@ import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Lud06
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ContactListEvent
@ -49,10 +51,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
@Stable
class User(val pubkeyHex: String) {
class User(
val pubkeyHex: String,
) {
var info: UserMetadata? = null
var latestMetadata: MetadataEvent? = null
var latestMetadataRelay: String? = null
var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null
@ -76,7 +81,16 @@ class User(val pubkeyHex: String) {
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
@ -96,17 +110,11 @@ class User(val pubkeyHex: String) {
return firstName
}
fun toBestDisplayName(): String {
return info?.bestName() ?: pubkeyDisplayHex()
}
fun toBestDisplayName(): String = info?.bestName() ?: pubkeyDisplayHex()
fun nip05(): String? {
return info?.nip05
}
fun nip05(): String? = info?.nip05
fun profilePicture(): String? {
return info?.picture
}
fun profilePicture(): String? = info?.picture
fun updateBookmark(event: BookmarkListEvent) {
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 new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData()
LocalCache
.getUserIfExists(it)
?.liveSet
?.innerFollowers
?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData()
LocalCache
.getUserIfExists(it)
?.liveSet
?.innerFollowers
?.invalidateData()
}
liveSet?.innerRelays?.invalidateData()
@ -198,25 +214,19 @@ class User(val pubkeyHex: String) {
return amount
}
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
}
fun reportsBy(user: User): Set<Note> = reports[user] ?: emptySet()
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.count { it.key.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<HexKey>): Int = reports.count { it.key.pubkeyHex in users }
fun reportsBy(users: Set<HexKey>): List<Note> {
return reports
fun reportsBy(users: Set<HexKey>): List<Note> =
reports
.mapNotNull {
if (it.key.pubkeyHex in users) {
it.value
} else {
null
}
}
.flatten()
}
}.flatten()
@Synchronized
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
@ -235,9 +245,7 @@ class User(val pubkeyHex: String) {
return getOrCreatePrivateChatroom(key)
}
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom {
return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
}
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom = privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
fun addMessage(
room: ChatroomKey,
@ -326,13 +334,9 @@ class User(val pubkeyHex: String) {
liveSet?.innerMetadata?.invalidateData()
}
fun isFollowing(user: User): Boolean {
return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
}
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.isTaggedHash(tag) ?: false
}
fun isFollowingHashtag(tag: String): Boolean = latestContactList?.isTaggedHash(tag) ?: false
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
@ -362,37 +366,21 @@ class User(val pubkeyHex: String) {
?: false
}
fun transientFollowCount(): Int? {
return latestContactList?.unverifiedFollowKeySet()?.size
}
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
suspend fun transientFollowerCount(): Int {
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun cachedFollowingKeySet(): Set<HexKey> {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowingKeySet(): Set<HexKey> = latestContactList?.verifiedFollowKeySet ?: emptySet()
fun cachedFollowingTagSet(): Set<String> {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
fun cachedFollowingTagSet(): Set<String> = latestContactList?.verifiedFollowTagSet ?: emptySet()
fun cachedFollowingGeohashSet(): Set<HexKey> {
return latestContactList?.verifiedFollowGeohashSet ?: emptySet()
}
fun cachedFollowingGeohashSet(): Set<HexKey> = latestContactList?.verifiedFollowGeohashSet ?: emptySet()
fun cachedFollowingCommunitiesSet(): Set<HexKey> {
return latestContactList?.verifiedFollowCommunitySet ?: emptySet()
}
fun cachedFollowingCommunitiesSet(): Set<HexKey> = latestContactList?.verifiedFollowCommunitySet ?: emptySet()
fun cachedFollowCount(): Int? {
return latestContactList?.verifiedFollowKeySet?.size
}
fun cachedFollowCount(): Int? = latestContactList?.verifiedFollowKeySet?.size
suspend fun cachedFollowerCount(): Int {
return LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
suspend fun cachedFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
@ -403,16 +391,13 @@ class User(val pubkeyHex: String) {
fun hasReport(
loggedIn: User,
type: ReportEvent.ReportType,
): Boolean {
return reports[loggedIn]?.firstOrNull {
): Boolean =
reports[loggedIn]?.firstOrNull {
it.event is ReportEvent &&
(it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
} != null
}
fun anyNameStartsWith(username: String): Boolean {
return info?.anyNameStartsWith(username) ?: false
}
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
var liveSet: UserLiveSet? = null
var flowSet: UserFlowSet? = null
@ -473,14 +458,14 @@ class User(val pubkeyHex: String) {
}
@Stable
class UserFlowSet(u: User) {
class UserFlowSet(
u: User,
) {
// Observers line up here.
val follows = UserBundledRefresherFlow(u)
val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean {
return relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
}
fun isInUse(): Boolean = relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
fun destroy() {
relays.destroy()
@ -489,7 +474,9 @@ class UserFlowSet(u: User) {
}
@Stable
class UserLiveSet(u: User) {
class UserLiveSet(
u: User,
) {
val innerMetadata = UserBundledRefresherLiveData(u)
// UI Observers line up here.
@ -521,8 +508,8 @@ class UserLiveSet(u: User) {
val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged()
fun isInUse(): Boolean {
return metadata.hasObservers() ||
fun isInUse(): Boolean =
metadata.hasObservers() ||
follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
@ -535,7 +522,6 @@ class UserLiveSet(u: User) {
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
userMetadataInfo.hasObservers()
}
fun destroy() {
innerMetadata.destroy()
@ -558,7 +544,9 @@ data class RelayInfo(
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.
private val bundler = BundledUpdate(500, Dispatchers.IO)
@ -585,7 +573,9 @@ class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserSta
}
@Stable
class UserBundledRefresherFlow(val user: User) {
class UserBundledRefresherFlow(
val user: User,
) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
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() {
super.onActive()
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,
)

View File

@ -186,8 +186,10 @@ class MainActivity : AppCompatActivity() {
}
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean {
val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
val isOnMobileData = !unmetered || networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val isOnWifi = unmetered && networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
var changedNetwork = false

View File

@ -20,12 +20,22 @@
*/
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.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box
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.graphics.graphicsLayer
import androidx.compose.ui.util.fastForEach
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@ -33,16 +43,86 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
fun <T> CrossfadeIfEnabled(
targetState: T,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
animationSpec: FiniteAnimationSpec<Float> = tween(),
label: String = "Crossfade",
accountViewModel: AccountViewModel,
content: @Composable (T) -> Unit,
) {
if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) {
Box(modifier) {
Box(modifier, contentAlignment) {
content(targetState)
}
} 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()
}
}
}
}

View File

@ -100,10 +100,10 @@ class NewMessageTagger(
val results = parseDirtyWordForKey(word)
when (val entity = results?.key?.entity) {
is Nip19Bech32.NPub -> {
getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord)
getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord)
}
is Nip19Bech32.NProfile -> {
getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord)
getNostrAddress(dao.getOrCreateUser(entity.hex).toNProfile(), results.restOfWord)
}
is Nip19Bech32.Note -> {
@ -138,17 +138,15 @@ class NewMessageTagger(
word
}
}
}
.joinToString(" ")
}
.joinToString("\n")
}.joinToString(" ")
}.joinToString("\n")
}
fun getNostrAddress(
bechAddress: String,
restOfTheWord: String?,
): String {
return if (restOfTheWord.isNullOrEmpty()) {
): String =
if (restOfTheWord.isNullOrEmpty()) {
"nostr:$bechAddress"
} else {
if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) {
@ -157,9 +155,11 @@ class NewMessageTagger(
"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? {
var key = mightBeAKey

View File

@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
@ -88,7 +89,7 @@ enum class UserSuggestionAnchor {
}
@Stable
open class NewPostViewModel() : ViewModel() {
open class NewPostViewModel : ViewModel() {
var draftTag: String by mutableStateOf(UUID.randomUUID().toString())
var accountViewModel: AccountViewModel? = null
@ -175,17 +176,11 @@ open class NewPostViewModel() : ViewModel() {
val draftTextChanges = Channel<String>(Channel.CONFLATED)
fun lnAddress(): String? {
return account?.userProfile()?.info?.lnAddress()
}
fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress()
fun hasLnAddress(): Boolean {
return account?.userProfile()?.info?.lnAddress() != null
}
fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null
fun user(): User? {
return account?.userProfile()
}
fun user(): User? = account?.userProfile()
open fun load(
accountViewModel: AccountViewModel,
@ -368,15 +363,21 @@ open class NewPostViewModel() : ViewModel() {
}
originalNote =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" }.map {
LocalCache.checkGetOrCreateNote(it[1])
}.firstOrNull()
draftEvent
.tags()
.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" }
.map {
LocalCache.checkGetOrCreateNote(it[1])
}.firstOrNull()
if (originalNote == null) {
originalNote =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" }.map {
LocalCache.checkGetOrCreateNote(it[1])
}.firstOrNull()
draftEvent
.tags()
.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
@ -403,12 +404,45 @@ open class NewPostViewModel() : ViewModel() {
wantsProduct = draftEvent.kind() == 30402
title = TextFieldValue(draftEvent.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() ?: "")
title =
TextFieldValue(
draftEvent
.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 {
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
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent
@ -479,7 +513,8 @@ open class NewPostViewModel() : ViewModel() {
if (split.percentage > 0.00001) {
val homeRelay =
accountViewModel?.getRelayListFor(split.key)?.writeRelays()?.firstOrNull()
?: split.key.relaysBeingUsed.keys.firstOrNull { !it.contains("localhost") }
?: split.key.relaysBeingUsed.keys
.firstOrNull { !it.contains("localhost") }
ZapSplitSetup(
lnAddressOrPubKeyHex = split.key.pubkeyHex,
@ -851,9 +886,7 @@ open class NewPostViewModel() : ViewModel() {
}
}
open fun findUrlInMessage(): String? {
return RichTextParser().parseValidUrls(message.text).firstOrNull()
}
open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull()
open fun removeFromReplyList(userToRemove: User) {
pTags = pTags?.filter { it != userToRemove }
@ -869,14 +902,18 @@ open class NewPostViewModel() : ViewModel() {
if (it.selection.collapsed) {
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
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
LocalCache
.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
@ -894,14 +931,18 @@ open class NewPostViewModel() : ViewModel() {
if (it.selection.collapsed) {
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
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
LocalCache
.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }, { it.pubkeyHex }))
.reversed()
}
@ -928,15 +969,15 @@ open class NewPostViewModel() : ViewModel() {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
LocalCache
.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(
compareBy(
{ account?.isFollowing(it) },
{ it.toBestDisplayName() },
{ it.pubkeyHex },
),
)
.reversed()
).reversed()
}
} else {
NostrSearchEventOrUserDataSource.clear()
@ -949,7 +990,10 @@ open class NewPostViewModel() : ViewModel() {
userSuggestionAnchor?.let {
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
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 wordToInsert = "@${item.pubkeyNpub()}"
@ -963,7 +1007,10 @@ open class NewPostViewModel() : ViewModel() {
forwardZapToEditting = TextFieldValue("")
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
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 wordToInsert = "@${item.pubkeyNpub()}"
@ -972,6 +1019,9 @@ open class NewPostViewModel() : ViewModel() {
toUsers.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length),
)
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelays()
nip17 = relayList != null
}
userSuggestionAnchor = null
@ -982,12 +1032,10 @@ open class NewPostViewModel() : ViewModel() {
saveDraft()
}
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> {
return mutableStateMapOf(Pair(0, ""), Pair(1, ""))
}
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> = mutableStateMapOf(Pair(0, ""), Pair(1, ""))
fun canPost(): Boolean {
return message.text.isNotBlank() &&
fun canPost(): Boolean =
message.text.isNotBlank() &&
!isUploadingImage &&
!wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) &&
@ -1009,7 +1057,6 @@ open class NewPostViewModel() : ViewModel() {
)
) &&
contentToAddUrl == null
}
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
@ -1020,11 +1067,20 @@ open class NewPostViewModel() : ViewModel() {
// Images don't seem to be ready immediately after upload
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
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 =
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 =
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 =
uploadingResult.tags
?.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_1250_X_625(2), // 1,250km × 625km
KM_156_X_156(3), // 156km × 156km

View File

@ -20,7 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ripple.rememberRipple
@ -33,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size24dp
@Composable
fun ClickableBox(
modifier: Modifier,
modifier: Modifier = Modifier,
onClick: () -> Unit,
content: @Composable () -> Unit,
) {
@ -49,3 +51,25 @@ fun ClickableBox(
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()
}
}

View File

@ -39,7 +39,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@ -97,10 +97,9 @@ fun SensitivityWarning(
accountViewModel: AccountViewModel,
content: @Composable () -> Unit,
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val accountState = accountViewModel.account.showSensitiveContent.collectAsStateWithLifecycle()
var showContentWarningNote by
remember(accountState) { mutableStateOf(accountState?.account?.showSensitiveContent != true) }
var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) }
CrossfadeIfEnabled(targetState = showContentWarningNote, accountViewModel = accountViewModel) {
if (it) {
@ -118,18 +117,26 @@ fun ContentWarningNote(onDismiss: () -> Unit) {
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Box(
Modifier.height(80.dp).width(90.dp),
Modifier
.height(80.dp)
.width(90.dp),
) {
Icon(
imageVector = Icons.Default.Visibility,
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,
)
Icon(
imageVector = Icons.Rounded.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,
)
}

View File

@ -77,6 +77,7 @@ fun TextSpinner(
readOnly = true,
label = { label?.let { Text(it) } },
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,
)

View File

@ -26,17 +26,15 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import kotlinx.coroutines.CancellationException
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
class HiddenAccountsFeedFilter(
val account: Account,
) : FeedFilter<User>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun showHiddenKey(): Boolean {
return true
}
override fun showHiddenKey(): Boolean = true
override fun feed(): List<User> {
return account.flowHiddenUsers.value.hiddenUsers.reversed().mapNotNull {
override fun feed(): List<User> =
account.flowHiddenUsers.value.hiddenUsers.reversed().mapNotNull {
try {
LocalCache.getOrCreateUser(it)
} catch (e: Exception) {
@ -45,33 +43,26 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
null
}
}
}
}
class HiddenWordsFeedFilter(val account: Account) : FeedFilter<String>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
class HiddenWordsFeedFilter(
val account: Account,
) : FeedFilter<String>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun showHiddenKey(): Boolean {
return true
}
override fun showHiddenKey(): Boolean = true
override fun feed(): List<String> {
return account.flowHiddenUsers.value.hiddenWords.toList()
}
override fun feed(): List<String> =
account.flowHiddenUsers.value.hiddenWords
.toList()
}
class SpammerAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
class SpammerAccountsFeedFilter(
val account: Account,
) : FeedFilter<User>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun showHiddenKey(): Boolean {
return true
}
override fun showHiddenKey(): Boolean = true
override fun feed(): List<User> {
return (account.transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
}
override fun feed(): List<User> = account.transientHiddenUsers.value.map { LocalCache.getOrCreateUser(it) }
}

View File

@ -145,10 +145,12 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
@ -758,16 +760,18 @@ class FollowListViewModel(
private val _kind3GlobalPeopleRoutes =
combineTransform(livePeopleListsFlow, liveKind3FollowsFlow) { myLivePeopleListsFlow, myLiveKind3FollowsFlow ->
checkNotInMainThread()
emit(
listOf(listOf(kind3Follow, globalFollow), myLivePeopleListsFlow, myLiveKind3FollowsFlow, listOf(muteListFollow))
.flatten()
.toImmutableList(),
)
}
val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists)
val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists)
private val _kind3GlobalPeople =
combineTransform(livePeopleListsFlow, liveKind3FollowsFlow) { myLivePeopleListsFlow, myLiveKind3FollowsFlow ->
checkNotInMainThread()
emit(
listOf(listOf(kind3Follow, globalFollow), myLivePeopleListsFlow, listOf(muteListFollow))
.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(
val account: Account,

View File

@ -168,8 +168,7 @@ fun DrawerContent(
BottomContent(
accountViewModel.account.userProfile(),
drawerState,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
accountViewModel,
nav,
)
}
@ -741,8 +740,7 @@ fun IconRowRelays(
fun BottomContent(
user: User,
drawerState: DrawerState,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
@ -800,8 +798,7 @@ fun BottomContent(
if (dialogOpen) {
ShowQRDialog(
user,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
accountViewModel,
onScan = {
dialogOpen = false
coroutineScope.launch { drawerState.close() }

View File

@ -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.VolumeOff
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.Cancel
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.Report
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.Bolt
import androidx.compose.material.icons.outlined.PlayCircle
@ -164,7 +166,20 @@ fun LikedIcon(
) {
Icon(
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,
tint = tint,
)

View File

@ -310,7 +310,7 @@ fun DisplayStatus(
}
@Composable
private fun DisplayNIP05(
fun DisplayNIP05(
nip05: String,
nip05Verified: MutableState<Boolean?>,
accountViewModel: AccountViewModel,

View File

@ -84,6 +84,7 @@ import androidx.core.graphics.ColorUtils
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
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.FileHeaderEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private fun lightenColor(
@ -110,6 +110,10 @@ private fun lightenColor(
return Color(argb)
}
val externalLinkForUser = { user: User ->
"https://njump.me/${user.toNProfile()}"
}
val externalLinkForNote = { note: Note ->
if (note is AddressableNote) {
if (note.event?.getReward() != null) {
@ -298,10 +302,12 @@ private fun RenderMainPopup(
Icons.Default.AlternateEmail,
stringRes(R.string.quick_action_copy_user_id),
) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
note.author?.let {
scope.launch {
clipboardManager.setText(AnnotatedString(it.toNostrUri()))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
}
}
VerticalDivider(color = primaryLight)
@ -309,8 +315,8 @@ private fun RenderMainPopup(
Icons.Default.FormatQuote,
stringRes(R.string.quick_action_copy_note_id),
) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
scope.launch {
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}

View File

@ -35,7 +35,6 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Button
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.text.SpanStyle
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.IntOffset
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.core.content.ContextCompat
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.Font14SP
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.NoSoTinyBorders
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.Size22Modifier
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.mediumImportanceLink
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.events.BaseTextNoteEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
@ -817,7 +824,6 @@ fun BoostText(
SlidingAnimationCount(boostState, grayTint, accountViewModel)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LikeReaction(
baseNote: Note,
@ -831,29 +837,21 @@ fun LikeReaction(
var wantsToChangeReactionSymbol by remember { mutableStateOf(false) }
var wantsToReact by remember { mutableStateOf(false) }
Box(
contentAlignment = Center,
modifier =
Modifier
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = Size24dp),
onClick = {
likeClick(
accountViewModel,
baseNote,
onMultipleChoices = { wantsToReact = true },
onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) },
)
},
onLongClick = { wantsToChangeReactionSymbol = true },
),
ClickableBox(
onClick = {
likeClick(
accountViewModel,
baseNote,
onMultipleChoices = { wantsToReact = true },
onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) },
)
},
onLongClick = { wantsToChangeReactionSymbol = true },
) {
ObserveLikeIcon(baseNote, accountViewModel) { reactionType ->
CrossfadeIfEnabled(targetState = reactionType, label = "LikeIcon", accountViewModel = accountViewModel) {
if (it != null) {
RenderReactionType(it, heartSizeModifier, iconFontSize)
CrossfadeIfEnabled(targetState = reactionType, contentAlignment = Center, label = "LikeIcon", accountViewModel = accountViewModel) {
if (reactionType != null) {
RenderReactionType(reactionType, heartSizeModifier, iconFontSize)
} else {
LikeIcon(heartSizeModifier, grayTint)
}
@ -1307,7 +1305,6 @@ private fun BoostTypeChoicePopup(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReactionChoicePopup(
baseNote: Note,
@ -1320,6 +1317,7 @@ fun ReactionChoicePopup(
val account = accountState?.account ?: return
val toRemove = remember { baseNote.reactedBy(account.userProfile()).toImmutableSet() }
val reactions = remember { account.reactionChoices.toImmutableList() }
val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() }
@ -1328,108 +1326,139 @@ fun ReactionChoicePopup(
offset = IntOffset(0, iconSizePx),
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(
Modifier
.border(width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = RoundedCornerShape(5.dp)),
shape = SmallBorder,
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Box(modifier = Modifier.padding(5.dp)) {
FlowRow(horizontalArrangement = Arrangement.Center) {
account.reactionChoices.forEach { reactionType ->
ActionableReactionButton(
baseNote,
reactionType,
accountViewModel,
onDismiss,
onChangeAmount,
toRemove,
)
}
FlowRow(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
) {
listOfReactions.forEach { reactionType ->
ActionableReactionButton(
reactionType = reactionType,
onClick = { onClick(reactionType) },
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
@OptIn(ExperimentalFoundationApi::class)
private fun ActionableReactionButton(
baseNote: Note,
reactionType: String,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
onClick: () -> Unit,
onChangeAmount: () -> Unit,
toRemove: ImmutableSet<String>,
) {
val thisModifier =
remember(reactionType) {
Modifier
.padding(horizontal = 3.dp)
.combinedClickable(
onClick = {
accountViewModel.reactToOrDelete(
baseNote,
reactionType,
)
onDismiss()
},
onLongClick = { onChangeAmount() },
)
.padding(5.dp)
}
val removeSymbol =
remember(reactionType) {
if (reactionType in toRemove) {
""
} else {
""
}
}
ClickableBox(
modifier = if (reactionType in toRemove) MaterialTheme.colorScheme.selectedReactionBoxModifier else reactionBox,
onClick = onClick,
onLongClick = onChangeAmount,
) {
RenderReaction(reactionType)
}
}
@Composable
fun RenderReaction(reactionType: String) {
if (reactionType.startsWith(":")) {
val noStartColon = reactionType.removePrefix(":")
val url = noStartColon.substringAfter(":")
val renderable =
InLineIconRenderer(
persistentListOf(
Nip30CustomEmoji.ImageUrlType(url),
Nip30CustomEmoji.TextType(removeSymbol),
)
InLineIconRenderer(
renderable,
style = SpanStyle(color = Color.White),
),
style = SpanStyle(color = MaterialTheme.colorScheme.onBackground),
maxLines = 1,
modifier = thisModifier,
fontSize = 22.sp,
)
} else {
when (reactionType) {
"+" -> {
LikedIcon(modifier = thisModifier.size(16.dp), tint = Color.White)
LikedIcon(modifier = Size28Modifier)
}
"-" -> {
Text(
text = removeSymbol,
color = Color.White,
textAlign = TextAlign.Center,
modifier = thisModifier,
text = "\uD83D\uDC4E",
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
fontSize = 22.sp,
)
}
"-" ->
else -> {
Text(
text = "\uD83D\uDC4E$removeSymbol",
color = Color.White,
textAlign = TextAlign.Center,
modifier = thisModifier,
)
else ->
Text(
"$reactionType$removeSymbol",
color = Color.White,
textAlign = TextAlign.Center,
modifier = thisModifier,
reactionType,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
fontSize = 22.sp,
)
}
}
}
}

View File

@ -277,8 +277,7 @@ private fun RenderReactionOption(
width = 1.dp,
color = MaterialTheme.colorScheme.surfaceDim,
shape = RoundedCornerShape(8.dp),
)
.padding(8.dp),
).padding(8.dp),
) {
if (reactionType.startsWith(":")) {
val noStartColon = reactionType.removePrefix(":")
@ -298,13 +297,15 @@ private fun RenderReactionOption(
} else {
when (reactionType) {
"+" -> {
LikedIcon(modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onBackground)
Row {
LikedIcon(modifier = Modifier.size(20.dp), tint = Color.Unspecified)
Text(
text = "",
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
Text(
text = "",
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
}
}
"-" ->
Text(

View File

@ -197,9 +197,11 @@ fun NoteDropDownMenu(
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_user_pubkey)) },
onClick = {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
onDismiss()
note.author?.let {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
onDismiss()
}
}
},
)
@ -207,7 +209,7 @@ fun NoteDropDownMenu(
text = { Text(stringRes(R.string.copy_note_id)) },
onClick = {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent()))
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
onDismiss()
}
},
@ -412,7 +414,7 @@ fun WatchBookmarksFollowsAndAccount(
isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note),
isLoggedUser = accountViewModel.isLoggedUser(note.author),
isSensitive = note.event?.isSensitive() ?: false,
showSensitiveContent = showSensitiveContent,
showSensitiveContent = showSensitiveContent.value,
)
launch(Dispatchers.Main) {

View File

@ -45,16 +45,22 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
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.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.theme.Size35dp
import com.vitorpamplona.quartz.events.UserMetadata
@ -62,21 +68,20 @@ import com.vitorpamplona.quartz.events.UserMetadata
@Preview
@Composable
fun ShowQRDialogPreview() {
val user = User("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
user.info =
val accountViewModel = mockAccountViewModel()
accountViewModel.userProfile().info =
UserMetadata().apply {
name = "My Name"
picture = "Picture"
nip05 = null
banner = "http://banner.com/test"
website = "http://mywebsite.com/test"
about = "This is the about me"
}
ShowQRDialog(
user = user,
loadProfilePicture = false,
loadRobohash = false,
user = accountViewModel.userProfile(),
accountViewModel = accountViewModel,
onScan = {},
onClose = {},
)
@ -85,8 +90,7 @@ fun ShowQRDialogPreview() {
@Composable
fun ShowQRDialog(
user: User,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
accountViewModel: AccountViewModel,
onScan: (String) -> Unit,
onClose: () -> Unit,
) {
@ -126,8 +130,8 @@ fun ShowQRDialog(
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colorScheme.background, CircleShape),
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
)
}
Row(
@ -141,13 +145,34 @@ fun ShowQRDialog(
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(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = Size35dp),
) {
QrCodeDrawer("nostr:${user.pubkeyNpub()}")
QrCodeDrawer(user.toNostrUri())
}
Row(modifier = Modifier.padding(horizontal = 30.dp)) {

View File

@ -111,6 +111,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.joinAll
@ -308,22 +309,19 @@ class AccountViewModel(
noteIsHiddenFlows.get(note)
?: combineTransform(
account.flowHiddenUsers,
account.liveKind3FollowsFlow,
account.liveKind3Follows,
note.flow().metadata.stateFlow,
note.flow().reports.stateFlow,
) { hiddenUsers, followingUsers, metadata, reports ->
val isAcceptable =
withContext(Dispatchers.IO) {
isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.users)
}
emit(isAcceptable)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
NoteComposeReportState(),
).also {
noteIsHiddenFlows.put(note, it)
}
emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.users))
}.flowOn(Dispatchers.Default)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
NoteComposeReportState(),
).also {
noteIsHiddenFlows.put(note, it)
}
private val noteMustShowExpandButtonFlows = LruCache<Note, StateFlow<Boolean>>(300)

View File

@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Intent
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
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.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
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.LightningAddressIcon
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.qrcode.ShowQRDialog
import com.vitorpamplona.amethyst.ui.screen.FeedState
@ -996,9 +999,8 @@ private fun DrawAdditionalInfo(
if (dialogOpen) {
ShowQRDialog(
user,
accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
user = user,
accountViewModel = accountViewModel,
onScan = {
dialogOpen = false
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) {
HorizontalDivider(thickness = DividerThickness)
if (accountViewModel.account.isHidden(user)) {

View File

@ -135,6 +135,7 @@ val Size22Modifier = Modifier.size(22.dp)
val Size24Modifier = Modifier.size(24.dp)
val Size25Modifier = Modifier.size(25.dp)
val Size26Modifier = Modifier.size(26.dp)
val Size28Modifier = Modifier.size(28.dp)
val Size30Modifier = Modifier.size(30.dp)
val Size35Modifier = Modifier.size(35.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 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)

View File

@ -94,18 +94,12 @@ private val LightLessImportantLink = LightColorPalette.primary.copy(alpha = 0.52
private val DarkMediumImportantLink = DarkColorPalette.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 LightGrayText = LightColorPalette.onSurface.copy(alpha = 0.52f)
private val DarkPlaceholderText = DarkColorPalette.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 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 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 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 =
Modifier
.fillMaxWidth()
@ -179,6 +162,22 @@ val LightInnerPostBorderModifier =
.clip(shape = 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 =
Modifier
.size(30.dp)
@ -225,16 +224,6 @@ val DarkLargeRelayIconModifier =
.size(Size55dp)
.clip(shape = CircleShape)
val LightBottomIconModifier =
Modifier
.size(Size10dp)
.clip(shape = CircleShape)
val DarkBottomIconModifier =
Modifier
.size(Size10dp)
.clip(shape = CircleShape)
val RichTextDefaults = RichTextStyle().resolveDefaults()
val MarkDownStyleOnDark =
@ -319,9 +308,6 @@ val ColorScheme.isLight: Boolean
val ColorScheme.newItemBackgroundColor: Color
get() = if (isLight) LightNewItemBackground else DarkNewItemBackground
val ColorScheme.replyBackground: Color
get() = if (isLight) LightReplyItemBackground else DarkReplyItemBackground
val ColorScheme.selectedNote: Color
get() = if (isLight) LightSelectedNote else DarkSelectedNote
@ -331,13 +317,8 @@ val ColorScheme.secondaryButtonBackground: Color
val ColorScheme.lessImportantLink: Color
get() = if (isLight) LightLessImportantLink else DarkLessImportantLink
val ColorScheme.zapraiserBackground: Color
get() = if (isLight) LightZapraiserBackground else DarkZapraiserBackground
val ColorScheme.mediumImportanceLink: Color
get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink
val ColorScheme.veryImportantLink: Color
get() = if (isLight) LightVeryImportantLink else DarkVeryImportantLink
val ColorScheme.placeholderText: Color
get() = if (isLight) LightPlaceholderText else DarkPlaceholderText
@ -345,9 +326,6 @@ val ColorScheme.placeholderText: Color
val ColorScheme.nip05: Color
get() = if (isLight) Nip05EmailColorLight else Nip05EmailColorDark
val ColorScheme.placeholderTextColorFilter: ColorFilter
get() = if (isLight) LightPlaceholderTextColorFilter else DarkPlaceholderTextColorFilter
val ColorScheme.onBackgroundColorFilter: ColorFilter
get() = if (isLight) LightOnBackgroundColorFilter else DarkOnBackgroundColorFilter
@ -375,9 +353,6 @@ val ColorScheme.allGoodColor: Color
val ColorScheme.markdownStyle: RichTextStyle
get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark
val ColorScheme.repostProfileBorder: Modifier
get() = if (isLight) RepostPictureBorderLight else RepostPictureBorderDark
val ColorScheme.imageModifier: Modifier
get() = if (isLight) LightImageModifier else DarkImageModifier
@ -402,8 +377,8 @@ val ColorScheme.relayIconModifier: Modifier
val ColorScheme.largeRelayIconModifier: Modifier
get() = if (isLight) LightLargeRelayIconModifier else DarkLargeRelayIconModifier
val ColorScheme.bottomIconModifier: Modifier
get() = if (isLight) LightBottomIconModifier else DarkBottomIconModifier
val ColorScheme.selectedReactionBoxModifier: Modifier
get() = if (isLight) LightSelectedReactionBoxModifier else DarkSelectedReactionBoxModifier
val ColorScheme.chartStyle: ChartStyle
get() {

View File

@ -451,7 +451,7 @@
<string name="connectivity_type_never">कभी नहीं</string>
<string name="ui_feature_set_type_complete">सम्पूर्ण</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="light">प्रकाशवान</string>
<string name="dark">अन्धकारमय</string>

View File

@ -451,6 +451,7 @@
<string name="connectivity_type_never">从不</string>
<string name="ui_feature_set_type_complete">完整版</string>
<string name="ui_feature_set_type_simplified">简化版</string>
<string name="ui_feature_set_type_performance">极速版</string>
<string name="system">系统</string>
<string name="light">浅色</string>
<string name="dark">深色</string>

View File

@ -511,6 +511,7 @@
<string name="settings">Settings</string>
<string name="connectivity_type_always">Always</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="ui_feature_set_type_complete">Complete</string>
@ -785,6 +786,8 @@
<string name="like_description">Like</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="relay_info">Relay %1$s</string>
<string name="expand_relay_list">Expand relay list</string>

View File

@ -14,7 +14,7 @@ coil = "2.6.0"
composeBom = "2024.06.00"
coreKtx = "1.13.1"
espressoCore = "3.5.1"
firebaseBom = "33.1.0"
firebaseBom = "33.1.1"
fragmentKtx = "1.8.0"
gms = "4.4.1"
jacksonModuleKotlin = "2.17.1"

View File

@ -39,7 +39,9 @@ object Nip19Bech32 {
ADDRESS,
}
enum class TlvTypes(val id: Byte) {
enum class TlvTypes(
val id: Byte,
) {
SPECIAL(0),
RELAY(1),
AUTHOR(2),
@ -53,33 +55,59 @@ object Nip19Bech32 {
)
@Immutable
data class ParseReturn(val entity: Entity, val additionalChars: String? = null)
data class ParseReturn(
val entity: Entity,
val additionalChars: String? = null,
)
interface Entity
@Immutable
data class NSec(val hex: String) : Entity
data class NSec(
val hex: String,
) : Entity
@Immutable
data class NPub(val hex: String) : Entity
data class NPub(
val hex: String,
) : Entity
@Immutable
data class Note(val hex: String) : Entity
data class Note(
val hex: String,
) : Entity
@Immutable
data class NProfile(val hex: String, val relay: List<String>) : Entity
data class NProfile(
val hex: String,
val relay: List<String>,
) : Entity
@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
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
data class NRelay(val relay: List<String>) : Entity
data class NRelay(
val relay: List<String>,
) : Entity
@Immutable
data class NEmbed(val event: Event) : Entity
data class NEmbed(
val event: Event,
) : Entity
fun uriToRoute(uri: String?): ParseReturn? {
if (uri == null) return null
@ -108,8 +136,8 @@ object Nip19Bech32 {
type: String,
key: String?,
additionalChars: String?,
): ParseReturn? {
return try {
): ParseReturn? =
try {
val bytes = (type + key).bechToBytes()
when (type.lowercase()) {
@ -129,7 +157,6 @@ object Nip19Bech32 {
Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
null
}
}
private fun nembed(bytes: ByteArray): NEmbed? {
if (bytes.isEmpty()) return null
@ -205,21 +232,30 @@ object Nip19Bech32 {
author: String?,
kind: Int?,
relay: String?,
): String {
return TlvBuilder()
): String =
TlvBuilder()
.apply {
addHex(TlvTypes.SPECIAL, idHex)
addStringIfNotNull(TlvTypes.RELAY, relay)
addHexIfNotNull(TlvTypes.AUTHOR, author)
addIntIfNotNull(TlvTypes.KIND, kind)
}
.build()
}.build()
.toNEvent()
}
fun createNEmbed(event: Event): String {
return gzip(event.toJson()).toNEmbed()
}
fun createNProfile(
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 {
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.toNProfile() = Bech32.encodeBytes(hrp = "nprofile", 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.toNEmbed() = Bech32.encodeBytes(hrp = "nembed", this, Bech32.Encoding.Bech32)
fun decodePublicKey(key: String): ByteArray {
return when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) {
fun decodePublicKey(key: String): ByteArray =
when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) {
is Nip19Bech32.NSec -> KeyPair(privKey = key.bechToBytes()).pubKey
is Nip19Bech32.NPub -> parsed.hex.hexToByteArray()
is Nip19Bech32.NProfile -> parsed.hex.hexToByteArray()
else -> Hex.decode(key) // crashes on purpose
}
}
fun decodePrivateKeyAsHexOrNull(key: String): HexKey? {
return try {
fun decodePrivateKeyAsHexOrNull(key: String): HexKey? =
try {
when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) {
is Nip19Bech32.NSec -> parsed.hex
is Nip19Bech32.NPub -> null
@ -271,10 +308,9 @@ fun decodePrivateKeyAsHexOrNull(key: String): HexKey? {
if (e is CancellationException) throw e
null
}
}
fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
return try {
fun decodePublicKeyAsHexOrNull(key: String): HexKey? =
try {
when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) {
is Nip19Bech32.NSec -> KeyPair(privKey = key.bechToBytes()).pubKey.toHexKey()
is Nip19Bech32.NPub -> parsed.hex
@ -290,10 +326,9 @@ fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
if (e is CancellationException) throw e
null
}
}
fun decodeEventIdAsHexOrNull(key: String): HexKey? {
return try {
fun decodeEventIdAsHexOrNull(key: String): HexKey? =
try {
when (val parsed = Nip19Bech32.uriToRoute(key)?.entity) {
is Nip19Bech32.NSec -> null
is Nip19Bech32.NPub -> null
@ -309,7 +344,6 @@ fun decodeEventIdAsHexOrNull(key: String): HexKey? {
if (e is CancellationException) throw e
null
}
}
fun TlvBuilder.addString(
type: Nip19Bech32.TlvTypes,