mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
refactor: unify Relay Groups onto the discovery feed
The top-level "Relay Groups" tab was a thin server-list home screen while a separate "Find Groups" discovery feed did the real work. They're now one screen: the discovery feed IS the Relay Groups tab, defaulting to a "My Groups" filter. - Route.RelayGroups now renders the discovery feed (top-level DisappearingScaffold + AppBottomBar + drawer top bar with the filter spinner and browse action). - "My Groups" (TopFilter.Mine) lists the groups you've joined. These live on their host relays (kind 10009), not your outbox, so the filter scans the cache for groups where you're the relay-key / an admin / a member; the joined rosters are kept live by RelayGroupRosterSubscription mounted on the screen. - Per-relay "server" browsing is still available via the relay chips in the filter; the grouped server rail still shows in the Messages tab (GROUPED mode). - Default discovery filter is now Mine (was Global); the "Mine" chip is back in the route list. - Deleted RelayGroupsHomeScreen and the redundant Route.RelayGroupDiscovery; the Messages "Find groups" row and everything else point at Route.RelayGroups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
@@ -962,7 +962,7 @@ object LocalPreferences {
|
||||
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
|
||||
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
relayGroupsDiscovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
|
||||
relayGroupsDiscovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, null), TopFilter.Mine),
|
||||
napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
|
||||
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
|
||||
|
||||
@@ -215,7 +215,7 @@ class AccountSettings(
|
||||
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultAppRecommendationsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
|
||||
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
|
||||
|
||||
@@ -114,7 +114,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupNewThreadScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupsHomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
|
||||
@@ -377,7 +376,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayGroups> { RelayGroupsHomeScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
|
||||
@@ -637,13 +636,6 @@ fun BuildNavigation(
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupDiscovery> {
|
||||
RelayGroupDiscoveryScreen(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
|
||||
|
||||
@@ -679,8 +679,6 @@ sealed class Route {
|
||||
|
||||
@Serializable object RelayGroupBrowse : Route()
|
||||
|
||||
@Serializable object RelayGroupDiscovery : Route()
|
||||
|
||||
@Serializable data class ChannelMetadataEdit(
|
||||
val id: String? = null,
|
||||
) : Route()
|
||||
|
||||
@@ -337,8 +337,8 @@ class TopNavFilterState(
|
||||
emit(
|
||||
listOf(
|
||||
// Relay-group discovery routes to relays by author, hashtag and geohash, plus a
|
||||
// favorite-relay chip; mirrors the kind3 catalog like the git-repositories feed.
|
||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
|
||||
// favorite-relay chip; "Mine" is the joined-groups view (the tab's default).
|
||||
listOf(mineFollow, allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
|
||||
peopleLists,
|
||||
interests,
|
||||
listOf(muteListFollow),
|
||||
@@ -447,7 +447,7 @@ class TopNavFilterState(
|
||||
val relayGroupsDiscoveryRoutes =
|
||||
_relayGroupsDiscoveryRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow))
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(mineFollow, allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow))
|
||||
|
||||
val podcastRoutes =
|
||||
_podcastRoutes
|
||||
|
||||
+63
-29
@@ -33,10 +33,10 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -64,14 +64,19 @@ import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.relayGroupDiscoveryChannelFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupPreviewSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
@@ -106,35 +111,19 @@ fun RelayGroupDiscoveryScreen(
|
||||
WatchLifecycleAndUpdateModel(feedContentState)
|
||||
WatchAccountForRelayGroupDiscovery(feedContentState, accountViewModel)
|
||||
RelayGroupsDiscoveryFilterAssemblerSubscription(accountViewModel)
|
||||
// Keep the joined groups' metadata + rosters live so the "My Groups" filter can list them
|
||||
// (their host relays aren't fetched by the discovery filter set).
|
||||
RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes
|
||||
.collectAsStateWithLifecycle()
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
FeedFilterSpinner(
|
||||
placeholderCode = selectedFilter,
|
||||
explainer = stringRes(R.string.select_list_to_filter),
|
||||
options = options,
|
||||
onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { nav.nav(Route.RelayGroupBrowse) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = stringRes(R.string.relay_group_browse_title),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = { RelayGroupsDiscoveryTopBar(accountViewModel, nav) },
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.RelayGroups, nav, accountViewModel) { route ->
|
||||
if (route == Route.RelayGroups) feedContentState.sendToTop() else nav.navBottomBar(route)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { padding ->
|
||||
Column(Modifier.padding(padding)) {
|
||||
RefresheableBox(feedContentState, true) {
|
||||
@@ -168,6 +157,51 @@ fun RelayGroupDiscoveryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Root top bar for the Relay Groups tab: the drawer opener (or back arrow on a sub-stack), the
|
||||
* feed-filter spinner as title, and a browse-a-relay-by-URL action. Mirrors GitRepositoriesTopBar
|
||||
* but keeps the browse action, which the shared UserDrawerSearchTopBar doesn't expose.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RelayGroupsDiscoveryTopBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
ShorterTopAppBar(
|
||||
title = {
|
||||
FeedFilterSpinner(
|
||||
placeholderCode = selectedFilter,
|
||||
explainer = stringRes(R.string.select_list_to_filter),
|
||||
options = options,
|
||||
onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
if (nav.canPop()) {
|
||||
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
|
||||
} else {
|
||||
LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { nav.nav(Route.RelayGroupBrowse) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = stringRes(R.string.relay_group_browse_title),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchAccountForRelayGroupDiscovery(
|
||||
feedContentState: FeedContentState,
|
||||
|
||||
+2
-2
@@ -71,8 +71,8 @@ fun RelayGroupServerList(
|
||||
}
|
||||
|
||||
// Always offer a discovery entry, so a user with no groups yet still has
|
||||
// somewhere to start.
|
||||
FindChannelsRow { nav.nav(Route.RelayGroupDiscovery) }
|
||||
// somewhere to start — the Relay Groups tab is the discovery feed.
|
||||
FindChannelsRow { nav.nav(Route.RelayGroups) }
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* The top-level "Relay Groups" destination — one tap from the nav drawer / bottom
|
||||
* bar, distinct from the DM-centric Messages tab. Lists the host relays of the
|
||||
* groups the user has joined (each drilling into that relay's channels) and offers
|
||||
* the discovery entry to find more. Keeps every joined group's roster live while
|
||||
* on top via [RelayGroupRosterSubscription].
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupsHomeScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel)
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_groups_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.RelayGroups, nav, accountViewModel) { route ->
|
||||
nav.navBottomBar(route)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Joined relays (each drills into its channels), followed by the
|
||||
// always-present "Find groups" discovery row.
|
||||
RelayGroupServerList(accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -64,9 +64,18 @@ class RelayGroupDiscoveryFeedFilter(
|
||||
|
||||
fun followList(): TopFilter = account.settings.defaultRelayGroupsDiscoveryFollowList.value
|
||||
|
||||
/**
|
||||
* "My Groups" = the groups I've joined. These live on their host relays (from kind 10009), not
|
||||
* my outbox, so the standard [TopFilter.Mine] relay-set resolution wouldn't reach them — instead
|
||||
* we scan the cache for groups where I'm the relay-key / an admin / a member. The joined groups'
|
||||
* metadata + rosters are kept in cache by RelayGroupRosterSubscription mounted on the screen.
|
||||
*/
|
||||
private fun isMine(): Boolean = followList() is TopFilter.Mine
|
||||
|
||||
private fun constraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
if (isMine()) return sort(myGroupNotes())
|
||||
val byRelay = constraints()
|
||||
val notes =
|
||||
LocalCache.addressables.filterIntoSet(GroupMetadataEvent.KIND) { _, note ->
|
||||
@@ -75,6 +84,13 @@ class RelayGroupDiscoveryFeedFilter(
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
private fun myGroupNotes(): Set<Note> {
|
||||
val me = account.userProfile().pubkeyHex
|
||||
return LocalCache.relayGroupChannels
|
||||
.filter { _, channel -> channel.event != null && channel.membershipOf(me).isMember() }
|
||||
.mapNotNullTo(HashSet()) { it.metadataNote }
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||
val byRelay = constraints()
|
||||
return newItems.flatMapTo(HashSet()) { note ->
|
||||
@@ -100,8 +116,9 @@ class RelayGroupDiscoveryFeedFilter(
|
||||
note: Note,
|
||||
byRelay: Map<NormalizedRelayUrl, GroupDiscoveryConstraint>,
|
||||
): Boolean {
|
||||
if (byRelay.isEmpty()) return false
|
||||
val channel = relayGroupDiscoveryChannelFor(note) ?: return false
|
||||
if (isMine()) return channel.membershipOf(account.userProfile().pubkeyHex).isMember()
|
||||
if (byRelay.isEmpty()) return false
|
||||
return byRelay[channel.groupId.relayUrl]?.matches(channel) == true
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user