mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+9
-9
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+2
-1
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-34
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
+4
-4
@@ -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 =
|
||||
|
||||
+130
@@ -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
|
||||
}
|
||||
}
|
||||
+78
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+492
@@ -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
|
||||
}
|
||||
}
|
||||
+144
-58
@@ -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),
|
||||
}
|
||||
+81
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -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> {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+18
-20
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -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,
|
||||
)
|
||||
|
||||
+37
-64
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+5
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -311,7 +311,6 @@ fun GroupDMScreenContent(
|
||||
it,
|
||||
postViewModel::autocompleteWithEmoji,
|
||||
postViewModel::autocompleteWithEmojiUrl,
|
||||
accountViewModel,
|
||||
Modifier.heightIn(0.dp, 300.dp),
|
||||
)
|
||||
}
|
||||
|
||||
-1
@@ -127,7 +127,6 @@ fun PrivateMessageEditFieldRow(
|
||||
it,
|
||||
channelScreenModel::autocompleteWithEmoji,
|
||||
channelScreenModel::autocompleteWithEmojiUrl,
|
||||
accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+9
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -104,7 +104,6 @@ fun EditFieldRow(
|
||||
it,
|
||||
channelScreenModel::autocompleteWithEmoji,
|
||||
channelScreenModel::autocompleteWithEmojiUrl,
|
||||
accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
-1
@@ -327,7 +327,6 @@ private fun NewProductBody(
|
||||
it,
|
||||
postViewModel::autocompleteWithEmoji,
|
||||
postViewModel::autocompleteWithEmojiUrl,
|
||||
accountViewModel,
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp),
|
||||
)
|
||||
}
|
||||
|
||||
+5
-5
@@ -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
Reference in New Issue
Block a user