mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
Merge pull request #3790 from vitorpamplona/claude/home-screen-event-toggles-q75xwc
Add per-content-type toggles for Home feed event kinds
This commit is contained in:
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntr
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
@@ -188,6 +189,10 @@ private object PrefKeys {
|
||||
// Stores the DISABLED chat feed types (comma-joined codes) so absence = all-on and any newly
|
||||
// added type defaults enabled for accounts that customized before it existed.
|
||||
const val DISABLED_CHAT_FEEDS = "disabled_chat_feeds"
|
||||
|
||||
// Same convention as DISABLED_CHAT_FEEDS but for the Home feed's event-kind groups: stores the
|
||||
// DISABLED codes so absence = all-on and any newly added group defaults enabled.
|
||||
const val DISABLED_HOME_FEED_TYPES = "disabled_home_feed_types"
|
||||
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
|
||||
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
|
||||
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
|
||||
@@ -604,6 +609,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
|
||||
putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name)
|
||||
putString(PrefKeys.DISABLED_CHAT_FEEDS, ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value))
|
||||
putString(PrefKeys.DISABLED_HOME_FEED_TYPES, HomeFeedType.encode(HomeFeedType.ALL - settings.enabledHomeFeedTypes.value))
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
|
||||
@@ -731,17 +737,10 @@ object LocalPreferences {
|
||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
|
||||
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
|
||||
val defaultRelayAuthPolicy =
|
||||
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
|
||||
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
|
||||
?: RelayAuthPolicy.CUSTOM
|
||||
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
|
||||
val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null))
|
||||
val enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null))
|
||||
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
|
||||
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
|
||||
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
|
||||
val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false)
|
||||
// Read as a group via a helper: this load lambda sits right at the JVM's
|
||||
// per-method bytecode limit (see the note above the awaits below), so keeping
|
||||
// these heavy string/enum decodes out of it preserves headroom.
|
||||
val inboxPrefs = readInboxPrefs()
|
||||
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
|
||||
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
@@ -968,14 +967,15 @@ object LocalPreferences {
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
|
||||
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
|
||||
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
|
||||
concordViewMode = MutableStateFlow(concordViewMode),
|
||||
enabledChatFeeds = MutableStateFlow(enabledChatFeeds),
|
||||
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
|
||||
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
|
||||
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
|
||||
relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers),
|
||||
defaultRelayAuthPolicy = MutableStateFlow(inboxPrefs.defaultRelayAuthPolicy),
|
||||
relayGroupViewMode = MutableStateFlow(inboxPrefs.relayGroupViewMode),
|
||||
concordViewMode = MutableStateFlow(inboxPrefs.concordViewMode),
|
||||
enabledChatFeeds = MutableStateFlow(inboxPrefs.enabledChatFeeds),
|
||||
enabledHomeFeedTypes = MutableStateFlow(inboxPrefs.enabledHomeFeedTypes),
|
||||
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(inboxPrefs.relayAuthTrustMyRelays),
|
||||
relayAuthTrustReadFollows = MutableStateFlow(inboxPrefs.relayAuthTrustReadFollows),
|
||||
relayAuthTrustMessageFollows = MutableStateFlow(inboxPrefs.relayAuthTrustMessageFollows),
|
||||
relayAuthTrustMessageStrangers = MutableStateFlow(inboxPrefs.relayAuthTrustMessageStrangers),
|
||||
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
|
||||
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
|
||||
backupUserMetadata = latestUserMetadataResolved,
|
||||
@@ -1184,3 +1184,36 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The inbox / relay-auth / feed-type preferences, read as one group. Extracted out of
|
||||
* [LocalPreferences]' account-load lambda (which is right at the JVM's per-method bytecode limit)
|
||||
* so these enum/set decodes don't count against that method's budget.
|
||||
*/
|
||||
private class InboxPrefs(
|
||||
val defaultRelayAuthPolicy: RelayAuthPolicy,
|
||||
val relayGroupViewMode: RelayGroupViewMode,
|
||||
val concordViewMode: ConcordViewMode,
|
||||
val enabledChatFeeds: Set<ChatFeedType>,
|
||||
val enabledHomeFeedTypes: Set<HomeFeedType>,
|
||||
val relayAuthTrustMyRelays: Boolean,
|
||||
val relayAuthTrustReadFollows: Boolean,
|
||||
val relayAuthTrustMessageFollows: Boolean,
|
||||
val relayAuthTrustMessageStrangers: Boolean,
|
||||
)
|
||||
|
||||
private fun SharedPreferences.readInboxPrefs() =
|
||||
InboxPrefs(
|
||||
defaultRelayAuthPolicy =
|
||||
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
|
||||
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
|
||||
?: RelayAuthPolicy.CUSTOM,
|
||||
relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)),
|
||||
concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)),
|
||||
enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null)),
|
||||
enabledHomeFeedTypes = HomeFeedType.ALL - HomeFeedType.decode(getString(PrefKeys.DISABLED_HOME_FEED_TYPES, null)),
|
||||
relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true),
|
||||
relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true),
|
||||
relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true),
|
||||
relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false),
|
||||
)
|
||||
|
||||
@@ -341,6 +341,9 @@ class AccountSettings(
|
||||
// Which conversation protocols the Messages inbox loads and shows. A disabled type is both hidden
|
||||
// from the inbox and dropped from the always-on downloading routes. Defaults to everything on.
|
||||
val enabledChatFeeds: MutableStateFlow<Set<ChatFeedType>> = MutableStateFlow(ChatFeedType.ALL),
|
||||
// Which event-kind groups the Home feed downloads (assembler) and renders (DAL). A disabled group
|
||||
// is both dropped from the always-on home relay filters and hidden from the tabs. Everything on by default.
|
||||
val enabledHomeFeedTypes: MutableStateFlow<Set<HomeFeedType>> = MutableStateFlow(HomeFeedType.ALL),
|
||||
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
|
||||
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
@@ -391,6 +394,20 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun isHomeFeedTypeEnabled(type: HomeFeedType): Boolean = type in enabledHomeFeedTypes.value
|
||||
|
||||
fun setHomeFeedTypeEnabled(
|
||||
type: HomeFeedType,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
val current = enabledHomeFeedTypes.value
|
||||
val next = if (enabled) current + type else current - type
|
||||
if (next != current) {
|
||||
enabledHomeFeedTypes.tryEmit(next)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// Always-on Notification Service
|
||||
// ---
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
|
||||
/**
|
||||
* The distinct event-kind groups the Home feed downloads (in the relay assembler) and renders (in
|
||||
* the DAL). Each is independently toggleable in Settings › Home: turning one off both drops its
|
||||
* kinds from the always-on home relay filters AND hides them from the New Threads / Conversations /
|
||||
* Everything tabs.
|
||||
*
|
||||
* [code] is the stable on-disk identifier (do NOT rename — it is what [encode]/[decode] persist);
|
||||
* the enum ordinal is never stored, so entries may be reordered freely. [kinds] are the Nostr event
|
||||
* kinds this group governs; they must stay disjoint across entries so a single toggle owns each kind.
|
||||
*/
|
||||
enum class HomeFeedType(
|
||||
val code: String,
|
||||
val kinds: List<Int>,
|
||||
) {
|
||||
TEXT_NOTES("text_notes", listOf(TextNoteEvent.KIND)),
|
||||
REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)),
|
||||
COMMENTS("comments", listOf(CommentEvent.KIND)),
|
||||
ARTICLES("articles", listOf(LongTextNoteEvent.KIND)),
|
||||
WIKI("wiki", listOf(WikiNoteEvent.KIND)),
|
||||
HIGHLIGHTS("highlights", listOf(HighlightEvent.KIND)),
|
||||
POLLS("polls", listOf(PollEvent.KIND, ZapPollEvent.KIND, PollResponseEvent.KIND)),
|
||||
CLASSIFIEDS("classifieds", listOf(ClassifiedsEvent.KIND)),
|
||||
VOICE("voice", listOf(VoiceEvent.KIND, VoiceReplyEvent.KIND)),
|
||||
LIVE_ACTIVITIES("live_activities", listOf(LiveActivitiesEvent.KIND, LiveActivitiesChatMessageEvent.KIND)),
|
||||
EPHEMERAL_CHAT("ephemeral_chat", listOf(EphemeralChatEvent.KIND)),
|
||||
INTERACTIVE_STORIES("interactive_stories", listOf(InteractiveStoryPrologueEvent.KIND)),
|
||||
CHESS("chess", listOf(ChessGameEvent.KIND, LiveChessGameChallengeEvent.KIND, LiveChessGameEndEvent.KIND)),
|
||||
BIRDS("birds", listOf(BirdDetectionEvent.KIND, BirdexEvent.KIND)),
|
||||
ATTESTATIONS(
|
||||
"attestations",
|
||||
listOf(
|
||||
AttestationEvent.KIND,
|
||||
AttestationRequestEvent.KIND,
|
||||
AttestorRecommendationEvent.KIND,
|
||||
AttestorProficiencyEvent.KIND,
|
||||
),
|
||||
),
|
||||
NIPS("nips", listOf(NipTextEvent.KIND)),
|
||||
MUSIC("music", listOf(AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, AudioHeaderEvent.KIND)),
|
||||
PODCASTS("podcasts", listOf(PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND)),
|
||||
FUNDRAISERS("fundraisers", listOf(FundraiserEvent.KIND)),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Every group, enabled by default so a fresh (or never-customized) account loads everything. */
|
||||
val ALL: Set<HomeFeedType> = entries.toSet()
|
||||
|
||||
fun fromCode(code: String?): HomeFeedType? = entries.firstOrNull { it.code == code }
|
||||
|
||||
/** Serializes a set of groups as their comma-joined [code]s, for SharedPreferences. */
|
||||
fun encode(types: Set<HomeFeedType>): String = types.joinToString(",") { it.code }
|
||||
|
||||
/** Parses a comma-joined [code] list back to a set, dropping any unknown codes. */
|
||||
fun decode(joined: String?): Set<HomeFeedType> =
|
||||
joined
|
||||
?.split(",")
|
||||
?.mapNotNull { fromCode(it.trim()) }
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
|
||||
/**
|
||||
* The event kinds to drop from the home relay filters and the home DAL, given the currently
|
||||
* [enabled] set. A kind stays live if ANY enabled group still owns it (guards against a
|
||||
* future overlap between two groups), so disabling one group never silently hides a kind a
|
||||
* still-enabled group also wants.
|
||||
*/
|
||||
fun disabledKinds(enabled: Set<HomeFeedType>): Set<Int> {
|
||||
if (enabled.size == ALL.size) return emptySet()
|
||||
val enabledKinds = enabled.flatMapTo(HashSet()) { it.kinds }
|
||||
return (ALL - enabled).flatMapTo(HashSet()) { it.kinds }.apply { removeAll(enabledKinds) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -248,6 +248,19 @@ class AccountFeedContentStates(
|
||||
}
|
||||
}
|
||||
|
||||
// Toggling a Home content type on/off in Settings › Home changes which event kinds the tabs
|
||||
// render, but no event flows through LocalCache — force a rebuild of all three home feeds so
|
||||
// hidden kinds disappear (and re-enabled ones reappear from cache) immediately.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.settings.enabledHomeFeedTypes
|
||||
.drop(1)
|
||||
.collect {
|
||||
homeNewThreads.invalidateData()
|
||||
homeReplies.invalidateData()
|
||||
homeEverything.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
// Pinning/unpinning a room only changes sort order, not membership, so no
|
||||
// chat event flows through LocalCache. Force a rebuild to re-sort. This
|
||||
// also fires when pins arrive via the synced AppSpecificData event.
|
||||
|
||||
+20
-13
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
|
||||
@@ -50,10 +51,11 @@ class HomeConversationsFeedFilter(
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val filterParams = buildFilterParams(account)
|
||||
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
|
||||
|
||||
return sort(
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
acceptableEvent(it, filterParams)
|
||||
acceptableEvent(it, filterParams, disabledKinds)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -68,9 +70,10 @@ class HomeConversationsFeedFilter(
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val filterParams = buildFilterParams(account)
|
||||
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
|
||||
|
||||
return collection.filterTo(HashSet()) {
|
||||
acceptableEvent(it, filterParams)
|
||||
acceptableEvent(it, filterParams, disabledKinds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,23 +81,27 @@ class HomeConversationsFeedFilter(
|
||||
event: Event?,
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
filterParams: FilterByListParams,
|
||||
disabledKinds: Set<Int>,
|
||||
): Boolean =
|
||||
(
|
||||
event is TextNoteEvent ||
|
||||
event is ZapPollEvent ||
|
||||
event is PollResponseEvent ||
|
||||
event is ChannelMessageEvent ||
|
||||
event is CommentEvent ||
|
||||
event is VoiceReplyEvent ||
|
||||
event is PublicMessageEvent ||
|
||||
event is LiveActivitiesChatMessageEvent
|
||||
) &&
|
||||
event != null &&
|
||||
event.kind !in disabledKinds &&
|
||||
(
|
||||
event is TextNoteEvent ||
|
||||
event is ZapPollEvent ||
|
||||
event is PollResponseEvent ||
|
||||
event is ChannelMessageEvent ||
|
||||
event is CommentEvent ||
|
||||
event is VoiceReplyEvent ||
|
||||
event is PublicMessageEvent ||
|
||||
event is LiveActivitiesChatMessageEvent
|
||||
) &&
|
||||
filterParams.match(event, relays)
|
||||
|
||||
fun acceptableEvent(
|
||||
note: Note,
|
||||
filterParams: FilterByListParams,
|
||||
): Boolean = acceptableEvent(note.event, note.relays, filterParams) && !note.isNewThread()
|
||||
disabledKinds: Set<Int>,
|
||||
): Boolean = acceptableEvent(note.event, note.relays, filterParams, disabledKinds) && !note.isNewThread()
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
|
||||
}
|
||||
|
||||
+9
-4
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
@@ -93,18 +94,19 @@ class HomeNewThreadFeedFilter(
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val filterParams = buildFilterParams(account)
|
||||
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
|
||||
|
||||
val notes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
// Avoids processing addressables twice.
|
||||
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams)
|
||||
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams, disabledKinds)
|
||||
}
|
||||
|
||||
val longFormNotes =
|
||||
LocalCache.addressables.filterIntoSet(
|
||||
kinds = ADDRESSABLE_KINDS,
|
||||
) { _, note ->
|
||||
acceptableEvent(note, filterParams)
|
||||
acceptableEvent(note, filterParams, disabledKinds)
|
||||
}
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
@@ -114,17 +116,20 @@ class HomeNewThreadFeedFilter(
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val filterParams = buildFilterParams(account)
|
||||
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
|
||||
|
||||
return collection.filterTo(HashSet()) {
|
||||
acceptableEvent(it, filterParams)
|
||||
acceptableEvent(it, filterParams, disabledKinds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptableEvent(
|
||||
it: Note,
|
||||
filterParams: FilterByListParams,
|
||||
disabledKinds: Set<Int>,
|
||||
): Boolean {
|
||||
val noteEvent = it.event
|
||||
val noteEvent = it.event ?: return false
|
||||
if (noteEvent.kind in disabledKinds) return false
|
||||
return (
|
||||
noteEvent is TextNoteEvent ||
|
||||
noteEvent is ClassifiedsEvent ||
|
||||
|
||||
+45
-13
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
@@ -49,6 +50,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -63,19 +65,24 @@ class HomeOutboxEventsEoseManager(
|
||||
val feedSettings = key.followsPerRelay()
|
||||
val newThreadSince = key.feedState.homeNewThreads.lastNoteCreatedAtIfFilled()
|
||||
val repliesSince = key.feedState.homeReplies.lastNoteCreatedAtIfFilled()
|
||||
return when (feedSettings) {
|
||||
is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince)
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince)
|
||||
is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince)
|
||||
is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
|
||||
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
val base =
|
||||
when (feedSettings) {
|
||||
is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince)
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince)
|
||||
is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince)
|
||||
is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
|
||||
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
// Drop the kinds the user turned off in Settings › Home from every home relay filter, so a
|
||||
// disabled group is never downloaded regardless of which top-nav strategy built the filters.
|
||||
return base.removeDisabledHomeKinds(HomeFeedType.disabledKinds(key.account.settings.enabledHomeFeedTypes.value))
|
||||
}
|
||||
|
||||
override fun user(key: HomeQueryState) = key.account.userProfile()
|
||||
@@ -108,6 +115,13 @@ class HomeOutboxEventsEoseManager(
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
// Re-arm the home subscriptions when a content-type toggle flips, so a disabled
|
||||
// group leaves the live REQ and a re-enabled one comes back without a restart.
|
||||
key.account.settings.enabledHomeFeedTypes
|
||||
.drop(1)
|
||||
.collectLatest { invalidateFilters() }
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
@@ -131,3 +145,21 @@ class HomeOutboxEventsEoseManager(
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the [disabled] kinds from each home filter. A filter with no `kinds` (e.g. an
|
||||
* algo-feed id/address fetch) is left untouched; a filter whose kinds all become disabled is
|
||||
* dropped entirely, since sending it with an empty `kinds` would wrongly match every kind.
|
||||
*/
|
||||
private fun List<RelayBasedFilter>.removeDisabledHomeKinds(disabled: Set<Int>): List<RelayBasedFilter> {
|
||||
if (disabled.isEmpty()) return this
|
||||
return mapNotNull { relayFilter ->
|
||||
val kinds = relayFilter.filter.kinds ?: return@mapNotNull relayFilter
|
||||
val kept = kinds.filterNot { it in disabled }
|
||||
when {
|
||||
kept.size == kinds.size -> relayFilter
|
||||
kept.isEmpty() -> null
|
||||
else -> RelayBasedFilter(relayFilter.relay, relayFilter.filter.copy(kinds = kept))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-36
@@ -34,7 +34,9 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.model.UiSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -65,53 +67,106 @@ fun HomeTabsSettingsScreen(
|
||||
TopBarWithBackButton(stringRes(id = R.string.home_tabs_settings), nav)
|
||||
},
|
||||
) { padding ->
|
||||
HomeTabsSettingsContent(accountViewModel.settings.uiSettingsFlow, Modifier.padding(padding))
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
HomeTabsSection(accountViewModel.settings.uiSettingsFlow)
|
||||
HomeContentTypesSection(accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeTabsSettingsContent(
|
||||
ui: UiSettingsFlow,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
private fun HomeTabsSection(ui: UiSettingsFlow) {
|
||||
val showNewThreads by ui.showHomeNewThreadsTab.collectAsStateWithLifecycle()
|
||||
val showConversations by ui.showHomeConversationsTab.collectAsStateWithLifecycle()
|
||||
val showEverything by ui.showHomeEverythingTab.collectAsStateWithLifecycle()
|
||||
|
||||
val activeCount = listOf(showNewThreads, showConversations, showEverything).count { it }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
SettingsSection(R.string.settings_section_home_tabs) {
|
||||
SettingsSection(R.string.settings_section_home_tabs) {
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Forum,
|
||||
title = R.string.new_threads,
|
||||
checked = showNewThreads,
|
||||
// Don't allow disabling the last remaining tab.
|
||||
enabled = !(showNewThreads && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeNewThreadsTab.tryEmit(it) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Chat,
|
||||
title = R.string.conversations,
|
||||
checked = showConversations,
|
||||
enabled = !(showConversations && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeConversationsTab.tryEmit(it) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Public,
|
||||
title = R.string.home_tab_everything,
|
||||
checked = showEverything,
|
||||
enabled = !(showEverything && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeEverythingTab.tryEmit(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One toggleable Home content group, mapping a [HomeFeedType] to its display title + icon. */
|
||||
private data class HomeFeedTypeUi(
|
||||
val type: HomeFeedType,
|
||||
val titleRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
)
|
||||
|
||||
// Ordered by how common each group is on a typical home feed (everyday posts first, niche last).
|
||||
private val HOME_FEED_TYPES =
|
||||
listOf(
|
||||
HomeFeedTypeUi(HomeFeedType.TEXT_NOTES, R.string.home_content_type_text_notes, MaterialSymbols.EditNote),
|
||||
HomeFeedTypeUi(HomeFeedType.REPOSTS, R.string.home_content_type_reposts, MaterialSymbols.Forward),
|
||||
HomeFeedTypeUi(HomeFeedType.COMMENTS, R.string.home_content_type_comments, MaterialSymbols.Chat),
|
||||
HomeFeedTypeUi(HomeFeedType.ARTICLES, R.string.home_content_type_articles, MaterialSymbols.AutoMirrored.Article),
|
||||
HomeFeedTypeUi(HomeFeedType.WIKI, R.string.home_content_type_wiki, MaterialSymbols.MenuBook),
|
||||
HomeFeedTypeUi(HomeFeedType.HIGHLIGHTS, R.string.home_content_type_highlights, MaterialSymbols.FormatQuote),
|
||||
HomeFeedTypeUi(HomeFeedType.POLLS, R.string.home_content_type_polls, MaterialSymbols.Poll),
|
||||
HomeFeedTypeUi(HomeFeedType.CLASSIFIEDS, R.string.home_content_type_classifieds, MaterialSymbols.Storefront),
|
||||
HomeFeedTypeUi(HomeFeedType.VOICE, R.string.home_content_type_voice, MaterialSymbols.Mic),
|
||||
HomeFeedTypeUi(HomeFeedType.LIVE_ACTIVITIES, R.string.home_content_type_live_activities, MaterialSymbols.Sensors),
|
||||
HomeFeedTypeUi(HomeFeedType.EPHEMERAL_CHAT, R.string.home_content_type_ephemeral_chat, MaterialSymbols.Forum),
|
||||
HomeFeedTypeUi(HomeFeedType.INTERACTIVE_STORIES, R.string.home_content_type_interactive_stories, MaterialSymbols.AutoAwesome),
|
||||
HomeFeedTypeUi(HomeFeedType.CHESS, R.string.home_content_type_chess, MaterialSymbols.ChessKnight),
|
||||
HomeFeedTypeUi(HomeFeedType.BIRDS, R.string.home_content_type_birds, MaterialSymbols.TravelExplore),
|
||||
HomeFeedTypeUi(HomeFeedType.ATTESTATIONS, R.string.home_content_type_attestations, MaterialSymbols.Shield),
|
||||
HomeFeedTypeUi(HomeFeedType.NIPS, R.string.home_content_type_nips, MaterialSymbols.Code),
|
||||
HomeFeedTypeUi(HomeFeedType.MUSIC, R.string.home_content_type_music, MaterialSymbols.MusicNote),
|
||||
HomeFeedTypeUi(HomeFeedType.PODCASTS, R.string.home_content_type_podcasts, MaterialSymbols.Podcasts),
|
||||
HomeFeedTypeUi(HomeFeedType.FUNDRAISERS, R.string.home_content_type_fundraisers, MaterialSymbols.Paid),
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-content-type load toggles for the Home feed. Turning one off both drops its event kinds from
|
||||
* the always-on home relay filters AND hides them from the New Threads / Conversations / Everything
|
||||
* tabs. Everything is on by default.
|
||||
*/
|
||||
@Composable
|
||||
private fun HomeContentTypesSection(accountViewModel: AccountViewModel) {
|
||||
val enabled by accountViewModel.account.settings.enabledHomeFeedTypes
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
SettingsSection(R.string.settings_section_home_content_types) {
|
||||
HOME_FEED_TYPES.forEachIndexed { index, item ->
|
||||
if (index > 0) SettingsDivider()
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Forum,
|
||||
title = R.string.new_threads,
|
||||
checked = showNewThreads,
|
||||
// Don't allow disabling the last remaining tab.
|
||||
enabled = !(showNewThreads && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeNewThreadsTab.tryEmit(it) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Chat,
|
||||
title = R.string.conversations,
|
||||
checked = showConversations,
|
||||
enabled = !(showConversations && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeConversationsTab.tryEmit(it) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Public,
|
||||
title = R.string.home_tab_everything,
|
||||
checked = showEverything,
|
||||
enabled = !(showEverything && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeEverythingTab.tryEmit(it) },
|
||||
icon = item.icon,
|
||||
title = item.titleRes,
|
||||
checked = item.type in enabled,
|
||||
onCheckedChange = { accountViewModel.account.settings.setHomeFeedTypeEnabled(item.type, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2330,6 +2330,26 @@
|
||||
<string name="settings_section_tor_routing">Route through Tor</string>
|
||||
<string name="settings_section_profile_sections">Sections & feeds</string>
|
||||
<string name="settings_section_home_tabs">Visible tabs</string>
|
||||
<string name="settings_section_home_content_types">Content in the feed</string>
|
||||
<string name="home_content_type_text_notes">Text notes</string>
|
||||
<string name="home_content_type_reposts">Reposts</string>
|
||||
<string name="home_content_type_comments">Comments & replies</string>
|
||||
<string name="home_content_type_articles">Articles</string>
|
||||
<string name="home_content_type_wiki">Wiki pages</string>
|
||||
<string name="home_content_type_highlights">Highlights</string>
|
||||
<string name="home_content_type_polls">Polls</string>
|
||||
<string name="home_content_type_classifieds">Classifieds</string>
|
||||
<string name="home_content_type_voice">Voice messages</string>
|
||||
<string name="home_content_type_live_activities">Live activities</string>
|
||||
<string name="home_content_type_ephemeral_chat">Ephemeral chats</string>
|
||||
<string name="home_content_type_interactive_stories">Interactive stories</string>
|
||||
<string name="home_content_type_chess">Chess games</string>
|
||||
<string name="home_content_type_birds">Bird sightings</string>
|
||||
<string name="home_content_type_attestations">Attestations</string>
|
||||
<string name="home_content_type_nips">NIP drafts</string>
|
||||
<string name="home_content_type_music">Music & audio</string>
|
||||
<string name="home_content_type_podcasts">Podcasts</string>
|
||||
<string name="home_content_type_fundraisers">Fundraisers</string>
|
||||
<string name="settings_section_reminders">Reminders</string>
|
||||
<string name="wallet_connect">Wallet Connect</string>
|
||||
<string name="language">Language</string>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HomeFeedTypeTest {
|
||||
@Test
|
||||
fun allContainsEveryEntry() {
|
||||
assertEquals(HomeFeedType.entries.toSet(), HomeFeedType.ALL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kindsAreDisjointAcrossTypes() {
|
||||
val seen = mutableSetOf<Int>()
|
||||
HomeFeedType.entries.forEach { type ->
|
||||
type.kinds.forEach { kind ->
|
||||
assertTrue("kind $kind is owned by more than one HomeFeedType", seen.add(kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeThenDecodeRoundTrips() {
|
||||
val disabled = setOf(HomeFeedType.CHESS, HomeFeedType.BIRDS)
|
||||
val enabled = HomeFeedType.ALL - disabled
|
||||
val stored = HomeFeedType.encode(HomeFeedType.ALL - enabled)
|
||||
assertEquals(enabled, HomeFeedType.ALL - HomeFeedType.decode(stored))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeNullOrBlankIsEmpty() {
|
||||
assertEquals(emptySet<HomeFeedType>(), HomeFeedType.decode(null))
|
||||
// Absence of a stored value means "nothing disabled" -> everything enabled.
|
||||
assertEquals(HomeFeedType.ALL, HomeFeedType.ALL - HomeFeedType.decode(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeDropsUnknownCodes() {
|
||||
val decoded = HomeFeedType.decode("chess,future-kind")
|
||||
assertEquals(setOf(HomeFeedType.CHESS), decoded)
|
||||
assertNull(HomeFeedType.fromCode("future-kind"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledKindsEmptyWhenEverythingEnabled() {
|
||||
assertTrue(HomeFeedType.disabledKinds(HomeFeedType.ALL).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledKindsAreExactlyTheDisabledGroupsKinds() {
|
||||
val enabled = HomeFeedType.ALL - HomeFeedType.TEXT_NOTES - HomeFeedType.REPOSTS
|
||||
val disabled = HomeFeedType.disabledKinds(enabled)
|
||||
|
||||
assertTrue(TextNoteEvent.KIND in disabled)
|
||||
HomeFeedType.REPOSTS.kinds.forEach { assertTrue(it in disabled) }
|
||||
// A still-enabled group's kinds must not leak into the disabled set.
|
||||
HomeFeedType.POLLS.kinds.forEach { assertFalse(it in disabled) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user