- Adds support for Ephemeral Chats from coolr.chat

- Adds support for following ephemeral chats
- Adds support for live events at the top of the feed.
- Adds support for NIP-51, kind:10005 public chat lists
- Adds support for Channel feeds
- Moves following of NIP-28 chats from the Contact List to kind: 10005
- Disables following of events at the Contact list
- Improves gallery display to slightly override profile pictures when in list
- Starts the Account refactoring by moving custom Emoji, EphemeralList and PublicChat lists to their own packages
- Refactors NIP-51 lists to use common classes of private tags instead of general list classes.
- Starts to separate all Public chats into their own database.
- Removes old account upgrades from the local storage
- Refactors url NIP-11 loading and unifies icon
- Reduces the dependency of Relay classes in the LocalCache, Notes and User classes
This commit is contained in:
Vitor Pamplona
2025-05-02 21:18:10 -04:00
parent b06e53f59d
commit 63a009f36d
86 changed files with 3694 additions and 1012 deletions
@@ -27,14 +27,7 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.AccountLanguagePreferencesInternal
import com.vitorpamplona.amethyst.model.AccountReactionPreferencesInternal
import com.vitorpamplona.amethyst.model.AccountSecurityPreferencesInternal
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal
import com.vitorpamplona.amethyst.model.AccountZapPreferencesInternal
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Settings
@@ -43,9 +36,9 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -56,10 +49,10 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import kotlinx.coroutines.CancellationException
@@ -69,7 +62,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale
// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
// To use plaintext SharedPreferences for debugging, set this to true
@@ -91,13 +83,7 @@ private object PrefKeys {
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val REACTION_CHOICES = "reactionChoices"
const val DEFAULT_ZAPTYPE = "defaultZapType"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
@@ -112,15 +98,14 @@ private object PrefKeys {
const val LATEST_MUTE_LIST = "latestMuteList"
const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList"
const val LATEST_APP_SPECIFIC_DATA = "latestAppSpecificData"
const val LATEST_CHANNEL_LIST = "latestChannelList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val TOR_SETTINGS = "tor_settings"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
const val WARN_ABOUT_REPORTS = "warn_about_reports"
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
const val SIGNER_PACKAGE_NAME = "signer_package_name"
@@ -412,6 +397,24 @@ object LocalPreferences {
remove(PrefKeys.LATEST_APP_SPECIFIC_DATA)
}
if (settings.backupChannelList != null) {
putString(
PrefKeys.LATEST_CHANNEL_LIST,
EventMapper.mapper.writeValueAsString(settings.backupChannelList),
)
} else {
remove(PrefKeys.LATEST_CHANNEL_LIST)
}
if (settings.backupEphemeralChatList != null) {
putString(
PrefKeys.LATEST_EPHEMERAL_LIST,
EventMapper.mapper.writeValueAsString(settings.backupEphemeralChatList),
)
} else {
remove(PrefKeys.LATEST_EPHEMERAL_LIST)
}
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
@@ -519,11 +522,6 @@ object LocalPreferences {
val defaultDiscoveryFollowList =
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultZapType =
getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
@@ -540,80 +538,14 @@ object LocalPreferences {
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
val latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(PrefKeys.LATEST_APP_SPECIFIC_DATA)
val syncedSettings =
if (latestAppSpecificData != null) {
null
} else {
// previous version. Delete this when ready.
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
val showSensitiveContent =
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} else {
null
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
AccountSyncedSettingsInternal(
reactions =
AccountReactionPreferencesInternal(
reactionChoices = reactionChoices,
),
zaps =
AccountZapPreferencesInternal(
zapAmountChoices = zapAmountChoices,
defaultZapType = defaultZapType,
),
languages =
AccountLanguagePreferencesInternal(
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
),
security =
AccountSecurityPreferencesInternal(
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
),
)
}
val latestEphemeralList = parseEventOrNull<EphemeralChatListEvent>(PrefKeys.LATEST_EPHEMERAL_LIST)
val latestChannelList = parseEventOrNull<ChannelListEvent>(PrefKeys.LATEST_CHANNEL_LIST)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val torSettings =
if (useProxy) {
// old settings, means Orbot
TorSettings(
TorType.EXTERNAL,
getInt(PrefKeys.PROXY_PORT, 9050),
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
)
} else {
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
}
val torSettings = parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
val lastReadPerRoute =
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
@@ -646,7 +578,8 @@ object LocalPreferences {
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
backupMuteList = latestMuteList,
backupAppSpecificData = latestAppSpecificData,
backupSyncedSettings = syncedSettings,
backupChannelList = latestChannelList,
backupEphemeralChatList = latestEphemeralList,
torSettings = TorSettingsFlow.build(torSettings),
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
@@ -27,9 +27,10 @@ import com.fasterxml.jackson.module.kotlin.readValue
import com.fonfon.kgeohash.GeoHash
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListState
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.ots.OtsResolverBuilder
@@ -43,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.ammolite.relays.Constants
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.relays.RelaySetupInfoToConnect
import com.vitorpamplona.ammolite.relays.TypedFilter
@@ -52,6 +54,7 @@ import com.vitorpamplona.quartz.blossom.BlossomServersEvent
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
@@ -84,7 +87,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
@@ -124,11 +126,11 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip37Drafts.DraftBuilder
@@ -185,6 +187,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -196,7 +199,6 @@ import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.toSet
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -233,7 +235,7 @@ class Account(
class FeedsBaseFlows(
val listName: String,
val peopleList: StateFlow<NoteState> = MutableStateFlow(NoteState(Note(" "))),
val kind3: StateFlow<Account.LiveFollowList?> = MutableStateFlow(null),
val kind3: StateFlow<LiveFollowList?> = MutableStateFlow(null),
val location: StateFlow<LocationState.LocationResult?> = MutableStateFlow(null),
)
@@ -1130,77 +1132,9 @@ class Account(
.flowOn(Dispatchers.Default)
}
class EmojiMedia(
val code: String,
val link: MediaUrlImage,
)
fun getEmojiPackSelection(): EmojiPackSelectionEvent? = getEmojiPackSelectionNote().event as? EmojiPackSelectionEvent
fun getEmojiPackSelectionFlow(): StateFlow<NoteState> = getEmojiPackSelectionNote().flow().metadata.stateFlow
fun getEmojiPackSelectionAddress() = EmojiPackSelectionEvent.createAddress(userProfile().pubkeyHex)
fun getEmojiPackSelectionNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getEmojiPackSelectionAddress())
fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List<StateFlow<NoteState>>? =
selection?.taggedAddresses()?.map {
LocalCache
.getOrCreateAddressableNote(it)
.flow()
.metadata.stateFlow
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveEmojiSelectionPack: StateFlow<List<StateFlow<NoteState>>?> by lazy {
getEmojiPackSelectionFlow()
.transformLatest {
emit(convertEmojiSelectionPack(it.note.event as? EmojiPackSelectionEvent))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
convertEmojiSelectionPack(getEmojiPackSelection()),
)
}
fun convertEmojiPack(pack: EmojiPackEvent): List<EmojiMedia> =
pack.taggedEmojis().map {
EmojiMedia(it.code, MediaUrlImage(it.url))
}
fun mergePack(list: Array<NoteState>): List<EmojiMedia> =
list
.mapNotNull {
val ev = it.note.event as? EmojiPackEvent
if (ev != null) {
convertEmojiPack(ev)
} else {
null
}
}.flatten()
.distinctBy { it.link }
@OptIn(ExperimentalCoroutinesApi::class)
val myEmojis by lazy {
liveEmojiSelectionPack
.transformLatest { emojiList ->
if (emojiList != null) {
emitAll(
combineTransform(emojiList) {
emit(mergePack(it))
},
)
} else {
emit(emptyList())
}
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
mergePack(convertEmojiSelectionPack(getEmojiPackSelection())?.map { it.value }?.toTypedArray() ?: emptyArray()),
)
}
val emoji = EmojiPackState(signer, LocalCache, scope)
val ephemeralChatList = EphemeralChatListState(signer, LocalCache, scope)
val publicChatList = PublicChatListState(signer, LocalCache, scope)
private var userProfileCache: User? = null
@@ -1311,7 +1245,6 @@ class Account(
followTags = listOf(),
followGeohashes = listOf(),
followCommunities = listOf(),
followEvents = DefaultChannels.toList(),
relayUse = relays,
signer = signer,
) {
@@ -1827,7 +1760,6 @@ class Account(
followTags = emptyList(),
followGeohashes = emptyList(),
followCommunities = emptyList(),
followEvents = DefaultChannels.toList(),
relayUse =
Constants.defaultRelays.associate {
it.url to ReadWrite(it.read, it.write)
@@ -1840,32 +1772,39 @@ class Account(
}
}
fun follow(channel: Channel) {
fun follow(channel: PublicChatChannel) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
publicChatList.follow(channel) {
sendToPrivateOutboxAndLocal(it)
LocalCache.justConsume(it, null)
}
}
if (contactList != null) {
ContactListEvent.followEvent(contactList, channel.idHex, signer) {
Amethyst.instance.client.send(it)
LocalCache.justConsume(it, null)
}
} else {
ContactListEvent.createFromScratch(
followUsers = emptyList(),
followTags = emptyList(),
followGeohashes = emptyList(),
followCommunities = emptyList(),
followEvents = DefaultChannels.toList().plus(channel.idHex),
relayUse =
Constants.defaultRelays.associate {
it.url to ReadWrite(it.read, it.write)
},
signer = signer,
) {
Amethyst.instance.client.send(it)
LocalCache.justConsume(it, null)
}
fun unfollow(channel: PublicChatChannel) {
if (!isWriteable()) return
publicChatList.unfollow(channel) {
sendToPrivateOutboxAndLocal(it)
LocalCache.justConsume(it, null)
}
}
fun follow(channel: EphemeralChatChannel) {
if (!isWriteable()) return
ephemeralChatList.follow(channel) {
sendToPrivateOutboxAndLocal(it)
LocalCache.justConsumeInner(it, RelayBriefInfoCache.get(channel.roomId.relayUrl))
}
}
fun unfollow(channel: EphemeralChatChannel) {
if (!isWriteable()) return
ephemeralChatList.unfollow(channel) {
sendToPrivateOutboxAndLocal(it)
LocalCache.justConsumeInner(it, RelayBriefInfoCache.get(channel.roomId.relayUrl))
}
}
@@ -1889,7 +1828,6 @@ class Account(
followTags = emptyList(),
followGeohashes = emptyList(),
followCommunities = listOf(community.toATag()),
followEvents = DefaultChannels.toList(),
relayUse = relays,
signer = signer,
) {
@@ -1919,7 +1857,6 @@ class Account(
followTags = listOf(tag),
followGeohashes = emptyList(),
followCommunities = emptyList(),
followEvents = DefaultChannels.toList(),
relayUse =
Constants.defaultRelays.associate {
it.url to ReadWrite(it.read, it.write)
@@ -1950,7 +1887,6 @@ class Account(
followTags = emptyList(),
followGeohashes = listOf(geohash),
followCommunities = emptyList(),
followEvents = DefaultChannels.toList(),
relayUse =
Constants.defaultRelays.associate {
it.url to ReadWrite(it.read, it.write)
@@ -2011,21 +1947,6 @@ class Account(
}
}
suspend fun unfollow(channel: Channel) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
if (contactList != null && contactList.tags.isNotEmpty()) {
ContactListEvent.unfollowEvent(
contactList,
channel.idHex,
signer,
onReady = this::onNewEventCreated,
)
}
}
suspend fun unfollow(community: AddressableNote) {
if (!isWriteable()) return
@@ -2468,8 +2389,6 @@ class Account(
) {
if (!isWriteable()) return
val relayList = getPrivateOutBoxRelayList()
val template =
InteractiveStoryReadingStateEvent.build(
root = root,
@@ -2479,12 +2398,7 @@ class Account(
)
signer.sign(template) {
if (relayList.isNotEmpty()) {
Amethyst.instance.client.sendPrivately(it, relayList = relayList)
} else {
Amethyst.instance.client.send(it)
}
LocalCache.justConsume(it, null)
sendToPrivateOutboxAndLocal(it)
}
}
@@ -2495,8 +2409,6 @@ class Account(
) {
if (!isWriteable()) return
val relayList = getPrivateOutBoxRelayList()
val template =
InteractiveStoryReadingStateEvent.update(
base = readingState,
@@ -2505,12 +2417,7 @@ class Account(
)
signer.sign(template) {
if (relayList.isNotEmpty()) {
Amethyst.instance.client.sendPrivately(it, relayList = relayList)
} else {
Amethyst.instance.client.send(it)
}
LocalCache.justConsume(it, null)
sendToPrivateOutboxAndLocal(it)
}
}
@@ -2749,13 +2656,17 @@ class Account(
}
fun sendDraftEvent(draftEvent: DraftEvent) {
val relayList = getPrivateOutBoxRelayList()
sendToPrivateOutboxAndLocal(draftEvent)
}
fun sendToPrivateOutboxAndLocal(event: Event) {
val relayList = normalizedPrivateOutBoxRelaySet.value + settings.localRelayServers
if (relayList.isNotEmpty()) {
Amethyst.instance.client.sendPrivately(draftEvent, relayList)
Amethyst.instance.client.sendPrivately(event, convertRelayList(relayList.toList()))
} else {
Amethyst.instance.client.send(draftEvent)
Amethyst.instance.client.send(event)
}
LocalCache.justConsume(draftEvent, null)
LocalCache.justConsume(event, null)
}
fun convertRelayList(broadcast: List<String>): List<RelaySetupInfoToConnect> =
@@ -3184,11 +3095,6 @@ class Account(
}
}
fun selectedChatsFollowList(): Set<String> {
val contactList = userProfile().latestContactList
return contactList?.taggedEventIds()?.toSet() ?: DefaultChannels
}
fun requestDVMContentDiscovery(
dvmPublicKey: String,
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
@@ -3859,6 +3765,18 @@ class Account(
GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) }
}
settings.backupEphemeralChatList?.let {
Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) }
}
settings.backupChannelList?.let {
Log.d("AccountRegisterObservers", "Loading saved channel list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.verifyAndConsume(it, null) }
}
// saves contact list for the next time.
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Kind 0 Collector Start")
@@ -3927,6 +3845,26 @@ class Account(
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "Channel List Collector Start")
publicChatList.getChannelListFlow().collect {
Log.d("AccountRegisterObservers", "Channel List for ${userProfile().toBestDisplayName()}")
(it.note.event as? ChannelListEvent)?.let {
settings.updateChannelListTo(it)
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start")
ephemeralChatList.getEphemeralChatListFlow().collect {
Log.d("AccountRegisterObservers", "EphemeralChatList List for ${userProfile().toBestDisplayName()}")
(it.note.event as? EphemeralChatListEvent)?.let {
settings.updateEphemeralChatListTo(it)
}
}
}
scope.launch(Dispatchers.Default) {
Log.d("AccountRegisterObservers", "AppSpecificData Collector Start")
getAppSpecificDataFlow().collect {
@@ -3962,5 +3900,29 @@ class Account(
}
}
}
scope.launch(Dispatchers.Default) {
delay(1000 * 60 * 1)
// waits 5 minutes before migrating the list.
val contactList = userProfile().latestContactList
val oldChannels = contactList?.taggedEventIds()?.toSet()?.mapNotNull { LocalCache.getChannelIfExists(it) as? PublicChatChannel }
if (oldChannels != null && oldChannels.isNotEmpty()) {
println("AABBCC Migrating List with ${oldChannels.size} old channels ")
val existingChannels = publicChatList.livePublicChatEventIdSet.value
val needsToUpgrade = oldChannels.filter { it.idHex !in existingChannels }
println("AABBCC Migrating List with ${needsToUpgrade.size} needsToUpgrade ")
if (needsToUpgrade.isNotEmpty()) {
println("AABBCC Migrating List")
publicChatList.follow(oldChannels) {
sendToPrivateOutboxAndLocal(it)
LocalCache.justConsume(it, null)
}
}
}
}
}
}
@@ -30,13 +30,16 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.ammolite.relays.Constants
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
@@ -53,7 +56,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import java.util.Locale
val DefaultChannels =
val DefaultChannelSet =
setOf(
// Anigma's Nostr
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
@@ -61,6 +64,14 @@ val DefaultChannels =
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5",
)
val DefaultChannels =
listOf(
// Anigma's Nostr
EventIdHint("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", "wss://nos.lol"),
// Amethyst's Group
EventIdHint("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", "wss://nos.lol"),
)
val DefaultNIP65List =
listOf(
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
@@ -116,16 +127,15 @@ class AccountSettings(
var backupMuteList: MuteListEvent? = null,
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
var backupAppSpecificData: AppSpecificDataEvent? = null,
backupSyncedSettings: AccountSyncedSettingsInternal? = null, // only exist for migration purposes
var backupChannelList: ChannelListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
val torSettings: TorSettingsFlow = TorSettingsFlow(),
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()),
) {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
val syncedSettings: AccountSyncedSettings =
backupSyncedSettings?.let { AccountSyncedSettings(it) }
?: AccountSyncedSettings(AccountSyncedSettingsInternal())
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
class AccountSettingsUpdater(
val accountSettings: AccountSettings?,
@@ -357,6 +367,26 @@ class AccountSettings(
}
}
fun updateChannelListTo(newChannelList: ChannelListEvent?) {
if (newChannelList == null || newChannelList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupChannelList?.id != newChannelList.id) {
backupChannelList = newChannelList
saveAccountSettings()
}
}
fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupEphemeralChatList?.id != newEphemeralChatList.id) {
backupEphemeralChatList = newEphemeralChatList
saveAccountSettings()
}
}
fun updateMuteList(newMuteList: MuteListEvent?) {
if (newMuteList == null || newMuteList.tags.isEmpty()) return
@@ -24,7 +24,7 @@ import android.util.Log
import android.util.LruCache
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.RelayStats
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -45,7 +45,7 @@ class AntiSpamFilter {
fun isSpam(
event: Event,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
): Boolean {
checkNotInMainThread()
@@ -21,13 +21,14 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
@@ -40,9 +41,29 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.LargeCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
class EphemeralChatChannel(
val roomId: RoomId,
) : Channel(roomId.toKey()) {
override fun idNote() = roomId.toDisplayKey()
override fun idDisplayNote() = idNote().toShortenHex()
override fun relays() = listOf(roomId.relayUrl)
override fun toBestDisplayName() = roomId.toDisplayKey()
override fun summary(): String? = null
override fun profilePicture(): String? = null
override fun anyNameStartsWith(prefix: String): Boolean = roomId.id.contains(prefix, true)
}
@Stable
class PublicChatChannel(
idHex: String,
@@ -57,6 +78,10 @@ class PublicChatChannel(
fun toNostrUri() = "nostr:${toNEvent()}"
fun toEventHint() = event?.let { EventHintBundle<ChannelCreateEvent>(it, relays().firstOrNull(), null) }
fun toEventId() = EventIdHint(idHex, relays().firstOrNull())
fun updateChannelInfo(
creator: User,
event: ChannelCreateEvent,
@@ -152,9 +177,7 @@ abstract class Channel(
var lastNoteCreatedAt: Long = 0
private var relays = mapOf<RelayBriefInfoCache.RelayBriefInfo, Counter>()
open fun id() = Hex.decode(idHex)
open fun idNote() = id().toNEvent()
open fun idNote() = Hex.decode(idHex).toNEvent()
open fun idDisplayNote() = idNote().toShortenHex()
@@ -181,7 +204,7 @@ abstract class Channel(
this.creator = creator
this.updatedMetadataAt = updatedAt
flow.invalidateData()
flowSet?.metadata?.invalidateData()
}
@Synchronized
@@ -191,18 +214,18 @@ abstract class Channel(
}
}
fun addRelay(relay: Relay) {
val counter = relays[relay.brief]
fun addRelay(relay: RelayBriefInfoCache.RelayBriefInfo) {
val counter = relays[relay]
if (counter != null) {
counter.number++
} else {
addRelaySync(relay.brief)
addRelaySync(relay)
}
}
fun addNote(
note: Note,
relay: Relay? = null,
relay: RelayBriefInfoCache.RelayBriefInfo? = null,
) {
notes.put(note.idHex, note)
@@ -213,6 +236,8 @@ abstract class Channel(
if (relay != null) {
addRelay(relay)
}
flowSet?.notes?.invalidateData()
}
fun removeNote(note: Note) {
@@ -225,9 +250,6 @@ abstract class Channel(
abstract fun anyNameStartsWith(prefix: String): Boolean
// Observers line up here.
val flow: ChannelFlow = ChannelFlow(this)
fun pruneOldMessages(): Set<Note> {
val important =
notes
@@ -240,6 +262,8 @@ abstract class Channel(
toBeRemoved.forEach { notes.remove(it.idHex) }
flowSet?.notes?.invalidateData()
return toBeRemoved.toSet()
}
@@ -252,8 +276,57 @@ abstract class Channel(
hidden.forEach { notes.remove(it.idHex) }
flowSet?.notes?.invalidateData()
return hidden.toSet()
}
var flowSet: ChannelFlowSet? = null
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = ChannelFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet?.destroy()
flowSet = null
}
}
}
fun flow(): ChannelFlowSet {
if (flowSet == null) {
createOrDestroyFlowSync(true)
}
return flowSet!!
}
fun clearFlow() {
if (flowSet != null && flowSet?.isInUse() == false) {
createOrDestroyFlowSync(false)
}
}
}
@Stable
class ChannelFlowSet(
u: Channel,
) {
// Observers line up here.
val metadata = ChannelFlow(u)
val notes = ChannelFlow(u)
fun isInUse(): Boolean =
metadata.hasObservers() ||
notes.hasObservers()
fun destroy() {
metadata.destroy()
notes.destroy()
}
}
class ChannelFlow(
@@ -24,8 +24,6 @@ import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.data.DeletionIndex
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -37,6 +35,9 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
@@ -72,6 +73,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip03Timestamp.VerificationState
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
@@ -86,11 +88,11 @@ import com.vitorpamplona.quartz.nip19Bech32.isATag
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
@@ -151,6 +153,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.LargeCache
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
@@ -179,14 +182,9 @@ interface ILocalCache {
fun justVerify(event: Event): Boolean
fun consume(
event: DraftEvent,
relay: Relay?,
)
fun markAsSeen(
string: String,
relay: Relay,
relay: RelayBriefInfoCache.RelayBriefInfo,
) {}
}
@@ -275,8 +273,6 @@ object LocalCache : ILocalCache {
}
fun checkGetOrCreateUser(key: String): User? {
// checkNotInMainThread()
if (isValidHex(key)) {
return getOrCreateUser(key)
}
@@ -284,7 +280,6 @@ object LocalCache : ILocalCache {
}
fun getOrCreateUser(key: HexKey): User {
// checkNotInMainThread()
require(isValidHex(key = key)) { "$key is not a valid hex" }
return users.getOrCreate(key) {
@@ -307,6 +302,8 @@ object LocalCache : ILocalCache {
fun getChannelIfExists(key: String): Channel? = channels.get(key)
fun getChannelIfExists(key: RoomId): Channel? = channels.get(key.toKey())
fun getNoteIfExists(event: Event): Note? =
if (event is AddressableEvent) {
getAddressableNoteIfExists(event.addressTag())
@@ -389,9 +386,21 @@ object LocalCache : ILocalCache {
return channels.getOrCreate(key, channelFactory)
}
fun checkGetOrCreateChannel(key: RoomId): Channel? =
channels.getOrCreate(key.toKey()) {
EphemeralChatChannel(key)
}
fun checkGetOrCreateChannel(key: String): Channel? {
checkNotInMainThread()
if (key.contains("@")) {
return channels.getOrCreate(key) {
val idParts = key.split("@")
EphemeralChatChannel(RoomId(idParts[0], idParts[1]))
}
}
if (isValidHex(key)) {
return channels.getOrCreate(key) { PublicChatChannel(key) }
}
@@ -447,14 +456,14 @@ object LocalCache : ILocalCache {
val relayHint = key.relay
if (!relayHint.isNullOrBlank()) {
val relay = RelayBriefInfoCache.get(RelayUrlFormatter.normalize(relayHint))
note.addRelayBrief(relay)
note.addRelay(relay)
}
return note
}
fun consume(
event: MetadataEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
// new event
val oldUser = getOrCreateUser(event.pubKey)
@@ -514,32 +523,32 @@ object LocalCache : ILocalCache {
fun consume(
event: TextNoteEvent,
relay: Relay? = null,
relay: RelayBriefInfoCache.RelayBriefInfo? = null,
) = consumeRegularEvent(event, relay)
fun consume(
event: TorrentEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: InteractiveStoryPrologueEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeBaseReplaceable(event, relay)
fun consume(
event: InteractiveStorySceneEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeBaseReplaceable(event, relay)
fun consume(
event: InteractiveStoryReadingStateEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeBaseReplaceable(event, relay)
fun consumeRegularEvent(
event: Event,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -568,57 +577,57 @@ object LocalCache : ILocalCache {
fun consume(
event: PictureEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: TorrentCommentEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: NIP90ContentDiscoveryResponseEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: NIP90ContentDiscoveryRequestEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: NIP90StatusEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: NIP90UserDiscoveryResponseEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: NIP90UserDiscoveryRequestEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: GitPatchEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: GitIssueEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: GitReplyEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: LongTextNoteEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -652,7 +661,7 @@ object LocalCache : ILocalCache {
fun consume(
event: WikiNoteEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -745,12 +754,12 @@ object LocalCache : ILocalCache {
fun consume(
event: PollNoteEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
private fun consume(
event: LiveActivitiesEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -782,154 +791,161 @@ object LocalCache : ILocalCache {
fun consume(
event: MuteListEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: CommunityListEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: GitRepositoryEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: ChannelListEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: BlossomServersEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: FileServersEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: PeopleListEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: EphemeralChatListEvent,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: FollowListEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: AdvertisedRelayListEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: ChatMessageRelayListEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: PrivateOutboxRelayListEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: SearchRelayListEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: CommunityDefinitionEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: EmojiPackSelectionEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: EmojiPackEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: ClassifiedsEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: PinListEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: RelaySetEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: AudioTrackEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: VideoVerticalEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: VideoHorizontalEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: StatusEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -954,14 +970,14 @@ object LocalCache : ILocalCache {
fun consume(
event: RelationshipStatusEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: OtsEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val version = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -979,7 +995,7 @@ object LocalCache : ILocalCache {
fun consume(
event: BadgeDefinitionEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
@@ -1008,54 +1024,54 @@ object LocalCache : ILocalCache {
fun consume(
event: BadgeAwardEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
private fun comsume(
event: NNSEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: AppDefinitionEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: CalendarEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: CalendarDateSlotEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: CalendarTimeSlotEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consume(
event: CalendarRSVPEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
private fun consumeBaseReplaceable(
event: BaseAddressableEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -1085,21 +1101,21 @@ object LocalCache : ILocalCache {
fun consume(
event: AppRecommendationEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: AppSpecificDataEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: PrivateDmEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
): Note {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1357,7 +1373,7 @@ object LocalCache : ILocalCache {
fun consume(
event: ReportEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1394,7 +1410,7 @@ object LocalCache : ILocalCache {
fun consume(
event: ChannelCreateEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
val oldChannel = getOrCreateChannel(event.id) { PublicChatChannel(it) }
@@ -1420,7 +1436,7 @@ object LocalCache : ILocalCache {
fun consume(
event: ChannelMetadataEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val channelId = event.channelId()
// Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}")
@@ -1450,7 +1466,7 @@ object LocalCache : ILocalCache {
fun consume(
event: ChannelMessageEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val channelId = event.channelId()
@@ -1488,14 +1504,46 @@ object LocalCache : ILocalCache {
refreshObservers(note)
}
fun consume(
event: EphemeralChatEvent,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val relayUrl = event.relay() ?: relay?.url ?: return
val channelId = RoomId(event.room(), relayUrl)
val channel = checkGetOrCreateChannel(channelId) ?: return
val note = getOrCreateNote(event.id)
channel.addNote(note, relay)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
return
}
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(
event: CommentEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: LiveActivitiesChatMessageEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val activityAddress = event.activityAddress() ?: return
@@ -1536,7 +1584,7 @@ object LocalCache : ILocalCache {
fun consume(
event: LnZapEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val note = getOrCreateNote(event.id)
// Already processed this event.
@@ -1587,32 +1635,32 @@ object LocalCache : ILocalCache {
fun consume(
event: AudioHeaderEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: FileHeaderEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: ProfileGalleryEntryEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: FileStorageHeaderEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: FhirResourceEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: TextNoteModificationEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1640,12 +1688,12 @@ object LocalCache : ILocalCache {
fun consume(
event: HighlightEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) = consumeRegularEvent(event, relay)
fun consume(
event: FileStorageEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1686,7 +1734,7 @@ object LocalCache : ILocalCache {
private fun consume(
event: ChatMessageEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1729,7 +1777,7 @@ object LocalCache : ILocalCache {
private fun consume(
event: ChatMessageEncryptedFileHeaderEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1772,7 +1820,7 @@ object LocalCache : ILocalCache {
fun consume(
event: SealedRumorEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1792,7 +1840,7 @@ object LocalCache : ILocalCache {
fun consume(
event: GiftWrapEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -2349,7 +2397,7 @@ object LocalCache : ILocalCache {
override fun markAsSeen(
eventId: String,
relay: Relay,
relay: RelayBriefInfoCache.RelayBriefInfo,
) {
val note = getNoteIfExists(eventId)
@@ -2397,9 +2445,9 @@ object LocalCache : ILocalCache {
}
}
override fun consume(
fun consume(
event: DraftEvent,
relay: Relay?,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
if (!event.isDeleted()) {
consumeBaseReplaceable(event, relay)
@@ -2466,6 +2514,13 @@ object LocalCache : ILocalCache {
}
}
}
is EphemeralChatEvent -> {
val room = draft.room()
val relay = draft.relay()
if (relay != null) {
checkGetOrCreateChannel(RoomId(room, relay).toKey())?.addNote(note, null)
}
}
is ChannelMessageEvent -> {
draft.channelId()?.let { channelId ->
checkGetOrCreateChannel(channelId)?.addNote(note, null)
@@ -2545,6 +2600,15 @@ object LocalCache : ILocalCache {
}
}
}
is EphemeralChatEvent -> {
val room = draft.room()
val relay = draft.relay()
if (relay != null) {
checkGetOrCreateChannel(RoomId(room, relay))?.let { channel ->
channel.removeNote(draftWrap)
}
}
}
is TextNoteEvent -> {
val replyTo = computeReplyTo(draft)
replyTo.forEach { it.removeReply(draftWrap) }
@@ -2579,6 +2643,13 @@ object LocalCache : ILocalCache {
}
}
justConsumeInner(event, relay?.brief)
}
fun justConsumeInner(
event: Event,
relay: RelayBriefInfoCache.RelayBriefInfo?,
) {
checkNotInMainThread()
try {
@@ -2612,7 +2683,11 @@ object LocalCache : ILocalCache {
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityListEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
event.containedPost()?.let { verifyAndConsume(it, relay) }
event.containedPost()?.let {
if (justVerify(it)) {
justConsumeInner(it, relay)
}
}
consume(event)
}
is ContactListEvent -> consume(event)
@@ -2620,8 +2695,14 @@ object LocalCache : ILocalCache {
is DraftEvent -> consume(event, relay)
is EmojiPackEvent -> consume(event, relay)
is EmojiPackSelectionEvent -> consume(event, relay)
is EphemeralChatEvent -> consume(event, relay)
is EphemeralChatListEvent -> consume(event, relay)
is GenericRepostEvent -> {
event.containedPost()?.let { verifyAndConsume(it, relay) }
event.containedPost()?.let {
if (justVerify(it)) {
justConsumeInner(it, relay)
}
}
consume(event)
}
is FhirResourceEvent -> consume(event, relay)
@@ -2645,8 +2726,10 @@ object LocalCache : ILocalCache {
is LnZapEvent -> {
event.zapRequest?.let {
// must have a valid request
verifyAndConsume(it, relay)
consume(event, relay)
if (justVerify(it)) {
justConsumeInner(it, relay)
consume(event, relay)
}
}
}
is LnZapRequestEvent -> consume(event)
@@ -2673,7 +2756,11 @@ object LocalCache : ILocalCache {
is RelaySetEvent -> consume(event, relay)
is ReportEvent -> consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let { verifyAndConsume(it, relay) }
event.containedPost()?.let {
if (justVerify(it)) {
justConsumeInner(it, relay)
}
}
consume(event)
}
is SealedRumorEvent -> consume(event, relay)
@@ -2712,7 +2799,7 @@ object LocalCache : ILocalCache {
) {
val toNote = getOrCreateNote(to)
from.relays.forEach {
toNote.addRelayBrief(it)
toNote.addRelay(it)
}
}
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue
import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -200,13 +201,15 @@ open class Note(
event is ChannelMetadataEvent ||
event is ChannelCreateEvent ||
event is LiveActivitiesChatMessageEvent ||
event is LiveActivitiesEvent
event is LiveActivitiesEvent ||
event is EphemeralChatEvent
) {
(event as? ChannelMessageEvent)?.channelId()
?: (event as? ChannelMetadataEvent)?.channelId()
?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.aTag()?.toTag()
?: (event as? EphemeralChatEvent)?.roomId()?.toKey()
} else {
null
}
@@ -447,14 +450,7 @@ open class Note(
fun hasRelay(relay: Relay) = relay.brief !in relays
fun addRelay(relay: Relay) {
if (relay.brief !in relays) {
addRelaySync(relay.brief)
flowSet?.relays?.invalidateData()
}
}
fun addRelayBrief(brief: RelayBriefInfoCache.RelayBriefInfo) {
fun addRelay(brief: RelayBriefInfoCache.RelayBriefInfo) {
if (brief !in relays) {
addRelaySync(brief)
flowSet?.relays?.invalidateData()
@@ -25,7 +25,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache.RelayBriefInfo
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -298,12 +298,12 @@ class User(
}
fun addRelayBeingUsed(
relay: Relay,
relay: RelayBriefInfo,
eventTime: Long,
) {
val here = relaysBeingUsed[relay.brief.url]
val here = relaysBeingUsed[relay.url]
if (here == null) {
relaysBeingUsed = relaysBeingUsed + Pair(relay.brief.url, RelayInfo(relay.brief.url, eventTime, 1))
relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1))
} else {
if (eventTime > here.lastEvent) {
here.lastEvent = eventTime
@@ -0,0 +1,128 @@
/**
* Copyright (c) 2024 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.model.emphChat
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlin.coroutines.resume
class EphemeralChatListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
fun getEphemeralChatListAddress() = EphemeralChatListEvent.createAddress(signer.pubKey)
fun getEphemeralChatListNote(): AddressableNote = cache.getOrCreateAddressableNote(getEphemeralChatListAddress())
fun getEphemeralChatListFlow(): StateFlow<NoteState> = getEphemeralChatListNote().flow().metadata.stateFlow
fun getEphemeralChatList(): EphemeralChatListEvent? = getEphemeralChatListNote().event as? EphemeralChatListEvent
@OptIn(ExperimentalCoroutinesApi::class)
val liveEphemeralChatList: StateFlow<Set<RoomId>> by lazy {
getEphemeralChatListFlow()
.transformLatest { noteState ->
val set =
tryAndWait { continuation ->
(noteState.note.event as? EphemeralChatListEvent)?.publicAndPrivateRoomIds(signer) {
continuation.resume(it)
}
}
if (set != null) {
emit(set)
}
}.onStart {
val set =
tryAndWait { continuation ->
getEphemeralChatList()?.publicAndPrivateRoomIds(signer) {
continuation.resume(it)
}
}
if (set != null) {
emit(set)
}
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
fun follow(
channel: EphemeralChatChannel,
onDone: (EphemeralChatListEvent) -> Unit,
) {
val ephemeralChatList = getEphemeralChatList()
if (ephemeralChatList == null) {
EphemeralChatListEvent.createRoom(
room = channel.roomId,
isPrivate = true,
signer = signer,
onReady = onDone,
)
} else {
EphemeralChatListEvent.addRoom(
earlierVersion = ephemeralChatList,
room = channel.roomId,
isPrivate = true,
signer = signer,
onReady = onDone,
)
}
}
fun unfollow(
channel: EphemeralChatChannel,
onDone: (EphemeralChatListEvent) -> Unit,
) {
val ephemeralChatList = getEphemeralChatList()
if (ephemeralChatList != null) {
EphemeralChatListEvent.removeRoom(
earlierVersion = ephemeralChatList,
room = channel.roomId,
isPrivate = true,
signer = signer,
onReady = onDone,
)
}
}
}
@@ -0,0 +1,150 @@
/**
* Copyright (c) 2024 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.model.nip28PublicChats
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.tryAndWait
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlin.coroutines.resume
class PublicChatListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
fun getChannelListAddress() = ChannelListEvent.createAddress(signer.pubKey)
fun getChannelListNote(): AddressableNote = cache.getOrCreateAddressableNote(getChannelListAddress())
fun getChannelListFlow(): StateFlow<NoteState> = getChannelListNote().flow().metadata.stateFlow
fun getChannelList(): ChannelListEvent? = getChannelListNote().event as? ChannelListEvent
@OptIn(ExperimentalCoroutinesApi::class)
val livePublicChatList: StateFlow<Set<EventIdHint>> by lazy {
getChannelListFlow()
.transformLatest { noteState ->
val set =
tryAndWait { continuation ->
(noteState.note.event as? ChannelListEvent)?.publicAndPrivateChannels(signer) {
continuation.resume(it)
}
}
if (set != null) {
emit(set)
}
}.onStart {
val set =
tryAndWait { continuation ->
getChannelList()?.publicAndPrivateChannels(signer) {
continuation.resume(it)
}
}
if (set != null) {
emit(set)
}
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
@OptIn(ExperimentalCoroutinesApi::class)
val livePublicChatEventIdSet: StateFlow<Set<HexKey>> by lazy {
livePublicChatList
.map {
it.mapTo(mutableSetOf()) { it.eventId }
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
fun follow(
channel: PublicChatChannel,
onDone: (ChannelListEvent) -> Unit,
) {
val publicChatList = getChannelList()
val fullHint = channel.toEventHint()
if (fullHint != null) {
if (publicChatList == null) {
ChannelListEvent.createChannel(fullHint, true, signer, onReady = onDone)
} else {
ChannelListEvent.addChannel(publicChatList, fullHint, true, signer, onReady = onDone)
}
} else {
val partialHint = channel.toEventId()
if (publicChatList == null) {
ChannelListEvent.createChannel(partialHint, true, signer, onReady = onDone)
} else {
ChannelListEvent.addChannel(publicChatList, partialHint, true, signer, onReady = onDone)
}
}
}
fun follow(
channels: List<PublicChatChannel>,
onDone: (ChannelListEvent) -> Unit,
) {
val publicChatList = getChannelList()
val partialHint = channels.map { it.toEventId() }
if (publicChatList == null) {
ChannelListEvent.createChannels(partialHint, true, signer, onReady = onDone)
} else {
ChannelListEvent.addChannels(publicChatList, partialHint, true, signer, onReady = onDone)
}
}
fun unfollow(
channel: PublicChatChannel,
onDone: (ChannelListEvent) -> Unit,
) {
val publicChatList = getChannelList()
if (publicChatList != null) {
ChannelListEvent.removeChannel(publicChatList, channel.idHex, signer, onReady = onDone)
}
}
}
@@ -0,0 +1,119 @@
/**
* Copyright (c) 2024 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.model.nip30CustomEmojis
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
class EmojiPackState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
class EmojiMedia(
val code: String,
val link: MediaUrlImage,
)
fun getEmojiPackSelectionAddress() = EmojiPackSelectionEvent.createAddress(signer.pubKey)
fun getEmojiPackSelection(): EmojiPackSelectionEvent? = getEmojiPackSelectionNote().event as? EmojiPackSelectionEvent
fun getEmojiPackSelectionFlow(): StateFlow<NoteState> = getEmojiPackSelectionNote().flow().metadata.stateFlow
fun getEmojiPackSelectionNote(): AddressableNote = cache.getOrCreateAddressableNote(getEmojiPackSelectionAddress())
fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List<StateFlow<NoteState>>? =
selection?.taggedAddresses()?.map {
cache
.getOrCreateAddressableNote(it)
.flow()
.metadata.stateFlow
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveEmojiSelectionPack: StateFlow<List<StateFlow<NoteState>>?> by lazy {
getEmojiPackSelectionFlow()
.transformLatest {
emit(convertEmojiSelectionPack(it.note.event as? EmojiPackSelectionEvent))
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
convertEmojiSelectionPack(getEmojiPackSelection()),
)
}
fun convertEmojiPack(pack: EmojiPackEvent): List<EmojiMedia> =
pack.taggedEmojis().map {
EmojiMedia(it.code, MediaUrlImage(it.url))
}
fun mergePack(list: Array<NoteState>): List<EmojiMedia> =
list
.mapNotNull {
val ev = it.note.event as? EmojiPackEvent
if (ev != null) {
convertEmojiPack(ev)
} else {
null
}
}.flatten()
.distinctBy { it.link }
@OptIn(ExperimentalCoroutinesApi::class)
val myEmojis by lazy {
liveEmojiSelectionPack
.transformLatest { emojiList ->
if (emojiList != null) {
emitAll(
combineTransform(emojiList) {
emit(mergePack(it))
},
)
} else {
emit(emptyList())
}
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
mergePack(convertEmojiSelectionPack(getEmojiPackSelection())?.map { it.value }?.toTypedArray() ?: emptyArray()),
)
}
}
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.datasources.EventCollector
import com.vitorpamplona.ammolite.relays.datasources.RelayInsertConfirmationCollector
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -40,8 +40,8 @@ class CacheClientConnector(
val confirmationWatcher =
RelayInsertConfirmationCollector(client) { eventId, relay ->
cache.markAsSeen(eventId, relay)
unwrapAndMarkAsSeen(eventId, relay)
cache.markAsSeen(eventId, relay.brief)
unwrapAndMarkAsSeen(eventId, relay.brief)
}
fun destroy() {
@@ -51,7 +51,7 @@ class CacheClientConnector(
private fun unwrapAndMarkAsSeen(
eventId: HexKey,
relay: Relay,
relay: RelayBriefInfoCache.RelayBriefInfo,
) {
val note = LocalCache.getNoteIfExists(eventId)
@@ -31,6 +31,8 @@ import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.blossom.BlossomServersEvent
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
@@ -43,6 +45,7 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
@@ -140,6 +143,8 @@ class AccountFilterAssembler(
MuteListEvent.KIND,
BadgeProfilesEvent.KIND,
EmojiPackSelectionEvent.KIND,
EphemeralChatListEvent.KIND,
ChannelListEvent.KIND,
),
authors = authorsHexes,
limit = 100 * authorsHexes.size,
@@ -209,7 +214,6 @@ class AccountFilterAssembler(
kinds =
listOf(
TextNoteEvent.KIND,
PollNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
@@ -217,6 +221,7 @@ class AccountFilterAssembler(
LnZapEvent.KIND,
LnZapPaymentResponseEvent.KIND,
ChannelMessageEvent.KIND,
EphemeralChatEvent.KIND,
BadgeAwardEvent.KIND,
),
tags = mapOf("p" to authorsHexes),
@@ -249,6 +254,21 @@ class AccountFilterAssembler(
),
)
fun createNotificationFilter3(authorsHexes: List<String>) =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
SincePerRelayFilter(
kinds =
listOf(
PollNoteEvent.KIND,
),
tags = mapOf("p" to authorsHexes),
limit = 100,
since = latestEOSE.relayList,
),
)
fun mergeAllFilters(
mainAccounts: List<HexKey>,
otherAccounts: List<HexKey>,
@@ -260,6 +280,7 @@ class AccountFilterAssembler(
createAccountSettings2Filter(mainAccounts),
createNotificationFilter(mainAccounts),
createNotificationFilter2(mainAccounts),
createNotificationFilter3(mainAccounts),
createGiftWrapsToMeFilter(mainAccounts),
createAccountReportsAndDraftsFilter(mainAccounts),
createAccountSettingsFilter(mainAccounts),
@@ -27,16 +27,48 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ChannelState
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache.notes
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
@Composable
fun observeChannel(baseChannel: Channel): State<ChannelState?> {
ChannelFinderFilterAssemblerSubscription(baseChannel)
return baseChannel.flow.stateFlow.collectAsStateWithLifecycle()
return baseChannel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
fun observeChannelNoteAuthors(baseChannel: Channel): State<ImmutableList<User>> {
ChannelFinderFilterAssemblerSubscription(baseChannel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(baseChannel) {
baseChannel
.flow()
.notes.stateFlow
.mapLatest {
it.channel.notes
.mapNotNull { key, value -> value.author }
.toSet()
.toImmutableList()
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(persistentListOf())
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -49,7 +81,8 @@ fun observeChannelPicture(baseChannel: Channel): State<String?> {
val flow =
remember(baseChannel) {
baseChannel
.flow.stateFlow
.flow()
.metadata.stateFlow
.mapLatest { it.channel.profilePicture() }
.distinctUntilChanged()
}
@@ -67,7 +100,8 @@ fun observeChannelInfo(baseChannel: LiveActivitiesChannel): State<LiveActivities
val flow =
remember(baseChannel) {
baseChannel
.flow.stateFlow
.flow()
.metadata.stateFlow
.mapLatest { (it.channel as? LiveActivitiesChannel)?.info }
.distinctUntilChanged()
}
@@ -20,17 +20,19 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user
import android.annotation.SuppressLint
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.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
import kotlinx.collections.immutable.ImmutableList
@@ -392,26 +394,51 @@ fun observeUserIsFollowingGeohash(
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserIsFollowingChannel(
user: User,
channel: Channel,
account: Account,
channel: PublicChatChannel,
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
UserFinderFilterAssemblerSubscription(account.userProfile())
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.follows.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false
remember(account) {
account
.publicChatList
.livePublicChatEventIdSet
.mapLatest { followingChannels ->
channel.idHex in followingChannels
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(user.latestContactList?.isTaggedEvent(channel.idHex) ?: false)
@SuppressLint("StateFlowValueCalledInComposition")
return flow.collectAsStateWithLifecycle(channel.idHex in account.publicChatList.livePublicChatEventIdSet.value)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserIsFollowingChannel(
account: Account,
channel: EphemeralChatChannel,
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(account.userProfile())
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(account) {
account
.ephemeralChatList
.liveEphemeralChatList
.mapLatest { followingChannels ->
channel.roomId in followingChannels
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
@SuppressLint("StateFlowValueCalledInComposition")
return flow.collectAsStateWithLifecycle(channel.roomId in account.ephemeralChatList.liveEphemeralChatList.value)
}
@Composable
@@ -43,6 +43,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
@@ -514,7 +516,7 @@ open class NewPostViewModel :
}
}
val emojis = findEmoji(tagger.message, account?.myEmojis?.value)
val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value)
val urls = findURLs(tagger.message)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
@@ -875,7 +877,7 @@ open class NewPostViewModel :
fun findEmoji(
message: String,
myEmojiSet: List<Account.EmojiMedia>?,
myEmojiSet: List<EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
@@ -1066,7 +1068,7 @@ open class NewPostViewModel :
saveDraft()
}
open fun autocompleteWithEmoji(item: Account.EmojiMedia) {
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
@@ -1077,7 +1079,7 @@ open class NewPostViewModel :
saveDraft()
}
open fun autocompleteWithEmojiUrl(item: Account.EmojiMedia) {
open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) {
val wordToInsert = item.link.url + " "
viewModelScope.launch(Dispatchers.IO) {
@@ -0,0 +1,28 @@
/**
* Copyright (c) 2024 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.dal
abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>() {
abstract fun updateListWith(
oldList: List<T>,
newItems: Set<U>,
): List<T>
}
@@ -0,0 +1,179 @@
/**
* Copyright (c) 2024 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.feeds
import android.util.Log
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.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.AdditiveComplexFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.ammolite.relays.BundledInsert
import com.vitorpamplona.ammolite.relays.BundledUpdate
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.collections.distinctBy
@Stable
class ChannelFeedContentState(
val localFilter: AdditiveComplexFeedFilter<Channel, Note>,
val viewModelScope: CoroutineScope,
) : InvalidatableContent {
private val _feedContent = MutableStateFlow<ChannelFeedState>(ChannelFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
// Simple counter that changes when it needs to invalidate everything
private val _scrollToTop = MutableStateFlow<Int>(0)
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
private var lastFeedKey: String? = null
override val isRefreshing: MutableState<Boolean> = mutableStateOf(false)
fun sendToTop() {
if (scrolltoTopPending) return
scrolltoTopPending = true
viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) }
}
suspend fun sentToTop() {
scrolltoTopPending = false
}
private fun refresh() {
viewModelScope.launch(Dispatchers.Default) { refreshSuspended() }
}
fun refreshSuspended() {
checkNotInMainThread()
isRefreshing.value = true
try {
lastFeedKey = localFilter.feedKey()
val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList()
val oldNotesState = _feedContent.value
if (oldNotesState is ChannelFeedState.Loaded) {
if (!equalImmutableLists(notes, oldNotesState.feed.value.list)) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
} finally {
isRefreshing.value = false
}
}
private fun updateFeed(notes: ImmutableList<Channel>) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.tryEmit(ChannelFeedState.Empty)
} else if (currentState is ChannelFeedState.Loaded) {
currentState.feed.tryEmit(LoadedFeedState<Channel>(notes, localFilter.showHiddenKey()))
} else {
_feedContent.tryEmit(
ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState<Channel>(notes, localFilter.showHiddenKey()))),
)
}
}
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
if (oldNotesState is ChannelFeedState.Loaded) {
val oldList = oldNotesState.feed.value.list
val newList =
localFilter
.updateListWith(oldList, newItems)
.distinctBy { it }
.toImmutableList()
if (!equalImmutableLists(newList, oldNotesState.feed.value.list)) {
updateFeed(newList)
}
} else if (oldNotesState is ChannelFeedState.Empty) {
val newList =
localFilter
.updateListWith(emptyList(), newItems)
.distinctBy { it.idHex }
.toImmutableList()
if (newList.isNotEmpty()) {
updateFeed(newList)
}
} else {
// Refresh Everything
refreshSuspended()
}
}
private val bundler = BundledUpdate(250, Dispatchers.Default)
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.Default)
override fun invalidateData(ignoreIfDoing: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
}
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
}
}
}
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) { refreshFromOldState(it.flatten().toSet()) }
}
fun updateFeedWith(newNotes: Set<Note>) {
if ((_feedContent.value is ChannelFeedState.Loaded || _feedContent.value is ChannelFeedState.Empty)) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
fun destroy() {
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
bundlerInsert.cancel()
bundler.cancel()
}
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2024 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.feeds
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
sealed class ChannelFeedState {
object Loading : ChannelFeedState()
class Loaded(
val feed: MutableStateFlow<LoadedFeedState<Channel>>,
) : ChannelFeedState()
object Empty : ChannelFeedState()
class FeedError(
val errorMessage: String,
) : ChannelFeedState()
}
@@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByA
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.EphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
@@ -83,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -129,8 +132,10 @@ fun AppNavigation(
composableFromEndArgs<Route.Room> { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEndArgs<Route.Channel> { ChannelScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.EphemeralChat> { EphemeralChatScreen(RoomId(it.id, it.relayUrl), accountViewModel, nav) }
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, accountViewModel, nav) }
@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.ui.navigation
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -154,7 +156,14 @@ fun routeToMessage(
accountViewModel: AccountViewModel,
): Route = routeToMessage(user.pubkeyHex, draftMessage, replyId, draftId, accountViewModel)
fun routeFor(note: Channel): Route = Route.Channel(note.idHex)
fun routeFor(note: Channel): Route =
if (note is EphemeralChatChannel) {
Route.EphemeralChat(note.roomId.id, note.roomId.relayUrl)
} else {
Route.Channel(note.idHex)
}
fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.relayUrl)
fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex)
@@ -100,6 +100,13 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class EphemeralChat(
val id: String,
val relayUrl: String,
) : Route()
@Serializable object NewEphemeralChat : Route()
@Serializable data class ChannelMetadataEdit(
val id: String? = null,
) : Route()
@@ -176,6 +183,8 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
dest.hasRoute<Route.RoomByAuthor>() -> entry.toRoute<Route.RoomByAuthor>()
dest.hasRoute<Route.Channel>() -> entry.toRoute<Route.Channel>()
dest.hasRoute<Route.ChannelMetadataEdit>() -> entry.toRoute<Route.ChannelMetadataEdit>()
dest.hasRoute<Route.EphemeralChat>() -> entry.toRoute<Route.EphemeralChat>()
dest.hasRoute<Route.NewEphemeralChat>() -> entry.toRoute<Route.NewEphemeralChat>()
dest.hasRoute<Route.EventRedirect>() -> entry.toRoute<Route.EventRedirect>()
dest.hasRoute<Route.EditRelays>() -> entry.toRoute<Route.EditRelays>()
dest.hasRoute<Route.Nip47NWCSetup>() -> entry.toRoute<Route.Nip47NWCSetup>()
@@ -1024,16 +1024,28 @@ fun Gallery(
users: ImmutableList<User>,
modifier: Modifier,
accountViewModel: AccountViewModel,
maxPictures: Int = 6,
) {
FlowRow(modifier, verticalArrangement = Arrangement.Center) {
users.take(6).forEach { ClickableUserPicture(it, Size25dp, accountViewModel) }
FlowRow(
modifier,
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy((-6).dp),
) {
users.take(maxPictures).forEach {
ClickableUserPicture(
it,
Size25dp,
accountViewModel,
Modifier,
)
}
if (users.size > 6) {
if (users.size > maxPictures) {
Text(
text = " + " + showCount(users.size - 6),
text = " + " + showCount(users.size - maxPictures),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 3.dp).align(CenterVertically),
modifier = Modifier.padding(start = 6.dp).align(CenterVertically),
)
}
}
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -29,17 +32,21 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
@Composable
fun LoadDecryptedContent(
@@ -198,3 +205,44 @@ fun LoadChannel(
channel?.let { content(it) }
}
@Composable
fun LoadChannel(
id: RoomId,
accountViewModel: AccountViewModel,
content: @Composable (EphemeralChatChannel) -> Unit,
) {
var channel =
produceStateIfNotNull(accountViewModel.getChannelIfExists(id) as? EphemeralChatChannel, id) {
value = accountViewModel.checkGetOrCreateChannel(id) as? EphemeralChatChannel
}
channel.value?.let { content(it) }
}
@Composable
fun <T> produceStateIfNotNull(
initialValue: T,
key1: Any?,
producer: suspend ProduceStateScope<T>.() -> Unit,
): State<T> {
val result = remember(key1) { mutableStateOf(initialValue) }
if (result.value == null) {
LaunchedEffect(key1) { ProduceStateScopeImpl(result, coroutineContext).producer() }
}
return result
}
class ProduceStateScopeImpl<T>(
state: MutableState<T>,
override val coroutineContext: CoroutineContext,
) : ProduceStateScope<T>,
MutableState<T> by state {
override suspend fun awaitDispose(onDispose: () -> Unit): Nothing {
try {
suspendCancellableCoroutine<Nothing> {}
} finally {
onDispose()
}
}
}
@@ -37,7 +37,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -52,12 +51,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
@@ -122,29 +121,14 @@ fun RenderRelay(
accountViewModel: AccountViewModel,
nav: INav,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val relayInfo by
produceState(
initialValue = Nip11CachedRetriever.getFromCache(relay.url),
) {
if (value == null) {
accountViewModel.retrieveRelayDocument(
relay.url,
onInfo = {
value = it
},
onError = { url, errorCode, exceptionMessage ->
},
)
}
}
val relayInfo = loadRelayInfo(relay.url, accountViewModel)
var openRelayDialog by remember { mutableStateOf(false) }
if (openRelayDialog && relayInfo != null) {
RelayInformationDialog(
onClose = { openRelayDialog = false },
relayInfo = relayInfo!!,
relayInfo = relayInfo,
relayBriefInfo = relay,
accountViewModel = accountViewModel,
nav = nav,
@@ -381,7 +381,7 @@ private fun EmojiSelector(
onClick: ((EmojiUrlTag) -> Unit)? = null,
) {
LoadAddressableNote(
accountViewModel.account.getEmojiPackSelectionAddress(),
accountViewModel.account.emoji.getEmojiPackSelectionAddress(),
accountViewModel,
) { emptyNote ->
emptyNote?.let { usersEmojiList ->
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -32,9 +32,9 @@ class EmojiSuggestionState(
val accountViewModel: AccountViewModel,
) {
val search: MutableStateFlow<String> = MutableStateFlow("")
val results: Flow<List<Account.EmojiMedia>> =
val results: Flow<List<EmojiPackState.EmojiMedia>> =
accountViewModel.account
.myEmojis
.emoji.myEmojis
.combine(search) { list, search ->
if (search.length == 1) {
list
@@ -44,7 +44,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
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.ui.stringRes
@@ -55,8 +55,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
@Composable
fun ShowEmojiSuggestionList(
emojiSuggestions: EmojiSuggestionState,
onSelect: (Account.EmojiMedia) -> Unit,
onFullSize: (Account.EmojiMedia) -> Unit,
onSelect: (EmojiPackState.EmojiMedia) -> Unit,
onFullSize: (EmojiPackState.EmojiMedia) -> Unit,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier.heightIn(0.dp, 200.dp),
) {
@@ -175,7 +175,7 @@ private fun EmojiListOptions(
emojiPackNote: Note,
) {
LoadAddressableNote(
accountViewModel.account.getEmojiPackSelectionAddress(),
accountViewModel.account.emoji.getEmojiPackSelectionAddress(),
accountViewModel,
) {
it?.let { usersEmojiList ->
@@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
@@ -297,7 +298,6 @@ class AccountStateViewModel : ViewModel() {
backupContactList =
ContactListEvent.createFromScratch(
followUsers = listOf(ContactTag(keyPair.pubKey.toHexKey(), null, null)),
followEvents = DefaultChannels.toList(),
relayUse =
Constants.defaultRelays.associate {
it.url to ReadWrite(it.read, it.write)
@@ -307,6 +307,7 @@ class AccountStateViewModel : ViewModel() {
backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner),
backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner),
backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList, tempSigner),
backupChannelList = ChannelListEvent.create(DefaultChannels, tempSigner),
torSettings = TorSettingsFlow.build(torSettings),
)
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.screen.FollowListState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
@@ -33,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal.DiscoverLiveFe
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal.DiscoverMarketplaceFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.dal.DiscoverNIP89FeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
@@ -42,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
class AccountFeedContentStates(
val accountViewModel: AccountViewModel,
) {
val homeLive = ChannelFeedContentState(HomeLiveFilter(accountViewModel.account), accountViewModel.viewModelScope)
val homeNewThreads = FeedContentState(HomeNewThreadFeedFilter(accountViewModel.account), accountViewModel.viewModelScope)
val homeReplies = FeedContentState(HomeConversationsFeedFilter(accountViewModel.account), accountViewModel.viewModelScope)
@@ -69,6 +72,7 @@ class AccountFeedContentStates(
fun updateFeedsWith(newNotes: Set<Note>) {
checkNotInMainThread()
homeLive.updateFeedWith(newNotes)
homeNewThreads.updateFeedWith(newNotes)
homeReplies.updateFeedWith(newNotes)
@@ -46,8 +46,10 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator
@@ -77,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.ammolite.relays.BundledInsert
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -823,11 +826,19 @@ class AccountViewModel(
account.decryptZapContentAuthor(note, onReady)
}
fun follow(channel: Channel) {
fun follow(channel: PublicChatChannel) {
viewModelScope.launch(Dispatchers.IO) { account.follow(channel) }
}
fun unfollow(channel: Channel) {
fun follow(channel: EphemeralChatChannel) {
viewModelScope.launch(Dispatchers.IO) { account.follow(channel) }
}
fun unfollow(channel: PublicChatChannel) {
viewModelScope.launch(Dispatchers.IO) { account.unfollow(channel) }
}
fun unfollow(channel: EphemeralChatChannel) {
viewModelScope.launch(Dispatchers.IO) { account.unfollow(channel) }
}
@@ -1147,6 +1158,8 @@ class AccountViewModel(
private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? = LocalCache.checkGetOrCreateChannel(key)
suspend fun checkGetOrCreateChannel(key: RoomId): Channel? = LocalCache.checkGetOrCreateChannel(key)
fun checkGetOrCreateChannel(
key: HexKey,
onResult: (Channel?) -> Unit,
@@ -1156,6 +1169,8 @@ class AccountViewModel(
fun getChannelIfExists(hex: HexKey): Channel? = LocalCache.getChannelIfExists(hex)
fun getChannelIfExists(key: RoomId): Channel? = LocalCache.getChannelIfExists(key.toKey())
fun <T : PubKeyReferenceTag> loadParticipants(
participants: List<T>,
onReady: (ImmutableList<Pair<T, User>>) -> Unit,
@@ -695,3 +695,18 @@ fun CreateButton(
Text(text = stringRes(R.string.create))
}
}
@Composable
fun JoinButton(
onPost: () -> Unit = {},
isActive: Boolean,
modifier: Modifier = Modifier,
) {
Button(
enabled = isActive,
modifier = modifier,
onClick = onPost,
) {
Text(text = stringRes(R.string.join))
}
}
@@ -36,7 +36,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -52,7 +51,6 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
@@ -60,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog
import com.vitorpamplona.amethyst.ui.stringRes
@@ -239,20 +238,7 @@ fun RenderRelayLinePublicChat(
nav: INav,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val relayInfo by produceState(
initialValue = Nip11CachedRetriever.getFromCache(dirtyUrl),
) {
if (value == null) {
accountViewModel.retrieveRelayDocument(
dirtyUrl,
onInfo = {
value = it
},
onError = { url, errorCode, exceptionMessage ->
},
)
}
}
val relayInfo = loadRelayInfo(dirtyUrl, accountViewModel)
var openRelayDialog by remember { mutableStateOf(false) }
@@ -37,6 +37,7 @@ 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.UserSuggestionAnchor
@@ -437,7 +438,7 @@ class ChatNewMessageViewModel :
val urls = findURLs(message.text)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = findEmoji(message.text, accountViewModel.account.myEmojis.value)
val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value)
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
val message = message.text
@@ -498,7 +499,7 @@ class ChatNewMessageViewModel :
fun findEmoji(
message: String,
myEmojiSet: List<Account.EmojiMedia>?,
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
@@ -635,7 +636,7 @@ class ChatNewMessageViewModel :
draftTag.newVersion()
}
fun autocompleteWithEmoji(item: Account.EmojiMedia) {
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
@@ -646,7 +647,7 @@ class ChatNewMessageViewModel :
draftTag.newVersion()
}
fun autocompleteWithEmojiUrl(item: Account.EmojiMedia) {
fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) {
val wordToInsert = item.link.url + " "
viewModelScope.launch(Dispatchers.IO) {
@@ -24,12 +24,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -86,6 +88,15 @@ fun ChannelHeader(
accountViewModel,
nav,
)
is EphemeralChatChannel ->
EphemeralChatChannelHeader(
it,
sendToChannel,
modifier,
accountViewModel,
nav,
)
}
}
}
@@ -24,14 +24,17 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@Composable
fun ChannelScreen(
@@ -46,6 +49,7 @@ fun ChannelScreen(
topBar = {
LoadChannel(channelId, accountViewModel) {
when (it) {
is EphemeralChatChannel -> EphemeralChatTopBar(it, accountViewModel, nav)
is PublicChatChannel -> PublicChatTopBar(it, accountViewModel, nav)
is LiveActivitiesChannel -> LiveActivityTopBar(it, accountViewModel, nav)
}
@@ -58,3 +62,24 @@ fun ChannelScreen(
}
}
}
@Composable
fun EphemeralChatScreen(
channelId: RoomId,
accountViewModel: AccountViewModel,
nav: INav,
) {
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
LoadChannel(channelId, accountViewModel) {
EphemeralChatTopBar(it, accountViewModel, nav)
}
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
ChannelView(channelId, accountViewModel, nav)
}
}
}
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53L
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import kotlinx.coroutines.launch
@Composable
@@ -62,6 +63,23 @@ fun ChannelView(
}
}
@Composable
fun ChannelView(
channelId: RoomId?,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (channelId == null) return
LoadChannel(channelId, accountViewModel) {
PrepareChannelViewModels(
baseChannel = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
fun PrepareChannelViewModels(
baseChannel: Channel,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
@@ -32,6 +33,7 @@ import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
@@ -52,9 +54,6 @@ class ChannelFilterAssembler(
FeedType.GLOBAL,
FeedType.SEARCH,
)
val PUBLIC_CHAT_LIST = listOf(ChannelMessageEvent.KIND)
val LIVE_ACTIVITY_LIST = listOf(LiveActivitiesChatMessageEvent.KIND)
}
private val latestEOSEs = EOSEAccount()
@@ -66,7 +65,7 @@ class ChannelFilterAssembler(
types = RELAY_SET,
filter =
SincePerRelayFilter(
kinds = PUBLIC_CHAT_LIST,
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(key.account.userProfile().pubkeyHex),
limit = 50,
),
@@ -76,25 +75,29 @@ class ChannelFilterAssembler(
types = RELAY_SET,
filter =
SincePerRelayFilter(
kinds = LIVE_ACTIVITY_LIST,
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(key.account.userProfile().pubkeyHex),
limit = 50,
),
)
is EphemeralChatChannel ->
null // there is nothing in the past
else -> {
null
}
}
fun createMessagesToChannelFilter(key: ChannelQueryState): TypedFilter? {
return when (key.channel) {
val channel = key.channel
return when (channel) {
is PublicChatChannel ->
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(key.channel.idHex)),
tags = mapOf("e" to listOfNotNull(channel.idHex)),
limit = 200,
),
)
@@ -104,10 +107,32 @@ class ChannelFilterAssembler(
filter =
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(key.channel.idHex)),
tags = mapOf("a" to listOfNotNull(channel.idHex)),
limit = 200,
),
)
is EphemeralChatChannel -> {
println("AABBCC - Creating ${channel.roomId.id} ${channel.roomId.relayUrl}")
TypedFilter(
types =
setOf(
FeedType.FOLLOWS,
FeedType.PUBLIC_CHATS,
FeedType.GLOBAL,
),
filter =
SincePerRelayFilter(
kinds = listOf(EphemeralChatEvent.KIND),
tags =
if (channel.roomId.id.isBlank()) {
mapOf("d" to listOf("_"))
} else {
mapOf("d" to listOfNotNull(channel.roomId.id))
},
limit = 200,
),
)
}
else -> {
null
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2024 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.screen.loggedIn.chats.publicChannels.ephemChat.header
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdPadding
@Composable
fun EphemeralChatChannelHeader(
baseChannel: EphemeralChatChannel,
sendToChannel: Boolean = false,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(Modifier.fillMaxWidth()) {
Column(
verticalArrangement = Arrangement.Center,
modifier =
modifier.clickable {
if (sendToChannel) {
nav.nav(routeFor(baseChannel))
}
},
) {
ShortEphemeralChatChannelHeader(
baseChannel = baseChannel,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 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.screen.loggedIn.chats.publicChannels.ephemChat.header
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun EphemeralChatTopBar(
baseChannel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
TopBarExtensibleWithBackButton(
title = {
ShortEphemeralChatChannelHeader(
baseChannel = baseChannel,
accountViewModel = accountViewModel,
nav = nav,
)
},
popBack = nav::popBack,
)
}
@@ -0,0 +1,163 @@
/**
* Copyright (c) 2024 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.screen.loggedIn.chats.publicChannels.ephemChat.header
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.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.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.produceStateIfNotNull
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.JoinChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.LeaveChatButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
@Composable
fun loadRelayInfo(
relayUrl: String,
accountViewModel: AccountViewModel,
): Nip11RelayInformation? {
@Suppress("ProduceStateDoesNotAssignValue")
val relayInfo by produceStateIfNotNull(
Nip11CachedRetriever.getFromCache(relayUrl),
relayUrl,
) {
accountViewModel.retrieveRelayDocument(
relayUrl,
onInfo = {
value = it
},
onError = { url, errorCode, exceptionMessage ->
},
)
}
return relayInfo
}
@Composable
fun ShortEphemeralChatChannelHeader(
baseChannel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val channelState by observeChannel(baseChannel)
val channel = channelState?.channel as? EphemeralChatChannel ?: return
Row(verticalAlignment = Alignment.CenterVertically) {
DrawRelayIcon(baseChannel, accountViewModel)
Column(
Modifier
.padding(start = 10.dp)
.height(35.dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = remember(channelState) { channel.toBestDisplayName() },
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(
modifier =
Modifier
.height(Size35dp)
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ShortEphemeralChatActionOptions(channel, accountViewModel, nav)
}
}
}
@Composable
private fun DrawRelayIcon(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
) {
val relayInfo = loadRelayInfo(channel.roomId.relayUrl, accountViewModel)
val info =
remember(channel.roomId.relayUrl) {
RelayBriefInfoCache.get(RelayUrlFormatter.normalize(channel.roomId.relayUrl))
}
RobohashFallbackAsyncImage(
robot = channel.idHex,
model = relayInfo?.icon ?: info.favIcon,
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
)
}
@Composable
fun ShortEphemeralChatActionOptions(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
JoinEphemeralChatButtonIfNotAlreadyJoined(channel, accountViewModel, nav)
}
@Composable
fun JoinEphemeralChatButtonIfNotAlreadyJoined(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel)
if (!isFollowing) {
JoinChatButton(channel, accountViewModel, nav)
} else {
LeaveChatButton(channel, accountViewModel, nav)
}
}
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2024 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.screen.loggedIn.chats.publicChannels.ephemChat.header.actions
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.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier
@Composable
fun JoinChatButton(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = { accountViewModel.follow(channel) },
contentPadding = ButtonPadding,
) {
Text(text = stringRes(R.string.join))
}
}
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2024 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.screen.loggedIn.chats.publicChannels.ephemChat.header.actions
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.EphemeralChatChannel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier
@Composable
fun LeaveChatButton(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
FilledTonalButton(
modifier = HalfHalfHorzModifier,
onClick = { accountViewModel.unfollow(channel) },
contentPadding = ButtonPadding,
) {
Text(text = stringRes(R.string.leave))
}
}
@@ -0,0 +1,59 @@
/**
* Copyright (c) 2024 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.screen.loggedIn.chats.publicChannels.ephemChat.metadata
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
class NewEphemeralChatMetaViewModel : ViewModel() {
private var account: Account? = null
val relayUrl = mutableStateOf(TextFieldValue())
val channelName = mutableStateOf(TextFieldValue())
val canPost by derivedStateOf {
channelName.value.text.isNotBlank() && relayUrl.value.text.isNotBlank()
}
fun load(account: Account) {
this.account = account
}
fun buildRoom() = RoomId(channelName.value.text, relayUrl.value.text)
/*
fun createOrUpdate(onDone: (RoomId) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
account?.follow(, onDone)
clear()
}
}*/
fun clear() {
channelName.value = TextFieldValue()
relayUrl.value = TextFieldValue()
}
}
@@ -0,0 +1,222 @@
/**
* Copyright (c) 2024 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.screen.loggedIn.chats.publicChannels.ephemChat.metadata
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NewEphemeralChatScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val postViewModel: NewEphemeralChatMetaViewModel = viewModel()
postViewModel.load(accountViewModel.account)
ChannelMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Preview
@Composable
private fun DialogContentPreview() {
val accountViewModel = mockAccountViewModel()
val postViewModel: NewEphemeralChatMetaViewModel = viewModel()
postViewModel.load(accountViewModel.account)
ThemeComparisonColumn {
ChannelMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = EmptyNav,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChannelMetadataScaffold(
postViewModel: NewEphemeralChatMetaViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = MinHorzSpacer)
Text(
text = stringRes(R.string.relay_chat),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
JoinButton(
onPost = {
nav.popBack()
nav.nav(routeFor(postViewModel.buildRoom()))
},
postViewModel.canPost,
)
}
},
navigationIcon = {
Row {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.clear()
nav.popBack()
},
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
LazyColumn(
Modifier
.fillMaxSize()
.padding(
start = 10.dp,
end = 10.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding(),
) {
item {
SettingsCategory(
stringRes(R.string.relay_chat_title),
stringRes(R.string.relay_chat_explainer),
SettingsCategoryFirstModifier,
)
RelayUrl(postViewModel)
Spacer(modifier = DoubleVertSpacer)
ChannelName(postViewModel)
}
}
}
}
@Composable
private fun ChannelName(postViewModel: NewEphemeralChatMetaViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.channel_name)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelName.value,
onValueChange = { postViewModel.channelName.value = it },
placeholder = {
Text(
text = stringRes(R.string.my_awesome_group),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@Composable
private fun RelayUrl(postViewModel: NewEphemeralChatMetaViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.group_relay)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.relayUrl.value,
onValueChange = { postViewModel.relayUrl.value = it },
placeholder = {
Text(
text = "nos.lol",
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@@ -211,7 +211,7 @@ fun LeaveButtonIfFollowing(
accountViewModel: AccountViewModel,
nav: INav,
) {
val isFollowing by observeUserIsFollowingChannel(accountViewModel.userProfile(), channel)
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel)
if (isFollowing) {
LeaveChatButton(channel, accountViewModel, nav)
@@ -139,7 +139,7 @@ fun JoinChatButtonIfNotAlreadyJoined(
accountViewModel: AccountViewModel,
nav: INav,
) {
val isFollowing by observeUserIsFollowingChannel(accountViewModel.userProfile(), channel)
val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel)
if (!isFollowing) {
JoinChatButton(channel, accountViewModel, nav)
@@ -110,9 +110,9 @@ class ChannelMetadataViewModel : ViewModel() {
relayList = { it.channelInfo().relays },
onDone = {
val channel = LocalCache.getOrCreateChannel(it.id) { PublicChatChannel(it) }
// follows the channel
account.follow(channel)
if (channel is PublicChatChannel) {
// follows the channel
account.follow(channel)
onDone(channel)
}
},
@@ -37,11 +37,13 @@ import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
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.UploadOrchestrator
@@ -58,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -371,7 +374,7 @@ open class ChannelNewMessageViewModel :
val urls = findURLs(message.text)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = findEmoji(message.text, accountViewModel.account.myEmojis.value)
val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value)
val channelRelays = channel.relays()
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
@@ -453,6 +456,19 @@ open class ChannelNewMessageViewModel :
imetas(usedAttachments)
}
}
} else if (channel is EphemeralChatChannel) {
EphemeralChatEvent.build(
tagger.message,
channel.roomId.relayUrl,
channel.roomId.id,
) {
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
emojis(emojis)
imetas(usedAttachments)
}
} else {
null
}
@@ -460,7 +476,7 @@ open class ChannelNewMessageViewModel :
fun findEmoji(
message: String,
myEmojiSet: List<Account.EmojiMedia>?,
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
@@ -551,7 +567,7 @@ open class ChannelNewMessageViewModel :
draftTag.newVersion()
}
open fun autocompleteWithEmoji(item: Account.EmojiMedia) {
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
@@ -560,7 +576,7 @@ open class ChannelNewMessageViewModel :
draftTag.newVersion()
}
open fun autocompleteWithEmojiUrl(item: Account.EmojiMedia) {
open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) {
val wordToInsert = item.link.url + " "
viewModelScope.launch(Dispatchers.IO) {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import android.R.attr.label
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
@@ -55,8 +56,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent
@@ -75,17 +78,20 @@ import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
@Composable
fun ChatroomHeaderCompose(
@@ -166,14 +172,17 @@ private fun ChatroomChannel(
nav: INav,
) {
LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel ->
ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
when (channel) {
is PublicChatChannel -> ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
is EphemeralChatChannel -> ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
}
}
}
@Composable
private fun ChannelRoomCompose(
note: Note,
channel: Channel,
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -201,7 +210,53 @@ private fun ChannelRoomCompose(
ChannelName(
channelIdHex = channel.idHex,
channelPicture = channelPicture,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, modifier) },
channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, R.string.public_chat, modifier) },
channelLastTime = note.createdAt(),
channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onClick = { nav.nav(route) },
)
}
@Composable
private fun ChannelRoomCompose(
note: Note,
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val authorName by observeUserName(note.author!!)
val channelState by observeChannel(channel)
val relayInfo = loadRelayInfo(channel.roomId.relayUrl, accountViewModel)
val info =
remember(channel.roomId.relayUrl) {
RelayBriefInfoCache.get(RelayUrlFormatter.normalize(channel.roomId.relayUrl))
}
val channelName = channelState?.channel?.toBestDisplayName() ?: channel.toBestDisplayName()
val noteEvent = note.event
val route = Route.Channel(channel.idHex)
val description =
if (noteEvent is ChannelCreateEvent) {
stringRes(R.string.channel_created)
} else if (noteEvent is ChannelMetadataEvent) {
"${stringRes(R.string.channel_information_changed_to)} "
} else {
noteEvent?.content?.take(200)
}
val lastReadTime by accountViewModel.account.loadLastReadFlow("Channel/${channel.idHex}").collectAsStateWithLifecycle()
ChannelName(
channelIdHex = channel.idHex,
channelPicture = relayInfo?.icon ?: info.favIcon,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, R.string.ephemeral_relay_chat, modifier) },
channelLastTime = note.createdAt(),
channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
@@ -214,9 +269,10 @@ private fun ChannelRoomCompose(
@Composable
private fun ChannelTitleWithLabelInfo(
channelName: String,
label: Int,
modifier: Modifier,
) {
val label = stringRes(id = R.string.public_chat)
val label = stringRes(id = label)
val placeHolderColor = MaterialTheme.colorScheme.placeholderText
val channelNameAndBoostInfo =
remember(channelName) {
@@ -26,7 +26,8 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@@ -56,7 +57,18 @@ class ChatroomListKnownFeedFilter(
val publicChannels =
account
.selectedChatsFollowList()
.publicChatList.livePublicChatList.value
.mapNotNull { LocalCache.getChannelIfExists(it.eventId) }
.mapNotNull { it ->
it.notes
.filter { key, it -> account.isAcceptable(it) && it.event != null }
.sortedWith(DefaultFeedOrder)
.firstOrNull()
}
val ephemeralChats =
account
.ephemeralChatList.liveEphemeralChatList.value
.mapNotNull { LocalCache.getChannelIfExists(it) }
.mapNotNull { it ->
it.notes
@@ -65,7 +77,7 @@ class ChatroomListKnownFeedFilter(
.firstOrNull()
}
return (privateMessages + publicChannels).sortedWith(DefaultFeedOrder)
return (privateMessages + publicChannels + ephemeralChats).sortedWith(DefaultFeedOrder)
}
override fun updateListWith(
@@ -76,11 +88,12 @@ class ChatroomListKnownFeedFilter(
// Gets the latest message by channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
val newRelevantEphemeralChats = filterRelevantEphemeralChats(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty()) {
return oldList
}
@@ -101,6 +114,21 @@ class ChatroomListKnownFeedFilter(
}
}
newRelevantEphemeralChats.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
if (newNotePair.key.toKey() == oldNote.channelHex()) {
hasUpdated = true
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
myNewList = myNewList.replace(oldNote, newNotePair.value)
}
}
}
if (!hasUpdated) {
myNewList = myNewList.plus(newNotePair.value)
}
}
newRelevantPrivateMessages.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
@@ -124,14 +152,15 @@ class ChatroomListKnownFeedFilter(
override fun applyFilter(newItems: Set<Note>): Set<Note> {
// Gets the latest message by channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
val newRelevantEphemeralChats = filterRelevantEphemeralChats(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty()) {
emptySet()
} else {
(newRelevantPrivateMessages.values + newRelevantPublicMessages.values).toSet()
(newRelevantPrivateMessages.values + newRelevantPublicMessages.values + newRelevantEphemeralChats.values).toSet()
}
}
@@ -139,12 +168,7 @@ class ChatroomListKnownFeedFilter(
newItems: Set<Note>,
account: Account,
): MutableMap<String, Note> {
val followingChannels =
account
.userProfile()
.latestContactList
?.taggedEventIds()
?.toSet() ?: emptySet()
val followingChannels = account.publicChatList.livePublicChatEventIdSet.value
val newRelevantPublicMessages = mutableMapOf<String, Note>()
newItems
.filter { it.event is ChannelMessageEvent }
@@ -165,6 +189,33 @@ class ChatroomListKnownFeedFilter(
return newRelevantPublicMessages
}
private fun filterRelevantEphemeralChats(
newItems: Set<Note>,
account: Account,
): MutableMap<RoomId, Note> {
val followingEphemeralChats = account.ephemeralChatList.liveEphemeralChatList.value
val newRelevantEphemeralChats = mutableMapOf<RoomId, Note>()
newItems
.forEach { newNote ->
val noteEvent = newNote.event as? EphemeralChatEvent
if (noteEvent != null) {
val room = noteEvent.roomId()
if (room in followingEphemeralChats && account.isAcceptable(newNote)) {
val lastNote = newRelevantEphemeralChats.get(room)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantEphemeralChats.put(room, newNote)
}
} else {
newRelevantEphemeralChats.put(room, newNote)
}
}
}
}
return newRelevantEphemeralChats
}
private fun filterRelevantPrivateMessages(
newItems: Set<Note>,
account: Account,
@@ -30,10 +30,13 @@ import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import kotlinx.coroutines.flow.toList
import kotlin.collections.flatten
// This allows multiple screen to be listening to tags, even the same tag
class ChatroomListState(
@@ -96,7 +99,7 @@ class ChatroomListFilterAssembler(
)
fun createMyChannelsFilter(account: Account): TypedFilter? {
val followingEvents = account.selectedChatsFollowList()
val followingEvents = account.publicChatList.livePublicChatEventIdSet.value
if (followingEvents.isEmpty()) return null
@@ -117,7 +120,8 @@ class ChatroomListFilterAssembler(
}
fun createLastChannelInfoFilter(account: Account): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
val followingEvents =
account.publicChatList.livePublicChatEventIdSet.value
if (followingEvents.isEmpty()) return null
@@ -136,7 +140,8 @@ class ChatroomListFilterAssembler(
}
fun createLastMessageOfEachChannelFilter(account: Account): List<TypedFilter>? {
val followingEvents = account.selectedChatsFollowList()
val followingEvents =
account.publicChatList.livePublicChatEventIdSet.value
if (followingEvents.isEmpty()) return null
@@ -159,6 +164,33 @@ class ChatroomListFilterAssembler(
}
}
fun createLastMessageOfEachEphemeralChatFilter(account: Account): List<TypedFilter>? {
val followingEvents =
account.ephemeralChatList.liveEphemeralChatList.value
.map { it.id }
if (followingEvents.isEmpty()) return null
return listOf(
TypedFilter(
// Metadata comes from any relay
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
kinds = listOf(EphemeralChatEvent.KIND),
tags = mapOf("d" to followingEvents),
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(chatRoomListKey)
?.relayList,
// Remember to consider spam that is being removed from the UI
limit = 50,
),
),
)
}
fun mergeAllFilters(account: Account): List<TypedFilter>? =
listOfNotNull(
listOfNotNull(
@@ -168,6 +200,7 @@ class ChatroomListFilterAssembler(
),
createLastChannelInfoFilter(account),
createLastMessageOfEachChannelFilter(account),
createLastMessageOfEachEphemeralChatFilter(account),
).flatten().ifEmpty { null }
fun newSub(key: ChatroomListState): Subscription =
@@ -203,7 +203,8 @@ class DiscoveryFilterAssembler(
val follows =
key.account.liveDiscoveryListAuthorsPerRelay.value
?.ifEmpty { null }
val followChats = key.account.selectedChatsFollowList().toList()
val followChats =
key.account.publicChatList.livePublicChatEventIdSet.value
return listOfNotNull(
TypedFilter(
@@ -221,7 +222,7 @@ class DiscoveryFilterAssembler(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
SincePerRelayFilter(
ids = followChats,
ids = followChats.toList(),
kinds =
listOf(
ChannelCreateEvent.KIND,
@@ -38,6 +38,7 @@ 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
@@ -330,7 +331,7 @@ open class NewProductViewModel :
)
tagger.run()
val emojis = findEmoji(tagger.message, account?.myEmojis?.value)
val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value)
val urls = findURLs(tagger.message)
val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() }
@@ -370,7 +371,7 @@ open class NewProductViewModel :
fun findEmoji(
message: String,
myEmojiSet: List<Account.EmojiMedia>?,
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
@@ -543,7 +544,7 @@ open class NewProductViewModel :
draftTag.newVersion()
}
open fun autocompleteWithEmoji(item: Account.EmojiMedia) {
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message = message.replaceCurrentWord(wordToInsert)
@@ -554,7 +555,7 @@ open class NewProductViewModel :
draftTag.newVersion()
}
open fun autocompleteWithEmojiUrl(item: Account.EmojiMedia) {
open fun autocompleteWithEmojiUrl(item: EmojiPackState.EmojiMedia) {
val wordToInsert = item.link.url + " "
viewModelScope.launch(Dispatchers.IO) {
@@ -22,11 +22,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Tab
@@ -47,9 +55,13 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AROUND_ME
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
@@ -59,17 +71,25 @@ import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.live.RenderEphemeralBubble
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.HorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import kotlin.collections.forEachIndexed
@Composable
fun HomeScreen(
@@ -77,6 +97,7 @@ fun HomeScreen(
nav: INav,
) {
HomeScreen(
liveFeedState = accountViewModel.feedStates.homeLive,
newThreadsFeedState = accountViewModel.feedStates.homeNewThreads,
repliesFeedState = accountViewModel.feedStates.homeReplies,
accountViewModel = accountViewModel,
@@ -87,6 +108,7 @@ fun HomeScreen(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomeScreen(
liveFeedState: ChannelFeedContentState,
newThreadsFeedState: FeedContentState,
repliesFeedState: FeedContentState,
accountViewModel: AccountViewModel,
@@ -94,12 +116,13 @@ fun HomeScreen(
) {
WatchAccountForHomeScreen(newThreadsFeedState, repliesFeedState, accountViewModel)
WatchLifecycleAndUpdateModel(liveFeedState)
WatchLifecycleAndUpdateModel(newThreadsFeedState)
WatchLifecycleAndUpdateModel(repliesFeedState)
HomeFilterAssemblerSubscription(accountViewModel)
AssembleHomeTabs(newThreadsFeedState, repliesFeedState) { pagerState, tabItems ->
AssembleHomeTabs(newThreadsFeedState, repliesFeedState, liveFeedState) { pagerState, tabItems ->
HomePages(pagerState, tabItems, accountViewModel, nav)
}
}
@@ -109,6 +132,7 @@ fun HomeScreen(
private fun AssembleHomeTabs(
newThreadsFeedState: FeedContentState,
repliesFeedState: FeedContentState,
liveFeedState: ChannelFeedContentState,
inner: @Composable (PagerState, ImmutableList<TabItem>) -> Unit,
) {
val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN) { 2 }
@@ -122,12 +146,14 @@ private fun AssembleHomeTabs(
feedState = newThreadsFeedState,
routeForLastRead = "HomeFollows",
scrollStateKey = ScrollStateKeys.HOME_FOLLOWS,
liveSection = liveFeedState,
),
TabItem(
resource = R.string.conversations,
feedState = repliesFeedState,
routeForLastRead = "HomeFollowsReplies",
scrollStateKey = ScrollStateKeys.HOME_REPLIES,
liveSection = liveFeedState,
),
).toImmutableList(),
)
@@ -192,6 +218,7 @@ private fun HomePages(
feedState = tabs[page].feedState,
routeForLastRead = tabs[page].routeForLastRead,
scrollStateKey = tabs[page].scrollStateKey,
liveSection = tabs[page].liveSection,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -205,6 +232,7 @@ fun HomeFeeds(
routeForLastRead: String?,
enablePullRefresh: Boolean = true,
scrollStateKey: String? = null,
liveSection: ChannelFeedContentState? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -216,12 +244,93 @@ fun HomeFeeds(
listState = listState,
nav = nav,
routeForLastRead = routeForLastRead,
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) },
onEmpty = { HomeFeedEmpty(feedState::invalidateData) },
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
routeForLastRead: String?,
liveSection: ChannelFeedContentState? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = FeedPadding,
state = listState,
) {
if (liveSection != null) {
item {
DisplayLiveBubbles(liveSection, accountViewModel, nav)
Spacer(StdVertSpacer)
}
}
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
Row(
Modifier
.fillMaxWidth()
.animateItem(),
) {
NoteCompose(
item,
modifier = Modifier.fillMaxWidth(),
routeForLastRead = routeForLastRead,
isBoostedNote = false,
isHiddenFeed = items.showHidden,
quotesLeft = 3,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
@Composable
fun DisplayLiveBubbles(
liveSection: ChannelFeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feedState by liveSection.feedContent.collectAsStateWithLifecycle()
when (val state = feedState) {
is ChannelFeedState.Empty -> null
is ChannelFeedState.FeedError -> null
is ChannelFeedState.Loaded -> DisplayLiveBubbles(state, accountViewModel, nav)
is ChannelFeedState.Loading -> null
}
}
@Composable
fun DisplayLiveBubbles(
liveFeed: ChannelFeedState.Loaded,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feed by liveFeed.feed.collectAsStateWithLifecycle()
LazyRow(HorzPadding, horizontalArrangement = spacedBy(Size5dp)) {
itemsIndexed(feed.list, key = { _, item -> item.idHex }) { _, item ->
when (item) {
is EphemeralChatChannel -> RenderEphemeralBubble(item, accountViewModel, nav)
}
}
}
}
@Preview
@Composable
fun HomeFeedEmptyPreview() {
@@ -319,4 +428,5 @@ class TabItem(
val scrollStateKey: String,
val forceEventKind: Int? = null,
val useGridLayout: Boolean = false,
val liveSection: ChannelFeedContentState? = null,
)
@@ -0,0 +1,168 @@
/**
* Copyright (c) 2024 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.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveComplexFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.collections.toSet
class HomeLiveFilter(
val account: Account,
) : AdditiveComplexFeedFilter<Channel, Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex
override fun showHiddenKey(): Boolean = false
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
userHex = account.userProfile().pubkeyHex,
selectedListName = account.settings.defaultHomeFollowList.value,
followLists = account.liveHomeFollowLists.value,
hiddenUsers = account.flowHiddenUsers.value,
)
fun limitTime() = TimeUtils.fifteenMinutesAgo()
override fun feed(): List<Channel> {
val gRelays = account.activeGlobalRelays().toSet()
val filterParams = buildFilterParams(account)
val fiveMinsAgo = limitTime()
val list =
LocalCache.channels.filter { id, channel ->
shouldIncludeChannel(channel, gRelays, filterParams, fiveMinsAgo)
}
return sort(list.toSet())
}
fun shouldIncludeChannel(
channel: Channel,
gRelays: Set<String>,
filterParams: FilterByListParams,
timeLimit: Long,
): Boolean =
if (channel is EphemeralChatChannel) {
val list =
channel.notes.filter { key, value ->
acceptableEvent(value, gRelays, filterParams, timeLimit)
}
list.isNotEmpty()
} else {
false
}
override fun updateListWith(
oldList: List<Channel>,
newItems: Set<Note>,
): List<Channel> {
val fiveMinsAgo = limitTime()
val revisedOldList =
oldList.filter { channel ->
channel.lastNoteCreatedAt > fiveMinsAgo
}
val newItemsToBeAdded = applyFilter(newItems)
return if (newItemsToBeAdded.isNotEmpty()) {
val channelsToAdd =
newItemsToBeAdded
.mapNotNull {
val room = (it.event as? EphemeralChatEvent)?.roomId()
if (room != null) {
LocalCache.getChannelIfExists(room)
} else {
null
}
}
val newList = revisedOldList.toSet() + channelsToAdd
sort(newList).take(limit())
} else {
revisedOldList
}
}
private fun applyFilter(collection: Collection<Note>): Set<Note> {
val gRelays = account.activeGlobalRelays().toSet()
val filterParams = buildFilterParams(account)
return collection.filterTo(HashSet()) {
acceptableEvent(it, gRelays, filterParams, limitTime())
}
}
private fun acceptableEvent(
it: Note,
globalRelays: Set<String>,
filterParams: FilterByListParams,
timeLimit: Long,
): Boolean {
val createdAt = it.createdAt() ?: return false
val noteEvent = it.event
val isGlobalRelay = it.relays.any { globalRelays.contains(it.url) }
return (noteEvent is EphemeralChatEvent) &&
createdAt > timeLimit &&
filterParams.match(noteEvent, isGlobalRelay)
}
fun sort(collection: Set<Channel>): List<Channel> {
val followingKeySet =
account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors
val followCounts =
collection.associate { it to followsThatParticipateOn(it, followingKeySet) }
return collection.sortedWith(
compareByDescending<Channel> { followCounts[it] }
.thenByDescending<Channel> { it.lastNoteCreatedAt }
.thenBy { it.idHex },
)
}
fun followsThatParticipateOn(
channel: Channel,
followingSet: Set<HexKey>?,
): Int {
if (channel == null) return 0
var count = 0
channel.notes.forEach { key, value ->
val author = value.author
if (author != null) {
if (followingSet == null || author.pubkeyHex in followingSet) {
count++
}
}
}
return count
}
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -80,11 +81,8 @@ class HomeFilterAssembler(
GenericRepostEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
PollNoteEvent.KIND,
EphemeralChatEvent.KIND,
HighlightEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
PinListEvent.KIND,
),
authors = follows,
limit = 400,
@@ -101,6 +99,10 @@ class HomeFilterAssembler(
SinceAuthorPerRelayFilter(
kinds =
listOf(
PollNoteEvent.KIND,
AudioTrackEvent.KIND,
AudioHeaderEvent.KIND,
PinListEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesEvent.KIND,
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2024 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.screen.loggedIn.home.live
import android.R.attr.onClick
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.EphemeralChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelNoteAuthors
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.Gallery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@Composable
fun RenderEphemeralBubble(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
FilledTonalButton(
contentPadding = PaddingValues(start = 8.dp, end = 10.dp, bottom = 0.dp, top = 0.dp),
onClick = {
nav.nav { routeFor(channel) }
},
) {
RenderUsers(channel, accountViewModel)
Spacer(StdHorzSpacer)
Text(
channel.toBestDisplayName(),
)
}
}
@Composable
fun RenderUsers(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
) {
val authors by observeChannelNoteAuthors(channel)
Gallery(authors, Modifier, accountViewModel, 3)
}
@@ -29,14 +29,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
@@ -69,10 +68,7 @@ fun BasicRelaySetupInfoClickableRow(
verticalAlignment = Alignment.CenterVertically,
modifier = HalfVertPadding,
) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
val iconUrlFromRelayInfoDoc = loadRelayInfo(item.url, accountViewModel)?.icon
RenderRelayIcon(
item.briefInfo.displayUrl,
@@ -326,14 +326,11 @@ fun ClickableRelayItem(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
val relayInfo = Nip11CachedRetriever.getFromCache(item.url)
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
relayInfo?.icon ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
item.relayStat.pingInMs,
@@ -39,20 +39,19 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.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.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.AddRelayButton
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -85,14 +84,11 @@ fun Kind3RelaySetupInfoProposalRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
val relayInfo = loadRelayInfo(item.url, accountViewModel)
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
relayInfo?.icon ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
item.relayStat.pingInMs,
+13
View File
@@ -225,6 +225,15 @@
<string name="unfollow">Unfollow</string>
<string name="channel_created">Channel created</string>
<string name="channel_information_changed_to">"Channel Information changed to"</string>
<string name="ephemeral_relay_chat">Disappearing Chat</string>
<string name="relay_chat">Relay Chat</string>
<string name="relay_chat_title">Relay Chats</string>
<string name="relay_chat_explainer">Relay chats are chat groups controlled by their home relay.
They are visible to everyone on Nostr and anyone can participate on them.
They are great for open communities around specific topics. Some of these groups are ephemeral
and thus chat messages disappear over time</string>
<string name="public_chat">Public Chat</string>
<string name="public_chat_title">Public Chat Metadata</string>
<string name="public_chat_explainer">Public chats are visible to everyone on Nostr and anyone
@@ -722,6 +731,7 @@
<string name="messages_create_public_chat">Public</string>
<string name="messages_create_public_private_chat_description">New Public or Private Group</string>
<string name="messages_relay_based">Relay</string>
<string name="messages_new_message">Private</string>
<string name="messages_new_message_to">To</string>
<string name="messages_new_message_subject">Subject</string>
@@ -1144,4 +1154,7 @@
<string name="temporary_account">Log off on device lock</string>
<string name="private_message">Private Message</string>
<string name="group_relay">Chat Relay</string>
<string name="group_relay_explanation">The relay that all users of this chat connect to</string>
</resources>
@@ -51,7 +51,7 @@ class RelaySubFilter(
val activeTypes: Set<FeedType>,
val subs: SubscriptionManager,
) : SubscriptionCollection {
fun isMatch(filter: TypedFilter) = activeTypes.any { it in filter.types } && filter.filter.isValidFor(url)
fun isMatch(filter: TypedFilter) = activeTypes.any { it in filter.types } && filter.isValidFor(url)
fun match(filters: List<TypedFilter>): Boolean =
filters.any { filter ->
@@ -25,4 +25,7 @@ import com.vitorpamplona.ammolite.relays.filters.IPerRelayFilter
class TypedFilter(
val types: Set<FeedType>,
val filter: IPerRelayFilter,
)
) {
// This only exists because some relays confuse empty lists with null lists
fun isValidFor(url: String) = filter.isValidFor(url)
}
@@ -25,10 +25,10 @@ import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper
import com.vitorpamplona.quartz.utils.LargeCache
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -25,10 +25,10 @@ import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper
import com.vitorpamplona.quartz.utils.LargeCache
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
@@ -53,11 +55,11 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
@@ -193,6 +195,8 @@ class EventFactory {
DraftEvent.KIND -> DraftEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig)
EphemeralChatListEvent.KIND -> EphemeralChatListEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig)
FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.chat
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RelayTag
import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RoomTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class EphemeralChatEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun room() = tags.firstNotNullOfOrNull(RoomTag::parse) ?: DEFAULT_ROOM
fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) ?: ""
fun roomId() = RoomId(room(), relay())
companion object {
const val KIND = 23333
const val ALT_DESCRIPTION = "Ephemeral Chat"
const val DEFAULT_ROOM = "_"
fun build(
message: String,
relay: String,
room: String = DEFAULT_ROOM,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EphemeralChatEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
room(room)
relay(relay)
alt(ALT_DESCRIPTION)
initializer()
}
}
}
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.chat
import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
data class RoomId(
val id: String,
val relayUrl: String,
) {
fun toKey() = "$id@$relayUrl"
fun toDisplayKey() = id + "@" + RelayUrlFormatter.displayUrl(relayUrl)
}
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.chat
import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RelayTag
import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RoomTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun TagArrayBuilder<EphemeralChatEvent>.room(room: String) = addUnique(RoomTag.assemble(room))
fun TagArrayBuilder<EphemeralChatEvent>.relay(room: String) = addUnique(RelayTag.assemble(room))
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.chat.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class RelayTag {
companion object {
const val TAG_NAME = "relay"
@JvmStatic
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@JvmStatic
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.chat.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class RoomTag {
companion object {
const val TAG_NAME = "d"
@JvmStatic
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
@JvmStatic
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -0,0 +1,58 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.db
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
class Room(
val roomId: RoomId,
) {
constructor(id: String, relayUrl: String) : this(RoomId(id, relayUrl))
val messages = LargeCache<HexKey, EphemeralChatEvent>()
fun addMsg(event: EphemeralChatEvent) {
if (!messages.containsKey(event.id)) {
messages.put(event.id, event)
messageFlow.tryEmit(RoomState(this))
}
}
fun removeMsg(event: EphemeralChatEvent) {
val existingEvent = messages.remove(event.id)
if (existingEvent != null) {
messageFlow.tryEmit(RoomState(this))
}
}
// Observers line up here.
val messageFlow = MutableStateFlow(RoomState(this))
}
class RoomState(
val room: Room,
)
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.db
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.utils.LargeCache
class EphemeralRoomCache {
val rooms = LargeCache<RoomId, Room>()
fun getRoomIfExists(
roomId: String,
relayUrl: String,
): Room? = rooms.get(RoomId(roomId, relayUrl))
fun getOrCreateRoom(
roomId: String,
relayUrl: String,
): Room = rooms.getOrCreate(RoomId(roomId, relayUrl)) { Room(roomId, relayUrl) }
fun findRoomsStartingWith(text: String): List<Room> {
if (text.isBlank()) return emptyList()
return rooms.filter { _, room ->
room.roomId.id.startsWith(text)
}
}
}
@@ -0,0 +1,149 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.list
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.collections.plus
@Immutable
class EphemeralChatListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient var publicAndPrivateEventCache: Set<RoomId>? = null
fun publicAndPrivateRoomIds(
signer: NostrSigner,
onReady: (Set<RoomId>) -> Unit,
) {
publicAndPrivateEventCache?.let { eventList ->
onReady(eventList)
return
}
privateTags(signer) {
val set = filterRooms(it)
publicAndPrivateEventCache = set
onReady(set)
}
}
fun filterRooms(privateTags: Array<Array<String>>): Set<RoomId> {
val privateRooms = privateTags.mapNotNull(RoomIdTag::parse)
val publicRooms = tags.mapNotNull(RoomIdTag::parse)
return (privateRooms + publicRooms).toSet()
}
companion object {
const val KIND = 10023
const val ALT = "Ephemeral Chat List"
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
fun createRoom(
room: RoomId,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (EphemeralChatListEvent) -> Unit,
) {
val tags = arrayOf(RoomIdTag.Companion.assemble(room))
if (isPrivate) {
PrivateTagsInContent.Companion.encryptNip04(
privateTags = tags,
signer = signer,
) { encryptedTags ->
create(encryptedTags, emptyArray(), signer, createdAt, onReady)
}
} else {
create("", tags, signer, createdAt, onReady)
}
}
fun removeRoom(
earlierVersion: EphemeralChatListEvent,
room: RoomId,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (EphemeralChatListEvent) -> Unit,
) {
PrivateTagArrayBuilder.removeAll(
earlierVersion,
RoomIdTag.Companion.assemble(room.id, room.relayUrl),
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
fun addRoom(
earlierVersion: EphemeralChatListEvent,
room: RoomId,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (EphemeralChatListEvent) -> Unit,
) {
PrivateTagArrayBuilder.add(
earlierVersion,
RoomIdTag.Companion.assemble(room.id, room.relayUrl),
isPrivate,
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (EphemeralChatListEvent) -> Unit,
) {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
} else {
tags + AltTag.Companion.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
}
}
}
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.list
import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun TagArrayBuilder<EphemeralChatListEvent>.roomId(
id: String,
relayUrl: String,
) = addUnique(RoomIdTag.assemble(id, relayUrl))
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.ephemChat.list.tags
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class RoomIdTag {
companion object {
const val TAG_NAME = "group"
@JvmStatic
fun parse(tag: Array<String>): RoomId? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[2].isNotEmpty()) { return null }
return RoomId(tag[1], tag[2])
}
@JvmStatic
fun assemble(
id: String,
relayUrl: String,
) = arrayOf(TAG_NAME, id, relayUrl)
@JvmStatic
fun assemble(id: RoomId) = arrayOf(TAG_NAME, id.id, id.relayUrl)
}
}
@@ -29,7 +29,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
@@ -88,7 +87,6 @@ class ContactListEvent(
followTags: List<String> = emptyList(),
followGeohashes: List<String> = emptyList(),
followCommunities: List<ATag> = emptyList(),
followEvents: List<String> = emptyList(),
relayUse: Map<String, ReadWrite>? = emptyMap(),
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
@@ -99,7 +97,6 @@ class ContactListEvent(
listOf(AltTag.assemble(ALT)) +
followUsers.map { it.toTagArray() } +
followTags.map { arrayOf("t", it) } +
followEvents.map { arrayOf("e", it) } +
followCommunities.map { it.toATagArray() } +
followGeohashes.map { arrayOf("g", it) }
@@ -111,7 +108,6 @@ class ContactListEvent(
followTags: List<String>,
followGeohashes: List<String>,
followCommunities: List<ATag>,
followEvents: List<String>,
relayUse: Map<String, ReadWrite>?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
@@ -122,7 +118,6 @@ class ContactListEvent(
val tags =
followUsers.map { it.toTagArray() } +
followTags.map { arrayOf("t", it) } +
followEvents.map { arrayOf("e", it) } +
followCommunities.map { it.toATagArray() } +
followGeohashes.map { arrayOf("g", it) }
@@ -244,42 +239,6 @@ class ContactListEvent(
)
}
fun followEvent(
earlierVersion: ContactListEvent,
idHex: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ContactListEvent) -> Unit,
) {
if (earlierVersion.isTaggedEvent(idHex)) return
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = arrayOf("e", idHex)),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun unfollowEvent(
earlierVersion: ContactListEvent,
idHex: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ContactListEvent) -> Unit,
) {
if (!earlierVersion.isTaggedEvent(idHex)) return
return create(
content = earlierVersion.content,
tags = earlierVersion.tags.filter { it.size > 1 && it[1] != idHex }.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
fun followAddressableEvent(
earlierVersion: ContactListEvent,
aTag: ATag,
@@ -18,12 +18,12 @@
* 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.commons.data
package com.vitorpamplona.quartz.nip09Deletions
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.utils.LargeCache
class DeletionIndex {
data class DeletionRequest(
@@ -1,271 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip28PublicChat
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.collections.immutable.ImmutableSet
@Immutable
class ChannelListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient var publicAndPrivateEventCache: ImmutableSet<HexKey>? = null
override fun countMemory(): Long =
super.countMemory() +
32 + (publicAndPrivateEventCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0L) // rough calculation
fun publicAndPrivateEvents(
signer: NostrSigner,
onReady: (ImmutableSet<HexKey>) -> Unit,
) {
publicAndPrivateEventCache?.let { eventList ->
onReady(eventList)
return
}
privateTagsOrEmpty(signer) {
publicAndPrivateEventCache = filterTagList("e", it)
publicAndPrivateEventCache?.let { eventList ->
onReady(eventList)
}
}
}
companion object {
const val KIND = 10005
const val ALT = "Public Chat List"
fun blockListFor(pubKeyHex: HexKey): String = "$KIND:$pubKeyHex:"
fun createListWithTag(
key: String,
tag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
if (isPrivate) {
encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags ->
create(
content = encryptedTags,
tags = emptyArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
} else {
create(
content = "",
tags = arrayOf(arrayOf(key, tag)),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
fun createListWithEvent(
eventId: HexKey,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = createListWithTag("e", eventId, isPrivate, signer, createdAt, onReady)
fun addEvents(
earlierVersion: ChannelListEvent,
listEvents: List<HexKey>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
if (isPrivate) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags =
privateTags.plus(
listEvents.map { arrayOf("e", it) },
),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags = earlierVersion.tags,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
create(
content = earlierVersion.content,
tags =
earlierVersion.tags.plus(
listEvents.map { arrayOf("e", it) },
),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
fun addEvent(
earlierVersion: ChannelListEvent,
event: HexKey,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = addTag(earlierVersion, "e", event, isPrivate, signer, createdAt, onReady)
fun addTag(
earlierVersion: ChannelListEvent,
key: String,
tag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged ->
if (!isTagged) {
if (isPrivate) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags = privateTags.plus(element = arrayOf(key, tag)),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags = earlierVersion.tags,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
create(
content = earlierVersion.content,
tags = earlierVersion.tags.plus(element = arrayOf(key, tag)),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
}
}
fun removeEvent(
earlierVersion: ChannelListEvent,
event: HexKey,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = removeTag(earlierVersion, "e", event, isPrivate, signer, createdAt, onReady)
fun removeTag(
earlierVersion: ChannelListEvent,
key: String,
tag: String,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged ->
if (isTagged) {
if (isPrivate) {
earlierVersion.privateTagsOrEmpty(signer) { privateTags ->
encryptTags(
privateTags =
privateTags
.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }
.toTypedArray(),
signer = signer,
) { encryptedTags ->
create(
content = encryptedTags,
tags =
earlierVersion.tags
.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }
.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
} else {
create(
content = earlierVersion.content,
tags =
earlierVersion.tags
.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }
.toTypedArray(),
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
}
}
}
}
fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
} else {
tags + AltTag.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
}
}
}
@@ -0,0 +1,239 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip28PublicChat.list
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import java.lang.reflect.Modifier.isPrivate
import kotlin.collections.plus
@Immutable
class ChannelListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient var publicAndPrivateEventCache: Set<EventIdHint>? = null
fun publicAndPrivateChannels(
signer: NostrSigner,
onReady: (Set<EventIdHint>) -> Unit,
) {
publicAndPrivateEventCache?.let { eventList ->
onReady(eventList)
return
}
mergeTagList(signer) {
val set = it.mapNotNull(ETag::parseAsHint).toSet()
publicAndPrivateEventCache = set
onReady(set)
}
}
companion object {
const val KIND = 10005
const val ALT = "Public Chat List"
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
private fun createChannelBase(
tags: Array<Array<String>>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
PrivateTagArrayBuilder.create(
tags,
isPrivate,
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
fun createChannel(
channel: EventHintBundle<ChannelCreateEvent>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = createChannelBase(
tags = arrayOf(ETag.assemble(channel.event.id, channel.relay, channel.event.pubKey)),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
fun createChannel(
channel: EventIdHint,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = createChannelBase(
tags = arrayOf(ETag.assemble(channel.eventId, channel.relay, null)),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
fun createChannels(
channels: List<EventIdHint>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = createChannelBase(
tags = channels.map { ETag.assemble(it.eventId, it.relay, null) }.toTypedArray(),
isPrivate = isPrivate,
signer = signer,
createdAt = createdAt,
onReady = onReady,
)
fun removeChannel(
earlierVersion: ChannelListEvent,
channel: HexKey,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
PrivateTagArrayBuilder.removeAll(
earlierVersion,
ETag.assemble(channel, null, null),
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
private fun addChannelBase(
earlierVersion: ChannelListEvent,
newTags: Array<Array<String>>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
PrivateTagArrayBuilder.addAll(
earlierVersion,
newTags,
isPrivate,
signer,
) { encryptedContent, newTags ->
create(encryptedContent, newTags, signer, createdAt, onReady)
}
}
fun addChannel(
earlierVersion: ChannelListEvent,
channel: EventHintBundle<ChannelCreateEvent>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = addChannelBase(
earlierVersion,
arrayOf(ETag.assemble(channel.event.id, channel.relay, channel.event.pubKey)),
isPrivate,
signer,
createdAt,
onReady,
)
fun addChannel(
earlierVersion: ChannelListEvent,
channel: EventIdHint,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = addChannelBase(
earlierVersion,
arrayOf(ETag.assemble(channel.eventId, channel.relay, null)),
isPrivate,
signer,
createdAt,
onReady,
)
fun addChannels(
earlierVersion: ChannelListEvent,
channels: List<EventIdHint>,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) = addChannelBase(
earlierVersion,
channels.map { ETag.assemble(it.eventId, it.relay, null) }.toTypedArray(),
isPrivate,
signer,
createdAt,
onReady,
)
private fun create(
content: String,
tags: Array<Array<String>>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (ChannelListEvent) -> Unit,
) {
val newTags =
if (tags.any { it.size > 1 && it[0] == "alt" }) {
tags
} else {
tags + AltTag.Companion.assemble(ALT)
}
signer.sign(createdAt, KIND, newTags, content, onReady)
}
fun create(
list: List<EventIdHint>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): ChannelListEvent? {
val tags = list.map { ETag.assemble(it.eventId, it.relay, null) }.toTypedArray()
return signer.sign(createdAt, KIND, tags, "")
}
}
}
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip28PublicChat.list
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
fun TagArrayBuilder<ChannelListEvent>.followChat(
eventId: HexKey,
relayUrl: String,
) = addUnique(ETag.assemble(eventId, relayUrl, null))
@@ -0,0 +1,214 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
class PrivateTagArrayBuilder {
companion object {
fun create(
tags: Array<Array<String>>,
toPrivate: Boolean,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (toPrivate) {
PrivateTagsInContent.encryptNip04(
privateTags = tags,
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, arrayOf())
}
} else {
onReady("", tags)
}
}
fun add(
current: PrivateTagArrayEvent,
newTag: Array<String>,
toPrivate: Boolean,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (toPrivate) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.plus(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
} else {
onReady(current.content, current.tags.plus(newTag))
}
}
fun addAll(
current: PrivateTagArrayEvent,
newTag: Array<Array<String>>,
toPrivate: Boolean,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (toPrivate) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.plus(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
} else {
onReady(current.content, current.tags.plus(newTag))
}
}
fun replaceAllToPrivateNewTag(
dTag: String,
current: PrivateTagArrayEvent?,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (current == null) {
createPrivate(dTag, newTag, signer, onReady)
} else {
replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer, onReady)
}
}
fun replaceAllToPublicNewTag(
dTag: String,
current: PrivateTagArrayEvent?,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (current == null) {
createPublic(dTag, newTag, signer, onReady)
} else {
replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer, onReady)
}
}
fun replaceAllToPrivateNewTag(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.replaceAll(oldTagStartsWith, newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith))
}
}
}
fun replaceAllToPublicNewTag(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith).plus(newTag))
}
}
}
fun removeAllFromPrivate(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
}
fun removeAllFromPublic(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) = onReady(current.content, current.tags.remove(oldTagStartsWith))
fun removeAll(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith))
}
}
}
fun createPrivate(
dTag: String,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
PrivateTagsInContent.encryptNip04(
privateTags = arrayOf(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, arrayOf(arrayOf("d", dTag)))
}
}
fun createPublic(
dTag: String,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
onReady("", arrayOf(arrayOf("d", dTag), newTag))
}
}
}
@@ -69,192 +69,27 @@ abstract class PrivateTagArrayEvent(
privateTagsCache?.let { onReady(it) }
}
} catch (e: Throwable) {
onReady(emptyArray())
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
}
}
fun decryptChangeEncrypt(
fun mergeTagList(
signer: NostrSigner,
change: (Array<Array<String>>) -> Array<Array<String>>,
onReady: (content: String) -> Unit,
onReady: (Array<Array<String>>) -> Unit,
) {
privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = change(privateTags),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags)
}
privateTags(signer) {
onReady(tags + it)
}
}
companion object {
fun add(
current: PrivateTagArrayEvent,
newTag: Array<String>,
toPrivate: Boolean,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (toPrivate) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.plus(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
} else {
onReady(current.content, current.tags.plus(newTag))
}
}
fun <T> mapAllTags(
privateTags: Array<Array<String>>,
mapper: (Array<String>) -> T,
): Set<T> {
val privateRooms = privateTags.mapNotNull(mapper)
val publicRooms = tags.mapNotNull(mapper)
fun addAll(
current: PrivateTagArrayEvent,
newTag: Array<Array<String>>,
toPrivate: Boolean,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (toPrivate) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.plus(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
} else {
onReady(current.content, current.tags.plus(newTag))
}
}
fun replaceAllToPrivateNewTag(
dTag: String,
current: PrivateTagArrayEvent?,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (current == null) {
createPrivate(dTag, newTag, signer, onReady)
} else {
replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer, onReady)
}
}
fun replaceAllToPublicNewTag(
dTag: String,
current: PrivateTagArrayEvent?,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
if (current == null) {
createPublic(dTag, newTag, signer, onReady)
} else {
replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer, onReady)
}
}
fun replaceAllToPrivateNewTag(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.replaceAll(oldTagStartsWith, newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith))
}
}
}
fun replaceAllToPublicNewTag(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith).plus(newTag))
}
}
}
fun removeAllFromPrivate(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags)
}
}
}
fun removeAllFromPublic(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) = onReady(current.content, current.tags.remove(oldTagStartsWith))
fun removeAll(
current: PrivateTagArrayEvent,
oldTagStartsWith: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
current.privateTags(signer) { privateTags ->
PrivateTagsInContent.encryptNip04(
privateTags = privateTags.remove(oldTagStartsWith),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, current.tags.remove(oldTagStartsWith))
}
}
}
fun createPrivate(
dTag: String,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
PrivateTagsInContent.encryptNip04(
privateTags = arrayOf(newTag),
signer = signer,
) { encryptedTags ->
onReady(encryptedTags, arrayOf(arrayOf("d", dTag)))
}
}
fun createPublic(
dTag: String,
newTag: Array<String>,
signer: NostrSigner,
onReady: (content: String, tags: Array<Array<String>>) -> Unit,
) {
onReady("", arrayOf(arrayOf("d", dTag), newTag))
}
return (privateRooms + publicRooms).toSet()
}
}
@@ -18,7 +18,7 @@
* 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.commons.data
package com.vitorpamplona.quartz.utils
import java.util.concurrent.ConcurrentSkipListMap
import java.util.function.BiConsumer