mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
- 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:
@@ -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
|
||||
|
||||
+128
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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)
|
||||
|
||||
|
||||
+22
-1
@@ -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),
|
||||
|
||||
+37
-3
@@ -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()
|
||||
}
|
||||
|
||||
+40
-13
@@ -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>
|
||||
}
|
||||
+179
@@ -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,
|
||||
|
||||
+1
-1
@@ -381,7 +381,7 @@ private fun EmojiSelector(
|
||||
onClick: ((EmojiUrlTag) -> Unit)? = null,
|
||||
) {
|
||||
LoadAddressableNote(
|
||||
accountViewModel.account.getEmojiPackSelectionAddress(),
|
||||
accountViewModel.account.emoji.getEmojiPackSelectionAddress(),
|
||||
accountViewModel,
|
||||
) { emptyNote ->
|
||||
emptyNote?.let { usersEmojiList ->
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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 ->
|
||||
|
||||
+2
-1
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
|
||||
+17
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+2
-16
@@ -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) }
|
||||
|
||||
|
||||
+5
-4
@@ -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) {
|
||||
|
||||
+11
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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,
|
||||
|
||||
+33
-8
@@ -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
|
||||
}
|
||||
|
||||
+60
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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,
|
||||
)
|
||||
}
|
||||
+163
@@ -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)
|
||||
}
|
||||
}
|
||||
+47
@@ -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))
|
||||
}
|
||||
}
|
||||
+47
@@ -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))
|
||||
}
|
||||
}
|
||||
+59
@@ -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()
|
||||
}
|
||||
}
|
||||
+222
@@ -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),
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
+20
-4
@@ -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) {
|
||||
|
||||
+60
-4
@@ -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) {
|
||||
|
||||
+63
-12
@@ -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,
|
||||
|
||||
+36
-3
@@ -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 =
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+5
-4
@@ -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) {
|
||||
|
||||
+111
-1
@@ -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,
|
||||
)
|
||||
|
||||
+168
@@ -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
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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,
|
||||
|
||||
+69
@@ -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)
|
||||
}
|
||||
+2
-6
@@ -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,
|
||||
|
||||
+2
-5
@@ -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,
|
||||
|
||||
+3
-7
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+67
@@ -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)
|
||||
}
|
||||
+29
@@ -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))
|
||||
+41
@@ -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)
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
}
|
||||
+58
@@ -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,
|
||||
)
|
||||
+45
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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))
|
||||
+49
@@ -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,
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+239
@@ -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, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
Reference in New Issue
Block a user