From 63a009f36d3cfd95f2775cdb337a1eb72ddde577 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 2 May 2025 21:18:10 -0400 Subject: [PATCH] - 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 --- .../amethyst/LocalPreferences.kt | 121 ++------ .../vitorpamplona/amethyst/model/Account.kt | 252 +++++++--------- .../amethyst/model/AccountSettings.kt | 40 ++- .../amethyst/model/AntiSpamFilter.kt | 4 +- .../vitorpamplona/amethyst/model/Channel.kt | 99 +++++- .../amethyst/model/LocalCache.kt | 283 ++++++++++++------ .../com/vitorpamplona/amethyst/model/Note.kt | 14 +- .../com/vitorpamplona/amethyst/model/User.kt | 8 +- .../model/emphChat/EphemeralChatListState.kt | 128 ++++++++ .../nip28PublicChats/PublicChatListState.kt | 150 ++++++++++ .../model/nip30CustomEmojis/EmojiPackState.kt | 119 ++++++++ .../relayClient/CacheClientConnector.kt | 8 +- .../account/AccountFilterAssembler.kt | 23 +- .../reqCommand/channel/ChannelObservers.kt | 40 ++- .../reqCommand/user/UserObservers.kt | 53 +++- .../amethyst/ui/actions/NewPostViewModel.kt | 10 +- .../ui/dal/AdditiveComplexFeedFilter.kt | 28 ++ .../ui/feeds/ChannelFeedContentState.kt | 179 +++++++++++ .../amethyst/ui/feeds/ChannelFeedState.kt | 40 +++ .../amethyst/ui/navigation/AppNavigation.kt | 5 + .../amethyst/ui/navigation/RouteMaker.kt | 11 +- .../amethyst/ui/navigation/Routes.kt | 9 + .../amethyst/ui/note/ChannelCardCompose.kt | 22 +- .../vitorpamplona/amethyst/ui/note/Loaders.kt | 48 +++ .../amethyst/ui/note/RelayListRow.kt | 22 +- .../ui/note/UpdateReactionTypeDialog.kt | 2 +- .../emojiSuggestions/EmojiSuggestionState.kt | 6 +- .../ShowEmojiSuggestionList.kt | 6 +- .../amethyst/ui/note/types/Emoji.kt | 2 +- .../ui/screen/AccountStateViewModel.kt | 3 +- .../loggedIn/AccountFeedContentStates.kt | 4 + .../ui/screen/loggedIn/AccountViewModel.kt | 19 +- .../ui/screen/loggedIn/NewPostScreen.kt | 15 + .../feed/types/RenderCreateChannelNote.kt | 18 +- .../privateDM/send/ChatNewMessageViewModel.kt | 9 +- .../chats/publicChannels/ChannelHeader.kt | 11 + .../chats/publicChannels/ChannelScreen.kt | 25 ++ .../chats/publicChannels/ChannelView.kt | 18 ++ .../datasource/ChannelFilterAssembler.kt | 41 ++- .../header/EphemeralChatChannelHeader.kt | 60 ++++ .../ephemChat/header/EphemeralChatTopBar.kt | 45 +++ .../header/ShortEphemeralChatChannelHeader.kt | 163 ++++++++++ .../header/actions/JoinChatButton.kt | 47 +++ .../header/actions/LeaveChatButton.kt | 47 +++ .../metadata/NewEphemeralChatMetaViewModel.kt | 59 ++++ .../metadata/NewEphemeralChatScreen.kt | 222 ++++++++++++++ .../header/LongPublicChatChannelHeader.kt | 2 +- .../header/ShortPublicChatChannelHeader.kt | 2 +- .../metadata/ChannelMetadataViewModel.kt | 4 +- .../send/ChannelNewMessageViewModel.kt | 24 +- .../chats/rooms/ChatroomHeaderCompose.kt | 64 +++- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 75 ++++- .../datasource/ChatroomListFilterAssembler.kt | 39 ++- .../datasource/DiscoveryFilterAssembler.kt | 5 +- .../marketplace/NewProductViewModel.kt | 9 +- .../ui/screen/loggedIn/home/HomeScreen.kt | 112 ++++++- .../loggedIn/home/dal/HomeLiveFilter.kt | 168 +++++++++++ .../home/datasource/HomeFilterAssembler.kt | 10 +- .../home/live/RenderEphemeralBubble.kt | 69 +++++ .../common/BasicRelaySetupInfoClickableRow.kt | 8 +- .../relays/kind3/Kind3RelayListView.kt | 7 +- .../Kind3RelaySetupInfoProposalRow.kt | 10 +- amethyst/src/main/res/values/strings.xml | 13 + .../vitorpamplona/ammolite/relays/Relay.kt | 2 +- .../ammolite/relays/TypedFilter.kt | 5 +- .../quartz/benchmark/CacheBenchmark.kt | 2 +- .../quartz/benchmark/LargeCacheBenchmark.kt | 2 +- .../com/vitorpamplona/quartz/EventFactory.kt | 6 +- .../ephemChat/chat/EphemeralChatEvent.kt | 67 +++++ .../experimental/ephemChat/chat/RoomId.kt | 32 ++ .../ephemChat/chat/TagArrayBuilderExt.kt | 29 ++ .../ephemChat/chat/tags/RelayTag.kt | 41 +++ .../ephemChat/chat/tags/RoomTag.kt | 41 +++ .../ephemChat/db/EphemeralRoom.kt | 58 ++++ .../ephemChat/db/EphemeralRoomCache.kt | 45 +++ .../ephemChat/list/EphemeralChatListEvent.kt | 149 +++++++++ .../ephemChat/list/TagArrayBuilderExt.kt | 29 ++ .../ephemChat/list/tags/RoomIdTag.kt | 49 +++ .../nip02FollowList/ContactListEvent.kt | 41 --- .../quartz/nip09Deletions}/DeletionIndex.kt | 4 +- .../nip28PublicChat/ChannelListEvent.kt | 271 ----------------- .../nip28PublicChat/list/ChannelListEvent.kt | 239 +++++++++++++++ .../list/TagArrayBuilderExt.kt | 30 ++ .../nip51Lists/PrivateTagArrayBuilder.kt | 214 +++++++++++++ .../quartz/nip51Lists/PrivateTagArrayEvent.kt | 189 +----------- .../vitorpamplona/quartz/utils}/LargeCache.kt | 2 +- 86 files changed, 3694 insertions(+), 1012 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveComplexFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt rename {commons/src/main/java/com/vitorpamplona/amethyst/commons/data => quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions}/DeletionIndex.kt (97%) delete mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt rename {commons/src/main/java/com/vitorpamplona/amethyst/commons/data => quartz/src/main/java/com/vitorpamplona/quartz/utils}/LargeCache.kt (99%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 6ef2fe673..5fc5ad8b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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>(PrefKeys.RELAYS) ?: emptySet() val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) @@ -540,80 +538,14 @@ object LocalPreferences { val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) val latestPrivateHomeRelayList = parseEventOrNull(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) val latestAppSpecificData = parseEventOrNull(PrefKeys.LATEST_APP_SPECIFIC_DATA) - - val syncedSettings = - if (latestAppSpecificData != null) { - null - } else { - // previous version. Delete this when ready. - val reactionChoices = parseOrNull>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions - val zapAmountChoices = parseOrNull>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts - - val languagePreferences = parseOrNull>(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(PrefKeys.LATEST_EPHEMERAL_LIST) + val latestChannelList = parseEventOrNull(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(PrefKeys.TOR_SETTINGS) ?: TorSettings() - } + val torSettings = parseOrNull(PrefKeys.TOR_SETTINGS) ?: TorSettings() val lastReadPerRoute = parseOrNull>(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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 848ef6c6c..3461c17d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = MutableStateFlow(NoteState(Note(" "))), - val kind3: StateFlow = MutableStateFlow(null), + val kind3: StateFlow = MutableStateFlow(null), val location: StateFlow = 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 = getEmojiPackSelectionNote().flow().metadata.stateFlow - - fun getEmojiPackSelectionAddress() = EmojiPackSelectionEvent.createAddress(userProfile().pubkeyHex) - - fun getEmojiPackSelectionNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getEmojiPackSelectionAddress()) - - fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List>? = - selection?.taggedAddresses()?.map { - LocalCache - .getOrCreateAddressableNote(it) - .flow() - .metadata.stateFlow - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveEmojiSelectionPack: StateFlow>?> 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 = - pack.taggedEmojis().map { - EmojiMedia(it.code, MediaUrlImage(it.url)) - } - - fun mergePack(list: Array): List = - 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): List = @@ -3184,11 +3095,6 @@ class Account( } } - fun selectedChatsFollowList(): Set { - 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) + } + } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 4bd7f8bf0..a901ab19d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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>> = MutableStateFlow(mapOf()), var hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow>(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 7305d4030..903a60153 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 7d281052e..831e833d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -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(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() - 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 { 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f530d9756..87b740c4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index a79d677f2..710b8b54c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 906735b8b..cd0183063 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt new file mode 100644 index 000000000..85d839d8f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt @@ -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 = getEphemeralChatListNote().flow().metadata.stateFlow + + fun getEphemeralChatList(): EphemeralChatListEvent? = getEphemeralChatListNote().event as? EphemeralChatListEvent + + @OptIn(ExperimentalCoroutinesApi::class) + val liveEphemeralChatList: StateFlow> 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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt new file mode 100644 index 000000000..61553369f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt @@ -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 = getChannelListNote().flow().metadata.stateFlow + + fun getChannelList(): ChannelListEvent? = getChannelListNote().event as? ChannelListEvent + + @OptIn(ExperimentalCoroutinesApi::class) + val livePublicChatList: StateFlow> 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> 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, + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt new file mode 100644 index 000000000..a63f17db6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt @@ -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 = getEmojiPackSelectionNote().flow().metadata.stateFlow + + fun getEmojiPackSelectionNote(): AddressableNote = cache.getOrCreateAddressableNote(getEmojiPackSelectionAddress()) + + fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List>? = + selection?.taggedAddresses()?.map { + cache + .getOrCreateAddressableNote(it) + .flow() + .metadata.stateFlow + } + + @OptIn(ExperimentalCoroutinesApi::class) + val liveEmojiSelectionPack: StateFlow>?> 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 = + pack.taggedEmojis().map { + EmojiMedia(it.code, MediaUrlImage(it.url)) + } + + fun mergePack(list: Array): List = + 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()), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt index 6e8eb394f..8835d1adb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 7680f70ac..9361cd434 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -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) = + 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, otherAccounts: List, @@ -260,6 +280,7 @@ class AccountFilterAssembler( createAccountSettings2Filter(mainAccounts), createNotificationFilter(mainAccounts), createNotificationFilter2(mainAccounts), + createNotificationFilter3(mainAccounts), createGiftWrapsToMeFilter(mainAccounts), createAccountReportsAndDraftsFilter(mainAccounts), createAccountSettingsFilter(mainAccounts), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt index bef5df1a5..565291d61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelObservers.kt @@ -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 { ChannelFinderFilterAssemblerSubscription(baseChannel) - return baseChannel.flow.stateFlow.collectAsStateWithLifecycle() + return baseChannel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() +} + +@Composable +fun observeChannelNoteAuthors(baseChannel: Channel): State> { + 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 { val flow = remember(baseChannel) { baseChannel - .flow.stateFlow + .flow() + .metadata.stateFlow .mapLatest { it.channel.profilePicture() } .distinctUntilChanged() } @@ -67,7 +100,8 @@ fun observeChannelInfo(baseChannel: LiveActivitiesChannel): State { // 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 { + // 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 54622d53d..4ca6ecec5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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?, + myEmojiSet: List?, ): List { 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveComplexFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveComplexFeedFilter.kt new file mode 100644 index 000000000..8852ab49a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/AdditiveComplexFeedFilter.kt @@ -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 : FeedFilter() { + abstract fun updateListWith( + oldList: List, + newItems: Set, + ): List +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt new file mode 100644 index 000000000..6e09b0c24 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt @@ -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, + val viewModelScope: CoroutineScope, +) : InvalidatableContent { + private val _feedContent = MutableStateFlow(ChannelFeedState.Loading) + val feedContent = _feedContent.asStateFlow() + + // Simple counter that changes when it needs to invalidate everything + private val _scrollToTop = MutableStateFlow(0) + val scrollToTop = _scrollToTop.asStateFlow() + var scrolltoTopPending = false + + private var lastFeedKey: String? = null + + override val isRefreshing: MutableState = 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) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.tryEmit(ChannelFeedState.Empty) + } else if (currentState is ChannelFeedState.Loaded) { + currentState.feed.tryEmit(LoadedFeedState(notes, localFilter.showHiddenKey())) + } else { + _feedContent.tryEmit( + ChannelFeedState.Loaded(MutableStateFlow(LoadedFeedState(notes, localFilter.showHiddenKey()))), + ) + } + } + + fun refreshFromOldState(newItems: Set) { + 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>(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) { + bundlerInsert.invalidateList(newItems) { refreshFromOldState(it.flatten().toSet()) } + } + + fun updateFeedWith(newNotes: Set) { + 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt new file mode 100644 index 000000000..31c966312 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt @@ -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>, + ) : ChannelFeedState() + + object Empty : ChannelFeedState() + + class FeedError( + val errorMessage: String, + ) : ChannelFeedState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 3744b0af2..49bc2776e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } composableFromEndArgs { ChannelScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { EphemeralChatScreen(RoomId(it.id, it.relayUrl), accountViewModel, nav) } composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } + composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } composableArgs { LoadRedirectScreen(it.id, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt index ff36927b5..e8bdf369c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index b3869c95d..ff171b579 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -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() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index bf0319c15..166c239ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -1024,16 +1024,28 @@ fun Gallery( users: ImmutableList, 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), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 1a767fa53..831498a8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -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 produceStateIfNotNull( + initialValue: T, + key1: Any?, + producer: suspend ProduceStateScope.() -> Unit, +): State { + val result = remember(key1) { mutableStateOf(initialValue) } + if (result.value == null) { + LaunchedEffect(key1) { ProduceStateScopeImpl(result, coroutineContext).producer() } + } + return result +} + +class ProduceStateScopeImpl( + state: MutableState, + override val coroutineContext: CoroutineContext, +) : ProduceStateScope, + MutableState by state { + override suspend fun awaitDispose(onDispose: () -> Unit): Nothing { + try { + suspendCancellableCoroutine {} + } finally { + onDispose() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 9b2e44df5..fe0e99ee9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index c56249ce9..a9f1b4482 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -381,7 +381,7 @@ private fun EmojiSelector( onClick: ((EmojiUrlTag) -> Unit)? = null, ) { LoadAddressableNote( - accountViewModel.account.getEmojiPackSelectionAddress(), + accountViewModel.account.emoji.getEmojiPackSelectionAddress(), accountViewModel, ) { emptyNote -> emptyNote?.let { usersEmojiList -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt index 5af179240..7d0fea38b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt @@ -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 = MutableStateFlow("") - val results: Flow> = + val results: Flow> = accountViewModel.account - .myEmojis + .emoji.myEmojis .combine(search) { list, search -> if (search.length == 1) { list diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt index c08fcf39d..8762d32fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt @@ -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), ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt index 98fef26ab..5912e66f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -175,7 +175,7 @@ private fun EmojiListOptions( emojiPackNote: Note, ) { LoadAddressableNote( - accountViewModel.account.getEmojiPackSelectionAddress(), + accountViewModel.account.emoji.getEmojiPackSelectionAddress(), accountViewModel, ) { it?.let { usersEmojiList -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 569ac9a10..323d1fd24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -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), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 3859b722f..bf38ef5e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) { checkNotInMainThread() + homeLive.updateFeedWith(newNotes) homeNewThreads.updateFeedWith(newNotes) homeReplies.updateFeedWith(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index d5e251a02..f69732154 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 loadParticipants( participants: List, onReady: (ImmutableList>) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index 59b4c4733..cd9241084 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -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)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index 4cd262587..b45b22c6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 8054a59e9..e8f5a3b47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -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?, + myEmojiSet: List?, ): List { 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt index 8143f1b4a..258d5cabc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt @@ -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, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt index 85d6f8bc0..77b32744b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt @@ -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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt index 2a14d4007..6a50dcadd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt index d37816325..85a9b8cd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssembler.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt new file mode 100644 index 000000000..93cfeade5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatChannelHeader.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt new file mode 100644 index 000000000..4b599fbad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/EphemeralChatTopBar.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt new file mode 100644 index 000000000..fa9e0c957 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt new file mode 100644 index 000000000..9796925f8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt @@ -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)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt new file mode 100644 index 000000000..2dd7927ff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt @@ -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)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt new file mode 100644 index 000000000..f4b81a501 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt @@ -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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt new file mode 100644 index 000000000..abaeb374e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt @@ -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), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index 81d56d8e4..0d1f93517 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index f639832b1..458238609 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index 3bf719d6d..5bd06c876 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -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) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 57f0c62b2..d93b0fa31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -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?, + myEmojiSet: List?, ): List { 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 444dca63e..27ca003d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index bf8d29ba3..aafd42af4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -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): Set { // 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, account: Account, ): MutableMap { - val followingChannels = - account - .userProfile() - .latestContactList - ?.taggedEventIds() - ?.toSet() ?: emptySet() + val followingChannels = account.publicChatList.livePublicChatEventIdSet.value val newRelevantPublicMessages = mutableMapOf() newItems .filter { it.event is ChannelMessageEvent } @@ -165,6 +189,33 @@ class ChatroomListKnownFeedFilter( return newRelevantPublicMessages } + private fun filterRelevantEphemeralChats( + newItems: Set, + account: Account, + ): MutableMap { + val followingEphemeralChats = account.ephemeralChatList.liveEphemeralChatList.value + val newRelevantEphemeralChats = mutableMapOf() + 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, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 153d1b19d..32fc70d29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -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? { - 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? { - 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? { + 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? = listOfNotNull( listOfNotNull( @@ -168,6 +200,7 @@ class ChatroomListFilterAssembler( ), createLastChannelInfoFilter(account), createLastMessageOfEachChannelFilter(account), + createLastMessageOfEachEphemeralChatFilter(account), ).flatten().ifEmpty { null } fun newSub(key: ChatroomListState): Subscription = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt index 116bffd3d..6d9313ac0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/NewProductViewModel.kt index 5db97583f..1e3951d2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/marketplace/NewProductViewModel.kt @@ -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?, + myEmojiSet: List?, ): List { 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 5faa61745..d6f5a7ee3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -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) -> 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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt new file mode 100644 index 000000000..638bcdeef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -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() { + 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 { + 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, + 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, + newItems: Set, + ): List { + 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): Set { + 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, + 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): List { + val followingKeySet = + account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors + + val followCounts = + collection.associate { it to followsThatParticipateOn(it, followingKeySet) } + + return collection.sortedWith( + compareByDescending { followCounts[it] } + .thenByDescending { it.lastNoteCreatedAt } + .thenBy { it.idHex }, + ) + } + + fun followsThatParticipateOn( + channel: Channel, + followingSet: Set?, + ): 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt index 54780b1d2..fbc70815c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt new file mode 100644 index 000000000..b4e3de3ac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderEphemeralBubble.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index d0fee4c97..ad65d5264 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt index 6e863b1a4..1601974be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt index 3ded4f702..e12bf94e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt @@ -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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index de69af3b0..905f66e90 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -225,6 +225,15 @@ Unfollow Channel created "Channel Information changed to" + + Disappearing Chat + Relay Chat + Relay Chats + 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 + Public Chat Public Chat Metadata Public chats are visible to everyone on Nostr and anyone @@ -722,6 +731,7 @@ Public New Public or Private Group + Relay Private To Subject @@ -1144,4 +1154,7 @@ Log off on device lock Private Message + + Chat Relay + The relay that all users of this chat connect to diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt index 2dcd65c40..a40758544 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt @@ -51,7 +51,7 @@ class RelaySubFilter( val activeTypes: Set, 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): Boolean = filters.any { filter -> diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt index a07e95feb..57a8f09e2 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt @@ -25,4 +25,7 @@ import com.vitorpamplona.ammolite.relays.filters.IPerRelayFilter class TypedFilter( val types: Set, val filter: IPerRelayFilter, -) +) { + // This only exists because some relays confuse empty lists with null lists + fun isValidFor(url: String) = filter.isValidFor(url) +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt index a20ce449e..f09b62c6e 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt @@ -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 diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt index d26cf3613..35ab407b4 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeCacheBenchmark.kt @@ -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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt index 8e2e2445d..e9eafafa9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt @@ -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) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt new file mode 100644 index 000000000..f09cdccd1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt @@ -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>, + 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.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + room(room) + relay(relay) + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt new file mode 100644 index 000000000..26fbac22c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt @@ -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) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt new file mode 100644 index 000000000..62eeeafdc --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt @@ -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.room(room: String) = addUnique(RoomTag.assemble(room)) + +fun TagArrayBuilder.relay(room: String) = addUnique(RelayTag.assemble(room)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt new file mode 100644 index 000000000..d27790cb4 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt new file mode 100644 index 000000000..5e4bdca98 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt new file mode 100644 index 000000000..02c8d2e60 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt @@ -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() + + 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, +) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt new file mode 100644 index 000000000..07d862b39 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt @@ -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() + + 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 { + if (text.isBlank()) return emptyList() + return rooms.filter { _, room -> + room.roomId.id.startsWith(text) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt new file mode 100644 index 000000000..7164f4061 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateEventCache: Set? = null + + fun publicAndPrivateRoomIds( + signer: NostrSigner, + onReady: (Set) -> Unit, + ) { + publicAndPrivateEventCache?.let { eventList -> + onReady(eventList) + return + } + + privateTags(signer) { + val set = filterRooms(it) + publicAndPrivateEventCache = set + onReady(set) + } + } + + fun filterRooms(privateTags: Array>): Set { + 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>, + 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) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt new file mode 100644 index 000000000..745cd1dd6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/TagArrayBuilderExt.kt @@ -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.roomId( + id: String, + relayUrl: String, +) = addUnique(RoomIdTag.assemble(id, relayUrl)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt new file mode 100644 index 000000000..915662f3d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt @@ -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): 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) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index a5d995afb..97b8d6317 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -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 = emptyList(), followGeohashes: List = emptyList(), followCommunities: List = emptyList(), - followEvents: List = emptyList(), relayUse: Map? = 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, followGeohashes: List, followCommunities: List, - followEvents: List, relayUse: Map?, 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, diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/DeletionIndex.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt similarity index 97% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/data/DeletionIndex.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt index 2fa763348..eb0b4ff73 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/DeletionIndex.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt @@ -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( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt deleted file mode 100644 index e892b6f8d..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/ChannelListEvent.kt +++ /dev/null @@ -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>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateEventCache: ImmutableSet? = null - - override fun countMemory(): Long = - super.countMemory() + - 32 + (publicAndPrivateEventCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0L) // rough calculation - - fun publicAndPrivateEvents( - signer: NostrSigner, - onReady: (ImmutableSet) -> 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, - 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>, - 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) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt new file mode 100644 index 000000000..202fddd0c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt @@ -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>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateEventCache: Set? = null + + fun publicAndPrivateChannels( + signer: NostrSigner, + onReady: (Set) -> 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>, + 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, + 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, + 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>, + 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, + 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, + 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>, + 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, + 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, "") + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt new file mode 100644 index 000000000..1e70bde25 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt @@ -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.followChat( + eventId: HexKey, + relayUrl: String, +) = addUnique(ETag.assemble(eventId, relayUrl, null)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt new file mode 100644 index 000000000..2b15aed70 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt @@ -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>, + toPrivate: Boolean, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + if (toPrivate) { + PrivateTagsInContent.encryptNip04( + privateTags = tags, + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags, arrayOf()) + } + } else { + onReady("", tags) + } + } + + fun add( + current: PrivateTagArrayEvent, + newTag: Array, + toPrivate: Boolean, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> 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>, + toPrivate: Boolean, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> 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, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + if (current == null) { + createPrivate(dTag, newTag, signer, onReady) + } else { + replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer, onReady) + } + } + + fun replaceAllToPublicNewTag( + dTag: String, + current: PrivateTagArrayEvent?, + oldTagStartsWith: Array, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + if (current == null) { + createPublic(dTag, newTag, signer, onReady) + } else { + replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer, onReady) + } + } + + fun replaceAllToPrivateNewTag( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> 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, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> 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, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + current.privateTags(signer) { privateTags -> + PrivateTagsInContent.encryptNip04( + privateTags = privateTags.remove(oldTagStartsWith), + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags, current.tags) + } + } + } + + fun removeAllFromPublic( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) = onReady(current.content, current.tags.remove(oldTagStartsWith)) + + fun removeAll( + current: PrivateTagArrayEvent, + oldTagStartsWith: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> 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, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + PrivateTagsInContent.encryptNip04( + privateTags = arrayOf(newTag), + signer = signer, + ) { encryptedTags -> + onReady(encryptedTags, arrayOf(arrayOf("d", dTag))) + } + } + + fun createPublic( + dTag: String, + newTag: Array, + signer: NostrSigner, + onReady: (content: String, tags: Array>) -> Unit, + ) { + onReady("", arrayOf(arrayOf("d", dTag), newTag)) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt index 47ce039fa..fc090a12b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt @@ -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>, - onReady: (content: String) -> Unit, + onReady: (Array>) -> 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, - toPrivate: Boolean, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> 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 mapAllTags( + privateTags: Array>, + mapper: (Array) -> T, + ): Set { + val privateRooms = privateTags.mapNotNull(mapper) + val publicRooms = tags.mapNotNull(mapper) - fun addAll( - current: PrivateTagArrayEvent, - newTag: Array>, - toPrivate: Boolean, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> 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, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - if (current == null) { - createPrivate(dTag, newTag, signer, onReady) - } else { - replaceAllToPrivateNewTag(current, oldTagStartsWith, newTag, signer, onReady) - } - } - - fun replaceAllToPublicNewTag( - dTag: String, - current: PrivateTagArrayEvent?, - oldTagStartsWith: Array, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - if (current == null) { - createPublic(dTag, newTag, signer, onReady) - } else { - replaceAllToPublicNewTag(current, oldTagStartsWith, newTag, signer, onReady) - } - } - - fun replaceAllToPrivateNewTag( - current: PrivateTagArrayEvent, - oldTagStartsWith: Array, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> 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, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> 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, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - current.privateTags(signer) { privateTags -> - PrivateTagsInContent.encryptNip04( - privateTags = privateTags.remove(oldTagStartsWith), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, current.tags) - } - } - } - - fun removeAllFromPublic( - current: PrivateTagArrayEvent, - oldTagStartsWith: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) = onReady(current.content, current.tags.remove(oldTagStartsWith)) - - fun removeAll( - current: PrivateTagArrayEvent, - oldTagStartsWith: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> 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, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - PrivateTagsInContent.encryptNip04( - privateTags = arrayOf(newTag), - signer = signer, - ) { encryptedTags -> - onReady(encryptedTags, arrayOf(arrayOf("d", dTag))) - } - } - - fun createPublic( - dTag: String, - newTag: Array, - signer: NostrSigner, - onReady: (content: String, tags: Array>) -> Unit, - ) { - onReady("", arrayOf(arrayOf("d", dTag), newTag)) - } + return (privateRooms + publicRooms).toSet() } } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt index e1a01af74..edbfbdd96 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt @@ -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