Merge upstream/main into nrobi144/phase-2A

Resolved import conflicts caused by package reorganization:
- upstream moved classes to amethyst/service/relayClient/*
- upstream moved models to commons/model/*
- kept our desktop additions (zaps, bookmarks, search)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-01-21 06:04:13 +02:00
co-authored by Claude Opus 4.5
244 changed files with 3194 additions and 1352 deletions
+3
View File
@@ -348,6 +348,9 @@ dependencies {
// Image compression lib
implementation libs.zelory.image.compressor
// Voice anonymization DSP
implementation libs.tarsosdsp
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
@@ -26,8 +26,11 @@ import android.content.pm.ApplicationInfo
import android.os.Debug
import androidx.core.content.getSystemService
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlin.time.DurationUnit
import kotlin.time.measureTimedValue
@@ -228,3 +231,12 @@ inline fun debug(
Log.d(tag, debugMessage())
}
}
fun Event.countMemory(): Int =
7 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit)
12 + // createdAt + kind
id.bytesUsedInMemory() +
pubKey.bytesUsedInMemory() +
tags.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } +
content.bytesUsedInMemory() +
sig.bytesUsedInMemory()
@@ -24,15 +24,21 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState
import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction
import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListDecryptionCache
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountHomeRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState
@@ -46,11 +52,6 @@ import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState
import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListDecryptionCache
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.model.nip38UserStatuses.UserStatusAction
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
@@ -77,7 +78,6 @@ import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListD
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
import com.vitorpamplona.amethyst.model.nip56Reports.ReportAction
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
@@ -571,7 +571,8 @@ class Account(
suspend fun report(
user: User,
type: ReportType,
) = sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, userProfile(), signer))
content: String = "",
) = sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, content, userProfile(), signer))
suspend fun delete(note: Note) = delete(listOf(note))
@@ -1658,7 +1659,7 @@ class Account(
fun isAllHidden(users: Set<HexKey>): Boolean = users.all { isHidden(it) }
fun isHidden(user: User) = isHidden(user.pubkeyHex)
override fun isHidden(user: User) = isHidden(user.pubkeyHex)
fun isHidden(userHex: String): Boolean = hiddenUsers.flow.value.isUserHidden(userHex)
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -143,7 +145,8 @@ class AccountSettings(
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
) {
) : EphemeralChatRepository,
PublicChatListRepository {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
@@ -389,7 +392,9 @@ class AccountSettings(
}
}
fun updateChannelListTo(newChannelList: ChannelListEvent?) {
override fun channelList() = backupChannelList
override fun updateChannelListTo(newChannelList: ChannelListEvent?) {
if (newChannelList == null || newChannelList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
@@ -429,7 +434,9 @@ class AccountSettings(
}
}
fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
override fun ephemeralChatList() = backupEphemeralChatList
override fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
@@ -106,12 +106,11 @@ class AntiSpamFilter {
recentAddressables.put(hash, address)
} else {
// normal event
val existingEvent = recentEventIds[hash]
if (
(recentEventIds[hash] != null && recentEventIds[hash] != event.id) ||
(existingEvent != null && existingEvent != event.id) ||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id))
) {
val existingEvent = recentEventIds[hash]
val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay))
val link2 = njumpLink(NEvent.create(event.id, null, null, relay))
@@ -23,17 +23,16 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.IChannel
import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
import com.vitorpamplona.amethyst.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
@@ -49,6 +48,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
@@ -324,7 +324,7 @@ object LocalCache : ILocalCache, ICacheProvider {
return users.get(key)
}
override fun countUsers(predicate: (String, Any) -> Boolean): Int {
override fun countUsers(predicate: (String, User) -> Boolean): Int {
var count = 0
users.forEach { key, user ->
if (predicate(key, user)) count++
@@ -332,17 +332,6 @@ object LocalCache : ILocalCache, ICacheProvider {
return count
}
override fun getAnyChannel(note: Any?): IChannel? {
val channelNote = note as? Note ?: return null
val channel = getAnyChannel(channelNote)
// Wrap Channel to implement IChannel interface
return channel?.let {
object : IChannel {
override fun relays(): List<Any>? = it.relays().toList()
}
}
}
fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) }
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
@@ -470,7 +459,7 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = addressables.getOrCreate(key) { AddressableNote(key) }
fun getOrCreateAddressableNote(key: Address): AddressableNote {
override fun getOrCreateAddressableNote(key: Address): AddressableNote {
val note = getOrCreateAddressableNoteInternal(key)
// Loads the user outside a Syncronized block to avoid blocking
if (note.author == null) {
@@ -531,9 +520,13 @@ object LocalCache : ILocalCache, ICacheProvider {
// avoids processing empty contact lists.
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty() && (wasVerified || justVerify(event))) {
user.updateContactList(event)
val needsToUpdateFollowers = user.updateContactList(event)
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
needsToUpdateFollowers.forEach {
getUserIfExists(it)?.flowSet?.followers?.invalidateData()
}
updateObservables(event)
return true
@@ -700,6 +693,51 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NipTextEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
val isVerified =
if (version.event == null && (wasVerified || justVerify(event))) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
true
} else {
wasVerified
}
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event?.id == event.id) return wasVerified
if (antiSpam.isSpam(event, relay)) {
return false
}
if (isVerified || justVerify(event)) {
val replyTo = computeReplyTo(event)
if (event.createdAt > (note.createdAt() ?: 0L)) {
note.loadEvent(event, author, replyTo)
refreshNewNoteObservers(note)
return true
}
}
return false
}
fun consume(
event: LongTextNoteEvent,
relay: NormalizedRelayUrl?,
@@ -794,7 +832,6 @@ object LocalCache : ILocalCache, ICacheProvider {
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) }
is GitReplyEvent -> event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
@@ -1361,7 +1398,7 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
fun getAnyChannel(note: Note): Channel? = note.event?.let { getAnyChannel(it) }
override fun getAnyChannel(note: Note): Channel? = note.event?.let { getAnyChannel(it) }
fun getAnyChannel(noteEvent: Event): Channel? =
when (noteEvent) {
@@ -2091,6 +2128,10 @@ object LocalCache : ILocalCache, ICacheProvider {
}
return notes.filter { _, note ->
if (note.event is AddressableEvent) {
return@filter false
}
if (excludeNoteEventFromSearchResults(note)) {
return@filter false
}
@@ -2677,7 +2718,7 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
fun justConsumeMyOwnEvent(event: Event) = justConsumeAndUpdateIndexes(event, null, true)
override fun justConsumeMyOwnEvent(event: Event) = justConsumeAndUpdateIndexes(event, null, true)
fun justConsume(
event: Event,
@@ -2888,6 +2929,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is MetadataEvent -> consume(event, relay, wasVerified)
is MuteListEvent -> consume(event, relay, wasVerified)
is NNSEvent -> consume(event, relay, wasVerified)
is NipTextEvent -> consume(event, relay, wasVerified)
is OtsEvent -> consume(event, relay, wasVerified)
is PictureEvent -> consume(event, relay, wasVerified)
is PrivateDmEvent -> consume(event, relay, wasVerified)
@@ -24,6 +24,4 @@ package com.vitorpamplona.amethyst.model
typealias Note = com.vitorpamplona.amethyst.commons.model.Note
typealias NotesGatherer = com.vitorpamplona.amethyst.commons.model.NotesGatherer
typealias AddressableNote = com.vitorpamplona.amethyst.commons.model.AddressableNote
typealias NoteFlowSet = com.vitorpamplona.amethyst.commons.model.NoteFlowSet
typealias NoteBundledRefresherFlow = com.vitorpamplona.amethyst.commons.model.NoteBundledRefresherFlow
typealias NoteState = com.vitorpamplona.amethyst.commons.model.NoteState
@@ -21,9 +21,6 @@
package com.vitorpamplona.amethyst.model
// Re-export from commons for backwards compatibility
typealias UserDependencies = com.vitorpamplona.amethyst.commons.model.UserDependencies
typealias User = com.vitorpamplona.amethyst.commons.model.User
typealias UserFlowSet = com.vitorpamplona.amethyst.commons.model.UserFlowSet
typealias RelayInfo = com.vitorpamplona.amethyst.commons.model.RelayInfo
typealias UserBundledRefresherFlow = com.vitorpamplona.amethyst.commons.model.UserBundledRefresherFlow
typealias UserState = com.vitorpamplona.amethyst.commons.model.UserState
@@ -30,8 +30,8 @@ import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.base64Image.Base64Image
import com.vitorpamplona.amethyst.commons.base64Image.toBitmap
import com.vitorpamplona.amethyst.commons.richtext.Base64Image
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -24,11 +24,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ChannelState
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.ChannelState
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.filterChannelMetadataUpdatesById
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities.filterLiveStreamUpdatesByAddress
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
@@ -25,17 +25,18 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import kotlinx.collections.immutable.ImmutableList
@@ -249,15 +250,12 @@ fun observeUserFollowCount(
remember(user) {
user
.flow()
.followers.stateFlow
.sample(200)
.mapLatest { userState ->
userState.user.transientFollowCount() ?: 0
}.distinctUntilChanged()
.follows.stateFlow
.mapLatest { it.user.transientFollowCount() ?: 0 }
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(0)
return flow.collectAsStateWithLifecycle(user.transientFollowCount() ?: 0)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -395,7 +393,9 @@ fun observeUserFollowerCount(
.followers.stateFlow
.sample(200)
.mapLatest { userState ->
userState.user.transientFollowerCount()
LocalCache.countUsers { _, user ->
user.latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
}
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
@@ -81,6 +82,7 @@ val SearchPostsByTextKinds3 =
InteractiveStoryPrologueEvent.KIND,
InteractiveStorySceneEvent.KIND,
FollowListEvent.KIND,
NipTextEvent.KIND,
)
fun searchPostsByText(
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
/**
* Listens to NostrClient's onNotify messages from the relay
@@ -47,7 +48,7 @@ class RelaySpeedLogger(
msg: Message,
) {
if (msg is EventMessage) {
current.increment(msg.event.kind, msg.subId, relay.url, msg.event.countMemory())
current.increment(msg.event.kind, msg.subId, relay.url, msgStr.bytesUsedInMemory())
}
}
}
@@ -37,30 +37,34 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.ClickAndHoldBoxComposable
import com.vitorpamplona.amethyst.ui.components.ToggleableBox
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
const val MAX_VOICE_RECORD_SECONDS = 600
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun RecordAudioBox(
modifier: Modifier,
onRecordTaken: (RecordingResult) -> Unit,
maxDurationSeconds: Int? = null,
content: @Composable (Boolean, Int) -> Unit,
) {
val mediaRecorder = remember { mutableStateOf<VoiceMessageRecorder?>(null) }
val context = LocalContext.current
var elapsedSeconds by remember { mutableIntStateOf(0) }
var wantsToRecord by remember { mutableStateOf(false) }
var pendingPermissionStart by remember { mutableStateOf(false) }
// Must be called at Composable scope, not in callback
val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val scope = rememberCoroutineScope()
val isRecording = mediaRecorder.value != null
DisposableEffect(Unit) {
onDispose {
wantsToRecord = false
mediaRecorder.value?.stop()
mediaRecorder.value = null
}
@@ -74,8 +78,25 @@ fun RecordAudioBox(
}
}
LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) {
if (recordPermissionState.status.isGranted && wantsToRecord) {
fun stopRecording() {
val result = mediaRecorder.value?.stop()
mediaRecorder.value = null
if (result != null) {
onRecordTaken(result)
} else {
Toast
.makeText(
context,
stringRes(context, R.string.record_a_message_description),
Toast.LENGTH_SHORT,
).show()
}
}
// Start recording after permission is granted
LaunchedEffect(recordPermissionState.status.isGranted) {
if (recordPermissionState.status.isGranted && pendingPermissionStart) {
pendingPermissionStart = false
startRecording()
}
}
@@ -89,6 +110,10 @@ fun RecordAudioBox(
while (isActive) {
delay(1000)
elapsedSeconds++
if (maxDurationSeconds != null && elapsedSeconds >= maxDurationSeconds) {
stopRecording()
break
}
}
} else {
// Reset elapsed time when not recording
@@ -96,38 +121,21 @@ fun RecordAudioBox(
}
}
ClickAndHoldBoxComposable(
ToggleableBox(
modifier = modifier,
onPress = {
wantsToRecord = true
if (!recordPermissionState.status.isGranted) {
recordPermissionState.launchPermissionRequest()
isActive = isRecording,
onClick = {
if (isRecording) {
stopRecording()
} else {
// Start immediately for responsive UX when permission already granted
startRecording()
if (!recordPermissionState.status.isGranted) {
pendingPermissionStart = true
recordPermissionState.launchPermissionRequest()
} else {
startRecording()
}
}
},
onRelease = {
wantsToRecord = false
val result = mediaRecorder.value?.stop()
mediaRecorder.value = null
if (result != null) {
onRecordTaken(result)
} else {
// less disruptive than error messages
Toast
.makeText(
context,
stringRes(context, R.string.record_a_message_description),
Toast.LENGTH_SHORT,
).show()
}
},
onCancel = {
wantsToRecord = false
mediaRecorder.value?.stop()
mediaRecorder.value = null
},
content = @Composable { isRecording -> content(isRecording, elapsedSeconds) },
content = { active -> content(active, elapsedSeconds) },
)
}
@@ -42,7 +42,10 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
fun RecordVoiceButton(
onVoiceTaken: (RecordingResult) -> Unit,
maxDurationSeconds: Int? = null,
) {
var isRecording by remember { mutableStateOf(false) }
var elapsedSeconds by remember { mutableIntStateOf(0) }
@@ -61,6 +64,7 @@ fun RecordVoiceButton(onVoiceTaken: (RecordingResult) -> Unit) {
elapsedSeconds = 0
onVoiceTaken(recording)
},
maxDurationSeconds = maxDurationSeconds,
) { recordingState, elapsed ->
// Update parent state after composition completes
SideEffect {
@@ -35,7 +35,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -206,8 +206,8 @@ fun FloatingRecordingIndicator(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = innerPadding),
) {
// Pulsing red dot
val infiniteTransition = rememberInfiniteTransition(label = "recording_dot")
// Pulsing stop square
val infiniteTransition = rememberInfiniteTransition(label = "recording_stop")
val dotAlpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.5f,
@@ -220,7 +220,7 @@ fun FloatingRecordingIndicator(
)
Icon(
imageVector = Icons.Default.FiberManualRecord,
imageVector = Icons.Default.Stop,
contentDescription = recordingLabel,
tint = Color.White,
modifier =
@@ -0,0 +1,130 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.File
class VoiceAnonymizationController(
private val scope: CoroutineScope,
private val logTag: String,
private val onError: (Throwable) -> Unit,
) {
var selectedPreset: VoicePreset by mutableStateOf(VoicePreset.NONE)
private set
var processingPreset: VoicePreset? by mutableStateOf(null)
private set
var distortedFiles: Map<VoicePreset, AnonymizedResult> by mutableStateOf(emptyMap())
private set
private var processingJob: Job? = null
fun activeFile(originalFile: File?): File? =
if (selectedPreset == VoicePreset.NONE) {
originalFile
} else {
distortedFiles[selectedPreset]?.file
}
fun activeWaveform(originalWaveform: List<Float>?): List<Float>? =
if (selectedPreset == VoicePreset.NONE) {
originalWaveform
} else {
distortedFiles[selectedPreset]?.waveform
}
fun selectPreset(
preset: VoicePreset,
originalFile: File?,
) {
Log.d(logTag, "selectPreset called with: ${preset.name}, pitchFactor: ${preset.pitchFactor}")
if (processingPreset != null || preset == selectedPreset) return
if (preset == VoicePreset.NONE) {
selectedPreset = preset
return
}
if (distortedFiles.containsKey(preset)) {
selectedPreset = preset
return
}
val file = originalFile ?: return
processingJob?.cancel()
processingPreset = preset
processingJob =
scope.launch {
try {
val anonymizer = VoiceAnonymizer()
val result = anonymizer.anonymize(file, preset)
result
.onSuccess { anonymizedResult ->
distortedFiles = distortedFiles + (preset to anonymizedResult)
selectedPreset = preset
}.onFailure { error ->
Log.w(logTag, "Failed to anonymize voice", error)
onError(error)
}
} finally {
processingPreset = null
processingJob = null
}
}
}
fun clear() {
cancelProcessing()
deleteDistortedFiles()
selectedPreset = VoicePreset.NONE
}
fun deleteDistortedFiles() {
distortedFiles.values.forEach { result ->
try {
if (result.file.exists()) {
if (result.file.delete()) {
Log.d(logTag, "Deleted distorted file: ${result.file.absolutePath}")
} else {
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}")
}
}
} catch (e: Exception) {
Log.w(logTag, "Failed to delete distorted file: ${result.file.absolutePath}", e)
}
}
distortedFiles = emptyMap()
}
private fun cancelProcessing() {
processingJob?.cancel()
processingJob = null
processingPreset = null
}
}
@@ -0,0 +1,78 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size5dp
@Composable
fun VoiceAnonymizationSection(
selectedPreset: VoicePreset,
processingPreset: VoicePreset?,
onPresetSelected: (VoicePreset) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
) {
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(thickness = DividerThickness)
Spacer(modifier = Modifier.height(12.dp))
Column(
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.voice_anonymize_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.voice_anonymize_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.height(8.dp))
VoicePresetSelector(
selectedPreset = selectedPreset,
processingPreset = processingPreset,
onPresetSelected = onPresetSelected,
)
}
}
@@ -0,0 +1,492 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import android.util.Log
import be.tarsos.dsp.AudioDispatcher
import be.tarsos.dsp.AudioEvent
import be.tarsos.dsp.AudioProcessor
import be.tarsos.dsp.WaveformSimilarityBasedOverlapAdd
import be.tarsos.dsp.io.TarsosDSPAudioFloatConverter
import be.tarsos.dsp.io.TarsosDSPAudioFormat
import be.tarsos.dsp.resample.RateTransposer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.ByteOrder
import kotlin.math.abs
/**
* Result of voice anonymization processing.
*
* @property file The output audio file (AAC in MP4 container)
* @property waveform Amplitude data for waveform visualization (one value per second)
* @property duration Audio duration in seconds
*/
data class AnonymizedResult(
val file: File,
val waveform: List<Float>,
val duration: Int,
)
/**
* Processes audio files to alter voice characteristics for privacy.
*
* Uses TarsosDSP's WSOLA (Waveform Similarity Overlap-Add) algorithm combined with
* rate transposition to shift pitch while preserving duration. Note that in TarsosDSP,
* pitch factors work inversely: factor < 1 raises pitch, factor > 1 lowers pitch.
*/
class VoiceAnonymizer {
companion object {
private const val TAG = "VoiceAnonymizer"
private const val CHANNELS = 1
private const val BIT_RATE = 128000
}
/**
* Applies voice anonymization to an audio file.
*
* The process involves three stages:
* 1. Decode input audio to PCM (0-30% progress)
* 2. Apply pitch shifting with TarsosDSP (30-70% progress)
* 3. Encode processed audio to AAC (70-100% progress)
*
* @param inputFile Source audio file (supports formats decodable by MediaCodec)
* @param preset Voice transformation preset (NONE is not allowed)
* @param onProgress Callback invoked with progress value from 0.0 to 1.0
* @return [Result.success] with [AnonymizedResult] containing the output file,
* waveform data, and duration; or [Result.failure] with the exception
*/
suspend fun anonymize(
inputFile: File,
preset: VoicePreset,
onProgress: (Float) -> Unit = {},
): Result<AnonymizedResult> =
withContext(Dispatchers.IO) {
if (preset == VoicePreset.NONE) {
return@withContext Result.failure(
IllegalArgumentException("Cannot anonymize with NONE preset"),
)
}
try {
val outputFile = createOutputFile(inputFile, preset)
val (pcmData, sampleRate, duration) =
decodeAudioToPcm(inputFile) { progress ->
onProgress(progress * 0.3f)
}
val processedPcm =
processPcmWithTarsos(pcmData, preset, sampleRate) { progress ->
onProgress(0.3f + progress * 0.4f)
}
val waveform = extractWaveform(processedPcm, sampleRate)
encodePcmToAac(processedPcm, sampleRate, outputFile) { progress ->
onProgress(0.7f + progress * 0.3f)
}
onProgress(1f)
Result.success(AnonymizedResult(outputFile, waveform, duration))
} catch (e: Exception) {
Log.e(TAG, "Failed to anonymize audio", e)
Result.failure(e)
}
}
private fun createOutputFile(
inputFile: File,
preset: VoicePreset,
): File {
val baseName = inputFile.nameWithoutExtension
val presetSuffix = preset.name.lowercase()
val parentDir = inputFile.parentFile ?: inputFile.absoluteFile.parentFile
return File(parentDir, "${baseName}_$presetSuffix.mp4")
}
private data class DecodedAudio(
val pcmData: FloatArray,
val sampleRate: Int,
val duration: Int,
)
private suspend fun decodeAudioToPcm(
inputFile: File,
onProgress: (Float) -> Unit,
): DecodedAudio {
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
try {
extractor.setDataSource(inputFile.absolutePath)
var audioTrackIndex = -1
var format: MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val trackFormat = extractor.getTrackFormat(i)
val mime = trackFormat.getString(MediaFormat.KEY_MIME)
if (mime?.startsWith("audio/") == true) {
audioTrackIndex = i
format = trackFormat
break
}
}
check(audioTrackIndex != -1 && format != null) { "No audio track found in file" }
extractor.selectTrack(audioTrackIndex)
val mime = format.getString(MediaFormat.KEY_MIME) ?: "audio/mp4a-latm"
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
val durationUs = format.getLong(MediaFormat.KEY_DURATION)
val duration = (durationUs / 1_000_000).toInt()
decoder = MediaCodec.createDecoderByType(mime)
decoder.configure(format, null, null, 0)
decoder.start()
val estimatedSamples = (sampleRate.toLong() * durationUs / 1_000_000).toInt()
val pcmSamples = ArrayList<Float>(estimatedSamples)
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
var outputDone = false
while (!outputDone && currentCoroutineContext().isActive) {
if (!inputDone) {
val inputBufferIndex = decoder.dequeueInputBuffer(10000)
if (inputBufferIndex >= 0) {
val inputBuffer = decoder.getInputBuffer(inputBufferIndex)!!
val sampleSize = extractor.readSampleData(inputBuffer, 0)
if (sampleSize < 0) {
decoder.queueInputBuffer(
inputBufferIndex,
0,
0,
0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
)
inputDone = true
} else {
val presentationTimeUs = extractor.sampleTime
decoder.queueInputBuffer(
inputBufferIndex,
0,
sampleSize,
presentationTimeUs,
0,
)
extractor.advance()
if (durationUs > 0) {
onProgress((presentationTimeUs.toFloat() / durationUs).coerceIn(0f, 1f))
}
}
}
}
val outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 10000)
if (outputBufferIndex >= 0) {
val outputBuffer = decoder.getOutputBuffer(outputBufferIndex)!!
val shortBuffer = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer()
while (shortBuffer.hasRemaining()) {
pcmSamples.add(shortBuffer.get() / 32768f)
}
decoder.releaseOutputBuffer(outputBufferIndex, false)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
outputDone = true
}
}
}
return DecodedAudio(pcmSamples.toFloatArray(), sampleRate, duration)
} finally {
try {
decoder?.stop()
} catch (_: IllegalStateException) {
// Decoder was never started
}
decoder?.release()
extractor.release()
}
}
private fun processPcmWithTarsos(
pcmData: FloatArray,
preset: VoicePreset,
sampleRate: Int,
onProgress: (Float) -> Unit,
): FloatArray {
val baseFactor = preset.pitchFactor
val factor =
when (preset) {
VoicePreset.DEEP, VoicePreset.HIGH -> {
// Add ±10% random variation
val randomShift = 0.9 + (Math.random() * 0.2)
baseFactor * randomShift
}
else -> baseFactor
}
val totalSamples = pcmData.size
val processedSamples = ArrayList<Float>(totalSamples)
val wsola =
WaveformSimilarityBasedOverlapAdd(
WaveformSimilarityBasedOverlapAdd.Parameters.musicDefaults(
factor,
sampleRate.toDouble(),
),
)
val rateTransposer = RateTransposer(factor)
val bufferSize = wsola.inputBufferSize
val overlap = wsola.overlap
val tarsosDspFormat =
TarsosDSPAudioFormat(
sampleRate.toFloat(),
16,
1,
true,
false,
)
val collector =
object : AudioProcessor {
override fun process(audioEvent: AudioEvent): Boolean {
val buffer = audioEvent.floatBuffer
for (i in 0 until audioEvent.bufferSize) {
processedSamples.add(buffer[i])
}
return true
}
override fun processingFinished() {
// No-op: no cleanup needed
}
}
val dispatcher =
AudioDispatcher(
FloatArrayAudioInputStream(pcmData, tarsosDspFormat, pcmData.size.toLong()),
bufferSize,
overlap,
)
wsola.setDispatcher(dispatcher)
dispatcher.addAudioProcessor(wsola)
dispatcher.addAudioProcessor(rateTransposer)
dispatcher.addAudioProcessor(collector)
var samplesProcessed = 0
val progressProcessor =
object : AudioProcessor {
override fun process(audioEvent: AudioEvent): Boolean {
samplesProcessed += audioEvent.bufferSize
onProgress((samplesProcessed.toFloat() / totalSamples).coerceIn(0f, 1f))
return true
}
override fun processingFinished() {
// No-op: no cleanup needed
}
}
dispatcher.addAudioProcessor(progressProcessor)
dispatcher.run()
return processedSamples.toFloatArray()
}
private fun extractWaveform(
pcmData: FloatArray,
sampleRate: Int,
): List<Float> {
val waveform = mutableListOf<Float>()
var offset = 0
while (offset < pcmData.size) {
val end = minOf(offset + sampleRate, pcmData.size)
var maxAmplitude = 0f
for (i in offset until end) {
val amplitude = abs(pcmData[i])
if (amplitude > maxAmplitude) {
maxAmplitude = amplitude
}
}
waveform.add(maxAmplitude * 32768f)
offset += sampleRate
}
return waveform
}
private fun encodePcmToAac(
pcmData: FloatArray,
sampleRate: Int,
outputFile: File,
onProgress: (Float) -> Unit,
) {
val format =
MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, CHANNELS)
format.setInteger(
MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC,
)
format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
var muxerStarted = false
try {
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
encoder.start()
var audioTrackIndex = -1
val bufferInfo = MediaCodec.BufferInfo()
var inputOffset = 0
var inputDone = false
var outputDone = false
val totalSamples = pcmData.size
while (!outputDone) {
if (!inputDone) {
val inputBufferIndex = encoder.dequeueInputBuffer(10000)
if (inputBufferIndex >= 0) {
val inputBuffer = encoder.getInputBuffer(inputBufferIndex)!!
inputBuffer.clear()
val samplesToWrite = minOf((inputBuffer.capacity() / 2), pcmData.size - inputOffset)
if (samplesToWrite <= 0) {
encoder.queueInputBuffer(
inputBufferIndex,
0,
0,
0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
)
inputDone = true
} else {
for (i in 0 until samplesToWrite) {
val sample =
(pcmData[inputOffset + i] * 32767)
.toInt()
.coerceIn(-32768, 32767)
.toShort()
inputBuffer.putShort(sample)
}
val presentationTimeUs = (inputOffset * 1_000_000L) / sampleRate
encoder.queueInputBuffer(
inputBufferIndex,
0,
inputBuffer.position(),
presentationTimeUs,
0,
)
inputOffset += samplesToWrite
onProgress(inputOffset.toFloat() / totalSamples)
}
}
}
val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000)
when {
outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
audioTrackIndex = muxer.addTrack(encoder.outputFormat)
muxer.start()
muxerStarted = true
}
outputBufferIndex >= 0 -> {
val outputBuffer = encoder.getOutputBuffer(outputBufferIndex)!!
if (muxerStarted && bufferInfo.size > 0) {
outputBuffer.position(bufferInfo.offset)
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
muxer.writeSampleData(audioTrackIndex, outputBuffer, bufferInfo)
}
encoder.releaseOutputBuffer(outputBufferIndex, false)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
outputDone = true
}
}
}
}
} finally {
try {
encoder.stop()
} catch (_: IllegalStateException) {
// Encoder was never started
}
encoder.release()
if (muxerStarted) {
muxer.stop()
}
muxer.release()
}
}
}
private class FloatArrayAudioInputStream(
private val floatArray: FloatArray,
private val format: TarsosDSPAudioFormat,
private val frameLength: Long,
) : be.tarsos.dsp.io.TarsosDSPAudioInputStream {
private var position = 0
override fun getFormat(): TarsosDSPAudioFormat = format
override fun getFrameLength(): Long = frameLength
override fun read(
buffer: ByteArray,
offset: Int,
length: Int,
): Int {
val converter = TarsosDSPAudioFloatConverter.getConverter(format)
val floatBuffer = FloatArray(length / 2)
val samplesToRead = minOf(floatBuffer.size, floatArray.size - position)
if (samplesToRead <= 0) return -1
System.arraycopy(floatArray, position, floatBuffer, 0, samplesToRead)
position += samplesToRead
converter.toByteArray(floatBuffer, samplesToRead, buffer, offset)
return samplesToRead * 2
}
override fun skip(bytesToSkip: Long): Long {
val samplesToSkip = (bytesToSkip / 2).toInt()
val actualSkip = minOf(samplesToSkip, floatArray.size - position)
position += actualSkip
return actualSkip.toLong() * 2
}
override fun close() {
// No-op: no cleanup needed
}
}
@@ -35,8 +35,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -67,6 +69,8 @@ fun VoiceMessagePreview(
voiceMetadata: AudioMeta,
localFile: File? = null,
onRemove: () -> Unit,
onReRecord: ((RecordingResult) -> Unit)? = null,
isUploading: Boolean = false,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -101,77 +105,159 @@ fun VoiceMessagePreview(
shape = RoundedCornerShape(8.dp),
).padding(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
// Play/Pause Button
IconButton(
onClick = {
handlePlayPauseClick(
mediaPlayer = mediaPlayer,
isPlaying = isPlaying,
progress = progress,
onProgressReset = { progress = 0f },
onPlayingChanged = { isPlaying = it },
)
},
modifier = Modifier.size(48.dp),
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play),
tint = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(8.dp))
// Waveform and Duration
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
) {
AudioWaveformReadOnly(
amplitudes = voiceMetadata.waveform ?: emptyList(),
progress = progress,
waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)),
progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)),
onProgressChange = { newProgress ->
handleWaveformScrub(
newProgress = newProgress,
// Play/Pause Button
IconButton(
onClick = {
handlePlayPauseClick(
mediaPlayer = mediaPlayer,
onProgressChanged = { progress = it },
isPlaying = isPlaying,
progress = progress,
onProgressReset = { progress = 0f },
onPlayingChanged = { isPlaying = it },
)
},
)
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) stringRes(context, R.string.pause) else stringRes(context, R.string.play),
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
text = formatSecondsToTime(voiceMetadata.duration ?: 0),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Spacer(modifier = Modifier.width(8.dp))
// Waveform and Duration
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
) {
AudioWaveformReadOnly(
amplitudes = voiceMetadata.waveform ?: emptyList(),
progress = progress,
waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)),
progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)),
onProgressChange = { newProgress ->
handleWaveformScrub(
newProgress = newProgress,
mediaPlayer = mediaPlayer,
onProgressChanged = { progress = it },
)
},
)
Text(
text = formatSecondsToTime(voiceMetadata.duration ?: 0),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
Spacer(modifier = Modifier.width(8.dp))
// Remove Button
IconButton(
onClick = onRemove,
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringRes(context, R.string.remove),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.width(8.dp))
// Remove Button
IconButton(
onClick = onRemove,
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringRes(context, R.string.remove),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
if (onReRecord != null) {
Spacer(modifier = Modifier.size(8.dp))
ReRecordButton(
isUploading = isUploading,
isPlaying = isPlaying,
onRecordTaken = onReRecord,
)
}
}
}
}
@Composable
private fun ReRecordButton(
isUploading: Boolean,
isPlaying: Boolean,
onRecordTaken: (RecordingResult) -> Unit,
) {
if (isUploading || isPlaying) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = stringRes(id = R.string.record_a_message),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringRes(id = R.string.re_record),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
RecordAudioBox(
modifier = Modifier,
onRecordTaken = onRecordTaken,
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) { isRecording, elapsedSeconds ->
val contentColor =
if (isRecording) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val icon =
if (isRecording) {
Icons.Default.Stop
} else {
Icons.Default.Mic
}
val label =
if (isRecording) {
formatSecondsToTime(elapsedSeconds)
} else {
stringRes(id = R.string.re_record)
}
val iconDescription =
if (isRecording) {
stringRes(id = R.string.recording_indicator_description)
} else {
stringRes(id = R.string.record_a_message)
}
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = icon,
contentDescription = iconDescription,
tint = contentColor,
)
Text(
text = label,
color = contentColor,
)
}
}
}
@Composable
private fun ManageMediaPlayer(
voiceMetadata: AudioMeta,
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import com.vitorpamplona.amethyst.R
enum class VoicePreset(
val pitchFactor: Double,
val labelRes: Int,
) {
NONE(1.0, R.string.voice_preset_none),
DEEP(1.4, R.string.voice_preset_deep),
HIGH(0.75, R.string.voice_preset_high),
NEUTRAL(1.1, R.string.voice_preset_neutral),
}
@@ -0,0 +1,81 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun VoicePresetSelector(
selectedPreset: VoicePreset,
processingPreset: VoicePreset?,
onPresetSelected: (VoicePreset) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val isProcessing = processingPreset != null
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
VoicePreset.entries.forEach { preset ->
val isSelected = preset == selectedPreset
val isThisProcessing = preset == processingPreset
val isEnabled = !isProcessing || preset == VoicePreset.NONE
FilterChip(
selected = isSelected,
onClick = { if (isEnabled) onPresetSelected(preset) },
enabled = isEnabled,
label = {
if (isThisProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Text(stringRes(context, preset.labelRes))
}
},
colors =
FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary,
),
)
}
}
}
@@ -26,7 +26,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -71,7 +70,6 @@ fun AudioWaveformReadOnly(
progressBrush: Brush = SolidColor(Color.Blue),
waveformAlignment: WaveformAlignment = WaveformAlignment.Center,
amplitudeType: AmplitudeType = AmplitudeType.Avg,
onProgressChangeFinished: (() -> Unit)? = null,
spikeAnimationSpec: AnimationSpec<Float> = tween(500),
spikeWidth: Dp = 3.dp,
spikeRadius: Dp = 2.dp,
@@ -80,7 +78,6 @@ fun AudioWaveformReadOnly(
amplitudes: List<Float>,
onProgressChange: (Float) -> Unit,
) {
val backgroundColor = MaterialTheme.colorScheme.background
val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) }
val spikeWidthState =
remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) }
@@ -195,7 +192,20 @@ internal fun <T> Iterable<T>.chunkToSize(
internal fun Iterable<Float>.normalize(
min: Float,
max: Float,
): List<Float> = map { (max - min) * ((it - min()) / (max() - min())) + min }
): List<Float> {
val values = toList()
if (values.isEmpty()) return emptyList()
val currentMin = values.minOrNull() ?: return emptyList()
val currentMax = values.maxOrNull() ?: return emptyList()
val range = currentMax - currentMin
if (!range.isFinite() || range == 0f) {
return List(values.size) { min }
}
val scale = max - min
return values.map { scale * ((it - currentMin) / range) + min }
}
private fun Int.safeDiv(value: Int): Float {
return if (value == 0) return 0F else this / value.toFloat()
@@ -28,17 +28,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
@@ -64,116 +59,6 @@ fun ClickableBox(
}
}
@Composable
fun ClickAndHoldBox(
modifier: Modifier = Modifier,
onPress: () -> Unit,
onRelease: () -> Unit,
content: @Composable (Boolean) -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
LaunchedEffect(isPressed) {
if (isPressed) {
// Button is pressed
onPress()
} else {
// Button is released
onRelease()
}
}
// Animation for the button scale
val scale by animateFloatAsState(
targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording
animationSpec = tween(durationMillis = 150), // Smooth animation
)
// Animation for the button color
val backgroundColor by animateColorAsState(
targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background,
animationSpec = tween(durationMillis = 150),
)
Box(
modifier
.scale(scale)
.background(backgroundColor, CircleShape)
.clickable(
role = Role.Button,
interactionSource = interactionSource,
indication = ripple24dp,
onClick = { },
),
contentAlignment = Alignment.Center,
) {
content(isPressed)
}
}
@Composable
fun ClickAndHoldBoxComposable(
modifier: Modifier = Modifier,
onPress: () -> Unit,
onRelease: suspend () -> Unit,
onCancel: suspend () -> Unit,
content: @Composable (Boolean) -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
var isPressed by remember { mutableStateOf(false) }
LaunchedEffect(interactionSource) {
val pressInteractions = mutableListOf<PressInteraction.Press>()
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
if (pressInteractions.isEmpty()) {
onPress()
}
pressInteractions.add(interaction)
}
is PressInteraction.Release -> {
onRelease()
pressInteractions.remove(interaction.press)
}
is PressInteraction.Cancel -> {
onCancel()
pressInteractions.remove(interaction.press)
}
}
isPressed = pressInteractions.isNotEmpty()
}
}
// Animation for the button scale
val scale by animateFloatAsState(
targetValue = if (isPressed) 1.5f else 1.0f, // Scale up when recording
animationSpec = tween(durationMillis = 150), // Smooth animation
)
// Animation for the button color
val backgroundColor by animateColorAsState(
targetValue = if (isPressed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background,
animationSpec = tween(durationMillis = 150),
)
Box(
modifier
.scale(scale)
.background(backgroundColor, CircleShape)
.clickable(
role = Role.Button,
interactionSource = interactionSource,
indication = ripple24dp,
onClick = { },
),
contentAlignment = Alignment.Center,
) {
content(isPressed)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableBox(
@@ -195,3 +80,38 @@ fun ClickableBox(
content()
}
}
@Composable
fun ToggleableBox(
modifier: Modifier = Modifier,
isActive: Boolean,
onClick: () -> Unit,
content: @Composable (Boolean) -> Unit,
) {
// Animation for the button scale
val scale by animateFloatAsState(
targetValue = if (isActive) 1.5f else 1.0f,
animationSpec = tween(durationMillis = 150),
)
// Animation for the button color
val backgroundColor by animateColorAsState(
targetValue = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background,
animationSpec = tween(durationMillis = 150),
)
Box(
modifier
.scale(scale)
.background(backgroundColor, CircleShape)
.clickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = ripple24dp,
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
content(isActive)
}
}
@@ -375,7 +375,7 @@ fun CustomEmojiChecker(
onEmojiText: @Composable (ImmutableList<CustomEmoji.Renderable>) -> Unit,
) {
val mayContainEmoji by remember(text, tags) {
mutableStateOf(CustomEmoji.fastMightContainEmoji(text, tags))
mutableStateOf(CustomEmoji.fastMightContainEmoji(text, tags?.lists))
}
if (mayContainEmoji) {
@@ -385,7 +385,7 @@ fun CustomEmojiChecker(
}
LaunchedEffect(text, tags) {
val newEmojiList = CustomEmoji.assembleAnnotatedList(text, tags)
val newEmojiList = CustomEmoji.assembleAnnotatedList(text, tags?.lists)
if (newEmojiList != null) {
emojiList = newEmojiList
}
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.ListChange
import com.vitorpamplona.amethyst.commons.model.ListChange
import kotlinx.coroutines.flow.MutableSharedFlow
interface ChangesFlowFilter<T> : IAdditiveFeedFilter<T> {
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.BundledUpdate
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.commons.model.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
@@ -20,13 +20,13 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.routes
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -32,13 +32,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
@@ -53,9 +53,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits
@@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status
import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent
import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
@@ -163,15 +164,15 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.isAFork
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
@@ -727,6 +728,7 @@ private fun RenderNoteRow(
is ReportEvent -> RenderReport(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
is LongTextNoteEvent -> RenderLongFormContent(baseNote, accountViewModel, nav)
is WikiNoteEvent -> RenderWikiContent(baseNote, accountViewModel, nav)
is NipTextEvent -> RenderNipContent(baseNote, accountViewModel, nav)
is BadgeAwardEvent -> RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
is FhirResourceEvent -> RenderFhirResource(baseNote, accountViewModel, nav)
is PeopleListEvent -> DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
@@ -1085,8 +1087,8 @@ fun SecondUserInfoRow(
modifier = UserNameMaxRowHeight,
) {
Column(modifier = remember(noteEvent) { Modifier.weight(1f) }) {
if (noteEvent is BaseThreadedEvent && noteEvent.isAFork()) {
ShowForkInformation(noteEvent, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav)
if (noteEvent is IForkableEvent && noteEvent.isAFork()) {
ShowForkInformation(noteEvent, Modifier, accountViewModel, nav)
} else {
ObserveDisplayNip05Status(noteAuthor, accountViewModel, nav)
}
@@ -113,6 +113,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.ClickableBox
@@ -159,6 +160,7 @@ import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
@@ -634,6 +636,7 @@ fun ReplyViaVoiceReaction(
)
}
},
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
) { isRecording, elapsedSeconds ->
if (voiceRecordingState != null) {
SideEffect {
@@ -1394,16 +1397,20 @@ private fun BoostTypeChoicePopup(
Text(stringRes(R.string.quote), color = Color.White, textAlign = TextAlign.Center)
}
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onFork,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringRes(R.string.fork), color = Color.White, textAlign = TextAlign.Center)
// removes the option to fork for now because we do not have screens for
// LongForm, Wiki and NIP posting.
if (baseNote.event is TextNoteEvent) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onFork,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringRes(R.string.fork), color = Color.White, textAlign = TextAlign.Center)
}
}
}
}
@@ -20,14 +20,16 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
@Stable
class EmojiSuggestionState(
val account: Account,
) {
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
@@ -43,10 +42,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -57,7 +55,6 @@ fun ShowEmojiSuggestionList(
emojiSuggestions: EmojiSuggestionState,
onSelect: (EmojiPackState.EmojiMedia) -> Unit,
onFullSize: (EmojiPackState.EmojiMedia) -> Unit,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier.heightIn(0.dp, 200.dp),
) {
val suggestions by emojiSuggestions.results.collectAsStateWithLifecycle(emptyList())
@@ -79,22 +76,23 @@ fun ShowEmojiSuggestionList(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(Size10dp),
) {
Box(Size40Modifier) {
UrlImageView(it.link, accountViewModel)
}
AsyncImage(
it.link,
contentDescription = it.code,
modifier = Size40Modifier,
)
Text(it.code, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Box(Size40Modifier, contentAlignment = Alignment.Center) {
IconButton(
onClick = {
onFullSize(it)
},
) {
Icon(
imageVector = Icons.Outlined.OpenInFull,
contentDescription = stringRes(R.string.use_direct_url),
modifier = Modifier.size(20.dp),
)
}
IconButton(
modifier = Size40Modifier,
onClick = {
onFullSize(it)
},
) {
Icon(
imageVector = Icons.Outlined.OpenInFull,
contentDescription = stringRes(R.string.use_direct_url),
modifier = Modifier.size(20.dp),
)
}
}
HorizontalDivider(
@@ -21,39 +21,29 @@
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.appendLink
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.quartz.experimental.forks.forkFromAddress
import com.vitorpamplona.quartz.experimental.forks.forkFromVersion
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
@Composable
fun ShowForkInformation(
noteEvent: BaseThreadedEvent,
noteEvent: IForkableEvent,
modifier: Modifier,
accountViewModel: AccountViewModel,
nav: INav,
@@ -67,7 +57,7 @@ fun ShowForkInformation(
}
}
} else if (forkedEvent != null) {
LoadNote(forkedEvent.eventId, accountViewModel) { event ->
LoadNote(forkedEvent, accountViewModel) { event ->
if (event != null) {
ForkInformationRowLightColor(event, modifier, accountViewModel, nav)
}
@@ -83,35 +73,19 @@ fun ForkInformationRowLightColor(
nav: INav,
) {
val noteState by observeNote(originalVersion, accountViewModel)
val note = noteState?.note ?: return
val note = noteState.note
val author = note.author ?: return
val route = remember(note) { routeFor(note, accountViewModel.account) }
if (route != null) {
Row(modifier) {
Text(
text =
buildAnnotatedString {
appendLink(stringRes(id = R.string.forked_from) + " ") {
nav.nav(route)
}
},
style =
LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.nip05,
fontSize = Font14SP,
),
maxLines = 1,
overflow = TextOverflow.Visible,
)
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
val userState by observeUser(author, accountViewModel)
userState?.user?.toBestDisplayName()?.let {
CreateClickableTextWithEmoji(
clickablePart = it,
clickablePart = stringRes(id = R.string.forked_from) + " " + it,
maxLines = 1,
route = route,
overrideColor = MaterialTheme.colorScheme.nip05,
overrideColor = MaterialTheme.colorScheme.primary,
fontSize = Font14SP,
nav = nav,
tags = userState?.user?.info?.tags,
@@ -120,35 +94,3 @@ fun ForkInformationRowLightColor(
}
}
}
@Composable
fun ForkInformationRow(
originalVersion: Note,
modifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by observeNote(originalVersion, accountViewModel)
val note = noteState?.note ?: return
val route = remember(note) { routeFor(note, accountViewModel.account) }
if (route != null) {
Row(modifier) {
val author = note.author ?: return
val meta by observeUserInfo(author, accountViewModel)
Text(stringRes(id = R.string.forked_from))
Spacer(modifier = StdHorzSpacer)
val userMetadata by observeUserInfo(author, accountViewModel)
CreateClickableTextWithEmoji(
clickablePart = remember(meta) { meta?.bestName() ?: author.pubkeyDisplayHex() },
maxLines = 1,
route = route,
nav = nav,
tags = userMetadata?.tags,
)
}
}
}
@@ -33,11 +33,11 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
@@ -435,7 +435,7 @@ open class CommentPostViewModel :
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let {
EmojiUrlTag(
it.code,
it.link.url,
it.link,
)
}
}
@@ -634,11 +634,11 @@ open class CommentPostViewModel :
}
open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) {
val wordToInsert = item.link.url + " "
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) {
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
iMetaAttachments.downloadAndPrepare(item.link) {
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link)
}
}
@@ -357,7 +357,6 @@ private fun GenericCommentPostBody(
it,
postViewModel::autocompleteWithEmoji,
postViewModel::autocompleteWithEmojiUrl,
accountViewModel,
modifier = Modifier.heightIn(0.dp, 300.dp),
)
}
@@ -0,0 +1,194 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds
@Composable
fun RenderNipContent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? NipTextEvent ?: return
NipNoteHeader(noteEvent, note, accountViewModel, nav)
}
@Composable
private fun NipNoteHeader(
noteEvent: NipTextEvent,
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val title = remember(noteEvent) { noteEvent.title() }
val kinds = remember(noteEvent) { noteEvent.kinds() }
Column(
modifier =
Modifier
.padding(top = Size5dp)
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colorScheme.subtleBorder,
QuoteBorder,
),
verticalArrangement = SpacedBy5dp,
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
)
}
Text(
text = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content },
style = MaterialTheme.typography.bodySmall,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
if (kinds.isNotEmpty()) {
FlowRow(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
horizontalArrangement = SpacedBy5dp,
verticalArrangement = SpacedBy5dp,
itemVerticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.kinds),
)
kinds.forEach {
NoPaddingSuggestionChip(
label = it.toString(),
)
}
}
}
}
}
@Preview
@Composable
fun NipNoteHeaderPreview() {
val event =
NipTextEvent(
id = "eb2b05394ff0014bb6a79c2eacfd1c80696821592f4dbf86c950c4bf16614aa0",
pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
createdAt = 1767978398,
content = "Trusted Translations\n--------------------\n\n`draft` `optional`\n\nThis NIP allows anyone to post a translation for any event on a `kind:76`.\n\n```js\n{\n \"kind\": 76,\n \"tags\": [\n [\"e\", \"\u003coriginal_event_id\u003e\", \"\u003crelay\u003e\"]\n [\"k\", \"\u003coriginal_event_kind\u003e\"]\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n [\"s\", \"title\", \"translated title tag\"]\n [\"s\", \"summary\", \"translated summary tag\"]\n ],\n \"content\": \"this is a translated version of the original content\",\n // ...other fields\n}\n```\n\n`e` tag points to the event being translated, `k` tag points to the kind of that event.\n\n`l` tags define the language this was translated to in lowercase codes as defined by ISO 639-1. \n\n`s` tags are the translations for tags in the original event. \n\n`.content` contains the translation of the original `.content`\n\nClients SHOULD request translations by `e` and `l` tags spoken by their user. For every tag being rendered, Clients SHOULD look for their translated versions.\n\nProviders SHOULD use the event id in the filter to know which events need translations.\n\nProviders MAY store their translations behind a paid relay with NIP-42 auth.\n\n## Declaring Translation Providers\n\nKind `10041` lists the user's authorized translation providers. Each `p` tag is followed by the `pubkey` of the service publishing kind 76s, and the relay translations can be found. Users can specify these publicly or privately by JSON-stringifying and encrypting the tag list in the `.content` using NIP-44. \n\n```js\n{\n \"kind\": 10041,\n \"tags\": [\n [\"p\", \"4fd5e210530e4f6b2cb083795834bfe5108324f1ed9f00ab73b9e8fcfe5f12fe\", \"wss://translations.nostr.com\"],\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n //...\n}\n```\n\n`l` tags in this event are the languages the user understands and wants translations to.\n\nProviders SHOULD create the `10041` event and post to the user's outbox relay.",
sig = "7fa9f1d49c41c7bfbdad6d089d1c6685777c86de8fbda7662a630888658839f0444cb1398385f759461c09191b287fa222eec0ffe22b3fa8cf740f71aab9dc21",
tags =
arrayOf(
arrayOf("d", "trusted-translations"),
arrayOf("title", "Trusted Translations"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("k", "1011"),
arrayOf("k", "10041"),
arrayOf("client", "nostrhub.io"),
),
)
LocalCache.justConsume(event, null, true)
val note = LocalCache.getOrCreateNote(event.id)
ThemeComparisonColumn(
toPreview = {
NipNoteHeader(
noteEvent = event,
note = note,
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
)
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoPaddingSuggestionChip(
label: String,
modifier: Modifier = Modifier,
) {
Surface(
shape = MaterialTheme.shapes.extraSmall, // Use a small shape for chip look
color = MaterialTheme.colorScheme.secondaryContainer, // Default chip color
modifier = modifier,
) {
Text(
text = label,
// Apply desired internal padding to the Text itself
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@@ -100,6 +100,13 @@ fun RenderTextEvent(
}
}
// Check if this is an audio-only event (content is just an audio URL with waveform IMeta)
val isAudioOnly = remember(noteEvent) { noteEvent.isAudioOnlyContent() }
if (isAudioOnly) {
RenderAudioFromIMeta(note, accountViewModel, nav)
return
}
LoadDecryptedContent(
note,
accountViewModel,
@@ -69,8 +69,11 @@ import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.Size75Modifier
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
@Composable
@@ -107,6 +110,32 @@ fun VoiceHeader(
?.let { WaveformData(it) }
}
RenderAudioWithWaveform(
mediaUrl = media,
title = noteEvent.subject(),
mimeType = null,
waveform = waveform,
note = note,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* Shared composable for rendering audio with waveform visualization.
* Used by both VoiceHeader (for BaseVoiceEvent) and RenderAudioFromIMeta (for other events with audio IMeta).
*/
@Composable
fun RenderAudioWithWaveform(
mediaUrl: String,
title: String?,
mimeType: String?,
waveform: WaveformData?,
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event ?: return
val callbackUri = remember(note) { note.toNostrUri() }
Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) {
@@ -114,14 +143,14 @@ fun VoiceHeader(
verticalAlignment = Alignment.CenterVertically,
) {
GetMediaItem(
videoUri = media,
title = noteEvent.subject(),
videoUri = mediaUrl,
title = title,
artworkUri = null,
authorName = note.author?.toBestDisplayName(),
callbackUri = callbackUri,
mimeType = null,
mimeType = mimeType,
aspectRatio = null,
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(media),
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(mediaUrl),
keepPlaying = false,
waveformData = waveform,
) { mediaItem ->
@@ -168,7 +197,7 @@ fun RenderVoicePlayer(
factory = { context: Context ->
PlayerView(context).apply {
player = controllerState.controller
// if we alrady know the size of the frame, this forces the player to stay in the size
// if we already know the size of the frame, this forces the player to stay in the size
layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
@@ -263,3 +292,54 @@ fun PlayPauseButton(controllerState: MediaControllerState) {
}
}
}
/**
* Extracts AudioMeta from an event's IMeta tags if it has audio content with waveform.
* Returns the first audio IMeta that has a waveform, or null if none found.
*/
fun Event.getAudioMetaWithWaveform(): AudioMeta? {
val audioMetas = imetas().map { AudioMeta.parse(it) }
return audioMetas.firstOrNull { meta ->
meta.waveform != null &&
(meta.mimeType == null || meta.mimeType?.startsWith("audio/") == true)
}
}
/**
* Checks if the event content is primarily an audio attachment (content is just the audio URL).
*/
fun Event.isAudioOnlyContent(): Boolean {
val audioMeta = getAudioMetaWithWaveform() ?: return false
return content.trim() == audioMeta.url
}
/**
* Renders audio with waveform for any event type that has audio IMeta attachment.
* This allows KIND 1 (TextNoteEvent) and KIND 1111 (CommentEvent) with voice
* attachments to display the same waveform UI as VoiceEvent.
*/
@Composable
fun RenderAudioFromIMeta(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event ?: return
val audioMeta = remember(noteEvent) { noteEvent.getAudioMetaWithWaveform() } ?: return
val waveform =
remember(audioMeta) {
audioMeta.waveform?.let { WaveformData(it) }
}
RenderAudioWithWaveform(
mediaUrl = audioMeta.url,
title = null,
mimeType = audioMeta.mimeType,
waveform = waveform,
note = note,
accountViewModel = accountViewModel,
nav = nav,
)
}
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -392,6 +393,7 @@ val DEFAULT_FEED_KINDS =
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
NipTextEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
)
@@ -406,6 +408,7 @@ val DEFAULT_COMMUNITY_FEEDS =
AudioTrackEvent.KIND,
PinListEvent.KIND,
WikiNoteEvent.KIND,
NipTextEvent.KIND,
CommunityPostApprovalEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
)
@@ -28,7 +28,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@@ -41,6 +40,9 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
import com.vitorpamplona.amethyst.logTime
@@ -52,9 +54,6 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator
import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
@@ -69,12 +68,8 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -107,6 +102,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -134,7 +130,6 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -390,7 +385,7 @@ class AccountViewModel(
note.flow().author(),
note.flow().metadata.stateFlow,
note.flow().reports.stateFlow,
) { hiddenUsers, followingUsers, autor, metadata, reports ->
) { hiddenUsers, followingUsers, _, metadata, _ ->
emit(isNoteAcceptable(metadata.note, hiddenUsers, followingUsers.authors))
}.onStart {
emit(
@@ -706,9 +701,10 @@ class AccountViewModel(
fun report(
user: User,
type: ReportType,
content: String = "",
) {
launchSigner {
account.report(user, type)
account.report(user, type, content)
account.hideUser(user.pubkeyHex)
}
}
@@ -998,6 +994,32 @@ class AccountViewModel(
fun getNoteIfExists(hex: HexKey): Note? = LocalCache.getNoteIfExists(hex)
/**
* Fixes author and relay hints in MarkedETag list by looking up notes from cache.
* This ensures reply tags have proper author pubkeys and relay hints for threading.
*/
fun fixReplyTagHints(tags: List<MarkedETag>) {
tags.forEach { tag ->
val note = getNoteIfExists(tag.eventId)
val cachedAuthor = note?.author?.pubkeyHex
val cachedRelay = note?.relayHintUrl()
// Fix author if missing or different from cached
if (tag.author.isNullOrBlank() && cachedAuthor != null) {
tag.author = cachedAuthor
} else if (cachedAuthor != null && tag.author != cachedAuthor) {
tag.author = cachedAuthor
}
// Fix relay hint if missing or different from cached
if (tag.relay == null && cachedRelay != null) {
tag.relay = cachedRelay
} else if (cachedRelay != null && tag.relay != cachedRelay) {
tag.relay = cachedRelay
}
}
}
override suspend fun getOrCreateAddressableNote(address: Address): AddressableNote = LocalCache.getOrCreateAddressableNote(address)
fun getAddressableNoteIfExists(key: String): AddressableNote? = LocalCache.getAddressableNoteIfExists(key)
@@ -1011,11 +1033,11 @@ class AccountViewModel(
LocalCache.findLatestModificationForNote(note)
}
fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel? = LocalCache.getOrCreatePublicChatChannel(key)
fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key)
fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel? = LocalCache.getOrCreateLiveChannel(key)
fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key)
fun checkGetOrCreateEphemeralChatChannel(key: RoomId): EphemeralChatChannel? = LocalCache.getOrCreateEphemeralChannel(key)
fun checkGetOrCreateEphemeralChatChannel(key: RoomId): EphemeralChatChannel = LocalCache.getOrCreateEphemeralChannel(key)
fun getPublicChatChannelIfExists(hex: HexKey) = LocalCache.getPublicChatChannelIfExists(hex)
@@ -1148,53 +1170,6 @@ class AccountViewModel(
super.onCleared()
}
fun sendVoiceReply(
note: Note,
recording: RecordingResult,
context: Context,
) {
if (isWriteable()) {
val hint = note.toEventHint<BaseVoiceEvent>() ?: return
launchSigner {
val uploader = UploadOrchestrator()
val result =
uploader.upload(
uri = recording.file.toUri(),
mimeType = recording.mimeType,
alt = null,
contentWarningReason = null,
compressionQuality = CompressorQuality.UNCOMPRESSED,
server = account.settings.defaultFileServer,
account = account,
context = context,
)
if (result is UploadingState.Finished && result.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
account.sendVoiceReplyMessage(
result.result.url,
result.result.fileHeader.mimeType ?: recording.mimeType,
result.result.fileHeader.hash,
recording.duration,
recording.amplitudes,
hint,
)
} else if (result is UploadingState.Error) {
toastManager.toast(
R.string.failed_to_upload_media_no_details,
result.errorResource,
*result.params,
)
}
}
} else {
toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_reply,
)
}
}
fun loadThumb(
context: Context,
thumbUri: String,
@@ -1422,7 +1397,7 @@ class AccountViewModel(
// First check if we have an actual response from the DVM in LocalCache
val response =
LocalCache.notes.maxOrNullOf(
filter = { key, note ->
filter = { _, note ->
val noteEvent = note.event
noteEvent is NIP90ContentDiscoveryResponseEvent &&
noteEvent.pubKey == pubkeyHex &&
@@ -1531,8 +1506,6 @@ class AccountViewModel(
}
}
fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account)
fun convertAccounts(loggedInAccounts: List<AccountInfo>?): Set<HexKey> =
loggedInAccounts
?.mapNotNull {
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.privateChats.ChatroomList
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.IEvent
@@ -24,10 +24,10 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.ListChange
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ListChange
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter
@@ -32,11 +32,11 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -519,7 +519,7 @@ class ChatNewMessageViewModel :
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) }
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
}
}
@@ -659,11 +659,11 @@ class ChatNewMessageViewModel :
}
fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) {
val wordToInsert = item.link.url + " "
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) {
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
iMetaAttachments.downloadAndPrepare(item.link) {
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link)
}
}
@@ -311,7 +311,6 @@ fun GroupDMScreenContent(
it,
postViewModel::autocompleteWithEmoji,
postViewModel::autocompleteWithEmojiUrl,
accountViewModel,
Modifier.heightIn(0.dp, 300.dp),
)
}
@@ -127,7 +127,6 @@ fun PrivateMessageEditFieldRow(
it,
channelScreenModel::autocompleteWithEmoji,
channelScreenModel::autocompleteWithEmojiUrl,
accountViewModel,
)
}
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ListChangeFeedViewModel
class ChannelFeedViewModel(
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelFromUserFilterSubAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelPublicFilterSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelQueryState
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelQueryState
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -30,8 +30,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.note.produceStateIfNotNull
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -35,7 +35,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
@@ -24,7 +24,7 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -24,7 +24,7 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -30,8 +30,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
@@ -41,7 +41,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@@ -28,7 +28,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -37,7 +37,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.LoadNote
@@ -30,7 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -24,7 +24,7 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -24,7 +24,7 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -33,7 +33,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -33,7 +33,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -32,7 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -47,7 +47,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -31,9 +31,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -29,8 +29,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
@@ -27,8 +27,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelInfo
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
@@ -28,7 +28,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -40,9 +40,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -36,9 +36,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LikeReaction
@@ -34,8 +34,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -33,16 +33,16 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
@@ -484,7 +484,7 @@ open class ChannelNewMessageViewModel :
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) }
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
}
}
@@ -575,11 +575,11 @@ open class ChannelNewMessageViewModel :
}
open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) {
val wordToInsert = item.link.url + " "
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) {
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
iMetaAttachments.downloadAndPrepare(item.link) {
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link)
}
}
@@ -104,7 +104,6 @@ fun EditFieldRow(
it,
channelScreenModel::autocompleteWithEmoji,
channelScreenModel::autocompleteWithEmojiUrl,
accountViewModel,
)
}
@@ -41,11 +41,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
@@ -41,10 +41,10 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter
@@ -327,7 +327,6 @@ private fun NewProductBody(
it,
postViewModel::autocompleteWithEmoji,
postViewModel::autocompleteWithEmojiUrl,
accountViewModel,
modifier = Modifier.heightIn(0.dp, 300.dp),
)
}
@@ -33,11 +33,11 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
@@ -365,7 +365,7 @@ open class NewProductViewModel :
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link.url) }
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
}
}
@@ -534,11 +534,11 @@ open class NewProductViewModel :
}
open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) {
val wordToInsert = item.link.url + " "
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaDescription.downloadAndPrepare(item.link.url) {
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
iMetaDescription.downloadAndPrepare(item.link) {
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link)
}
}

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