mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(search): respect scope in channel suggestions
This commit is contained in:
+2
-2
@@ -14,9 +14,9 @@ import {msg} from '@lingui/core/macro';
|
||||
import type {IconProps} from '@phosphor-icons/react';
|
||||
import {
|
||||
ChatCenteredDotsIcon,
|
||||
ChatCircleIcon,
|
||||
ClockClockwiseIcon,
|
||||
ClockCounterClockwiseIcon,
|
||||
EnvelopeSimpleIcon,
|
||||
GlobeIcon,
|
||||
HashIcon,
|
||||
SparkleIcon,
|
||||
@@ -162,7 +162,7 @@ export const getSortModeOptions = (i18n: I18n): Array<{mode: ChannelSearchSortMo
|
||||
];
|
||||
const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
|
||||
current: HashIcon,
|
||||
all_dms: EnvelopeSimpleIcon,
|
||||
all_dms: ChatCircleIcon,
|
||||
open_dms: ChatCenteredDotsIcon,
|
||||
all_guilds: GlobeIcon,
|
||||
all: UsersIcon,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import {AutocompleteOption} from '@app/features/channel/components/message_search_bar/AutocompleteOption';
|
||||
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
|
||||
import {resolveChannelSuggestionDisplayName} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -72,7 +72,7 @@ export const ChannelsSection: React.FC<ChannelsSectionProps> = observer(
|
||||
className={styles.channelName}
|
||||
data-flx="channel.message-search-bar.channels-section.channel-name"
|
||||
>
|
||||
{resolveSearchChannelDisplayName(channelOption) || 'Unnamed Channel'}
|
||||
{resolveChannelSuggestionDisplayName(channelOption) || 'Unnamed Channel'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+46
-2
@@ -5,25 +5,34 @@ import type {
|
||||
HistoryFilterRow,
|
||||
} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import type {GuildMember} from '@app/features/member/models/GuildMember';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
|
||||
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
|
||||
import type {MessageSearchScope, SearchFilterOption} from '@app/features/search/utils/SearchUtils';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {IconProps} from '@phosphor-icons/react';
|
||||
import {ChatCenteredDotsIcon, EnvelopeSimpleIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
|
||||
import {ChatCenteredDotsIcon, ChatCircleIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
|
||||
|
||||
export const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
|
||||
current: HashIcon,
|
||||
all_dms: EnvelopeSimpleIcon,
|
||||
all_dms: ChatCircleIcon,
|
||||
open_dms: ChatCenteredDotsIcon,
|
||||
all_guilds: GlobeIcon,
|
||||
all: UsersIcon,
|
||||
open_dms_and_all_guilds: UsersIcon,
|
||||
};
|
||||
|
||||
export function resolveChannelSuggestionDisplayName(channel: Channel): string {
|
||||
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return ChannelUtils.getDMDisplayName(channel);
|
||||
}
|
||||
return resolveSearchChannelDisplayName(channel);
|
||||
}
|
||||
|
||||
export function filterRequiresValue(filter: SearchFilterOption): boolean {
|
||||
return Boolean(filter.requiresValue) || (filter.values?.length ?? 0) > 0;
|
||||
}
|
||||
@@ -274,6 +283,41 @@ export function isUserFilterKey(filterKey: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export type DmChannelSuggestionMode = 'none' | 'current' | 'open' | 'all';
|
||||
export type GuildChannelSuggestionMode = 'none' | 'current_guild' | 'all_guilds';
|
||||
|
||||
export interface ChannelSuggestionSearchPlan {
|
||||
dmMode: DmChannelSuggestionMode;
|
||||
guildMode: GuildChannelSuggestionMode;
|
||||
currentGuildId?: string;
|
||||
}
|
||||
|
||||
export function getChannelSuggestionSearchPlan(
|
||||
scope: MessageSearchScope,
|
||||
currentGuildId: string | undefined,
|
||||
): ChannelSuggestionSearchPlan {
|
||||
switch (scope) {
|
||||
case 'current':
|
||||
return currentGuildId
|
||||
? {dmMode: 'none', guildMode: 'current_guild', currentGuildId}
|
||||
: {dmMode: 'current', guildMode: 'none'};
|
||||
case 'all_dms':
|
||||
return {dmMode: 'all', guildMode: 'none'};
|
||||
case 'open_dms':
|
||||
return {dmMode: 'open', guildMode: 'none'};
|
||||
case 'all':
|
||||
return {dmMode: 'all', guildMode: 'all_guilds'};
|
||||
case 'open_dms_and_all_guilds':
|
||||
return {dmMode: 'open', guildMode: 'all_guilds'};
|
||||
case 'all_guilds':
|
||||
return {dmMode: 'none', guildMode: 'all_guilds'};
|
||||
default: {
|
||||
const exhaustiveScope: never = scope;
|
||||
throw new Error(`Unsupported message search scope: ${exhaustiveScope}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type GuildSearchMode = 'none' | 'current_guild' | 'all_guilds';
|
||||
|
||||
export interface UserGuildSearchPlan {
|
||||
|
||||
@@ -4,8 +4,8 @@ import {AutocompleteOption} from '@app/features/channel/components/message_searc
|
||||
import {FilterOption} from '@app/features/channel/components/message_search_bar/FilterOption';
|
||||
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
|
||||
import type {PlaintextAutocompleteRow} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
|
||||
import {resolveChannelSuggestionDisplayName} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
@@ -143,7 +143,7 @@ export const PlaintextSection: React.FC<PlaintextSectionProps> = observer(
|
||||
className={styles.channelName}
|
||||
data-flx="channel.message-search-bar.plaintext-section.channel-name"
|
||||
>
|
||||
{resolveSearchChannelDisplayName(row.channel)}
|
||||
{resolveChannelSuggestionDisplayName(row.channel)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
+101
-33
@@ -12,6 +12,7 @@ import {
|
||||
buildHistoryFilterRows,
|
||||
buildUserSearchBoosters,
|
||||
filterRequiresValue,
|
||||
getChannelSuggestionSearchPlan,
|
||||
getUserGuildSearchPlan,
|
||||
isDateFilterKey,
|
||||
isSearchFilterOptionEligible,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
PLAINTEXT_SUGGESTION_FILTER_KEYS,
|
||||
PLAINTEXT_SUGGESTIONS_PER_FILTER,
|
||||
replaceSearchTokenAtCursor,
|
||||
resolveChannelSuggestionDisplayName,
|
||||
resolveMessageSearchCurrentWord,
|
||||
type SearchTokenReplacementResult,
|
||||
} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
|
||||
@@ -32,6 +34,7 @@ import type {LexicalSearchInputHandle} from '@app/features/lexical/search/Lexica
|
||||
import MemberSearch, {type SearchContext} from '@app/features/member/state/MemberSearch';
|
||||
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import SearchHistory, {
|
||||
SEARCH_HISTORY_DISPLAY_LIMIT,
|
||||
@@ -41,7 +44,7 @@ import {
|
||||
buildSearchSegmentsFromHints,
|
||||
formatSearchHistoryEntryForStreamerMode,
|
||||
} from '@app/features/search/utils/SearchPrivacyUtils';
|
||||
import {matchSearchDateWords, resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
|
||||
import {matchSearchDateWords} from '@app/features/search/utils/SearchQueryParser';
|
||||
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
|
||||
import type {MessageSearchScope, SearchFilterOption} from '@app/features/search/utils/SearchUtils';
|
||||
import {getSearchFilterOptions} from '@app/features/search/utils/SearchUtils';
|
||||
@@ -52,7 +55,7 @@ import Users from '@app/features/user/state/Users';
|
||||
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {DateTime} from 'luxon';
|
||||
@@ -63,6 +66,18 @@ const MAX_FILTER_VALUE_SUGGESTIONS = 12;
|
||||
const MAX_MEMBER_SEARCH_RESULTS = 25;
|
||||
const MAX_DATE_WORD_SUGGESTIONS = 10;
|
||||
|
||||
function isDmSearchChannel(channel: Channel): boolean {
|
||||
return channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM;
|
||||
}
|
||||
|
||||
function isGuildSearchChannel(channel: Channel): boolean {
|
||||
return (
|
||||
channel.guildId != null &&
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) &&
|
||||
Permission.can(Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY, channel)
|
||||
);
|
||||
}
|
||||
|
||||
function isHistoryFilterRow(option: AutocompleteOption): option is HistoryFilterRow {
|
||||
if (typeof option !== 'object' || option === null || !('kind' in option)) {
|
||||
return false;
|
||||
@@ -396,27 +411,79 @@ export function useMessageSearchAutocomplete({
|
||||
);
|
||||
const resolveChannelSuggestions = useCallback(
|
||||
(searchTerm: string, limit: number): Array<Channel> => {
|
||||
const guildIdForChannels = channelGuildId ?? (routeGuildId === ME ? undefined : routeGuildId);
|
||||
if (!guildIdForChannels) {
|
||||
const privateChannels = Channels.getPrivateChannels().filter((c) => resolveSearchChannelDisplayName(c) !== '');
|
||||
return matchSorter(privateChannels, searchTerm, {
|
||||
keys: [(c: Channel) => resolveSearchChannelDisplayName(c)],
|
||||
}).slice(0, limit);
|
||||
const currentGuildId = channelGuildId ?? (routeGuildId === ME ? undefined : routeGuildId);
|
||||
const plan = getChannelSuggestionSearchPlan(activeScope, currentGuildId);
|
||||
const candidatesById = new Map<string, Channel>();
|
||||
const addCandidate = (candidate: Channel | undefined) => {
|
||||
if (candidate) {
|
||||
candidatesById.set(candidate.id, candidate);
|
||||
}
|
||||
};
|
||||
if (!hidePersonalInformation) {
|
||||
switch (plan.dmMode) {
|
||||
case 'none':
|
||||
break;
|
||||
case 'current':
|
||||
if (channel && channel.guildId == null) {
|
||||
addCandidate(channel);
|
||||
}
|
||||
break;
|
||||
case 'open':
|
||||
case 'all':
|
||||
for (const privateChannel of Channels.getPrivateChannels()) {
|
||||
addCandidate(privateChannel);
|
||||
}
|
||||
if (channel && isDmSearchChannel(channel)) {
|
||||
addCandidate(channel);
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
const exhaustiveDmMode: never = plan.dmMode;
|
||||
throw new Error(`Unsupported DM channel suggestion mode: ${exhaustiveDmMode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const channels = Channels.getGuildChannels(guildIdForChannels).filter((c) =>
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES.has(c.type),
|
||||
switch (plan.guildMode) {
|
||||
case 'none':
|
||||
break;
|
||||
case 'current_guild':
|
||||
if (!plan.currentGuildId) {
|
||||
throw new Error('Current guild channel suggestion mode requires a guild ID');
|
||||
}
|
||||
for (const guildChannel of Channels.getGuildChannels(plan.currentGuildId)) {
|
||||
if (isGuildSearchChannel(guildChannel)) {
|
||||
addCandidate(guildChannel);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'all_guilds':
|
||||
for (const guildChannels of Channels.channelGroups.byGuild.values()) {
|
||||
for (const guildChannel of guildChannels) {
|
||||
if (isGuildSearchChannel(guildChannel)) {
|
||||
addCandidate(guildChannel);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
const exhaustiveGuildMode: never = plan.guildMode;
|
||||
throw new Error(`Unsupported guild channel suggestion mode: ${exhaustiveGuildMode}`);
|
||||
}
|
||||
}
|
||||
const candidates = Array.from(candidatesById.values()).filter(
|
||||
(candidate) => resolveChannelSuggestionDisplayName(candidate) !== '',
|
||||
);
|
||||
const recentVisitsForGuild = SelectedChannel.recentlyVisitedChannels
|
||||
.filter((visit) => visit.guildId === guildIdForChannels)
|
||||
.sort((a, b) => b.timestamp - a.timestamp);
|
||||
const recencyRank = new Map<string, number>();
|
||||
recentVisitsForGuild.forEach((visit, index) => {
|
||||
SelectedChannel.sortedRecentVisits.forEach((visit, index) => {
|
||||
if (!recencyRank.has(visit.channelId)) {
|
||||
recencyRank.set(visit.channelId, index);
|
||||
}
|
||||
});
|
||||
const currentChannelId = channel?.id;
|
||||
const matches = matchSorter(channels, searchTerm, {keys: ['name']});
|
||||
const matches = matchSorter(candidates, searchTerm, {
|
||||
keys: [(candidate: Channel) => resolveChannelSuggestionDisplayName(candidate)],
|
||||
});
|
||||
const matchRank = new Map(matches.map((candidate, index) => [candidate.id, index]));
|
||||
const orderedMatches = [...matches].sort((a, b) => {
|
||||
const resolveRank = (ch: Channel) => {
|
||||
if (ch.id === currentChannelId) return -1;
|
||||
@@ -426,11 +493,11 @@ export function useMessageSearchAutocomplete({
|
||||
if (rankDifference !== 0) {
|
||||
return rankDifference;
|
||||
}
|
||||
return (a.name ?? '').localeCompare(b.name ?? '');
|
||||
return (matchRank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (matchRank.get(b.id) ?? Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
return orderedMatches.slice(0, limit);
|
||||
},
|
||||
[channelGuildId, routeGuildId, channel],
|
||||
[activeScope, channelGuildId, routeGuildId, channel, hidePersonalInformation],
|
||||
);
|
||||
const getPlaintextRows = useCallback((): Array<PlaintextAutocompleteRow> => {
|
||||
const cursorPosition = resolveInputCursorPosition(inputRef, value);
|
||||
@@ -462,7 +529,7 @@ export function useMessageSearchAutocomplete({
|
||||
}
|
||||
return rows;
|
||||
}, [value, filterOptions, filterEligibility, resolveChannelSuggestions, resolveUserSuggestions]);
|
||||
const getAutocompleteOptions = useCallback((): Array<AutocompleteOption> => {
|
||||
const resolveAutocompleteOptions = useCallback((): Array<AutocompleteOption> => {
|
||||
const cursorPos = resolveInputCursorPosition(inputRef, value);
|
||||
const currentWord = resolveMessageSearchCurrentWord({value, cursorPosition: cursorPos});
|
||||
switch (autocompleteType) {
|
||||
@@ -548,13 +615,17 @@ export function useMessageSearchAutocomplete({
|
||||
() => buildHistoryFilterRows(filterOptions, filterEligibility),
|
||||
[filterOptions, filterEligibility],
|
||||
);
|
||||
const autocompleteOptions = resolveAutocompleteOptions();
|
||||
const totalOptions = autocompleteType
|
||||
? autocompleteOptions.length + (autocompleteType === 'history' ? historyFilterRows.length : 0)
|
||||
: 0;
|
||||
const getAutocompleteOptions = useCallback(
|
||||
(): Array<AutocompleteOption> => autocompleteOptions,
|
||||
[autocompleteOptions],
|
||||
);
|
||||
const getTotalOptions = useCallback((): number => {
|
||||
if (!autocompleteType) return 0;
|
||||
if (autocompleteType === 'history') {
|
||||
return historyFilterRows.length + getAutocompleteOptions().length;
|
||||
}
|
||||
return getAutocompleteOptions().length;
|
||||
}, [autocompleteType, getAutocompleteOptions, historyFilterRows]);
|
||||
return totalOptions;
|
||||
}, [totalOptions]);
|
||||
const hasAnyOptions = useCallback((): boolean => {
|
||||
return getTotalOptions() > 0;
|
||||
}, [getTotalOptions]);
|
||||
@@ -564,13 +635,11 @@ export function useMessageSearchAutocomplete({
|
||||
if (selectedIndex < historyFilterRows.length) {
|
||||
return historyFilterRows[selectedIndex] ?? null;
|
||||
}
|
||||
const historyOptions = getAutocompleteOptions();
|
||||
const historyIndex = selectedIndex - historyFilterRows.length;
|
||||
return historyOptions[historyIndex] ?? null;
|
||||
return autocompleteOptions[historyIndex] ?? null;
|
||||
}
|
||||
const options = getAutocompleteOptions();
|
||||
return options[selectedIndex] ?? null;
|
||||
}, [selectedIndex, autocompleteType, getAutocompleteOptions, historyFilterRows]);
|
||||
return autocompleteOptions[selectedIndex] ?? null;
|
||||
}, [selectedIndex, autocompleteType, autocompleteOptions, historyFilterRows]);
|
||||
useEffect(() => {
|
||||
if (!isFocused || suppressAutoOpen) {
|
||||
setAutocompleteType(null);
|
||||
@@ -643,11 +712,10 @@ export function useMessageSearchAutocomplete({
|
||||
setCurrentFilter(null);
|
||||
}, [value, isFocused, isInGuildChannel, suppressAutoOpen, filterOptions, filterEligibility]);
|
||||
useEffect(() => {
|
||||
const totalOptions = getTotalOptions();
|
||||
if (totalOptions > 0 && (selectedIndex >= totalOptions || selectedIndex < -1)) {
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}, [autocompleteType, selectedIndex, getTotalOptions]);
|
||||
}, [selectedIndex, totalOptions]);
|
||||
const handleOptionMouseEnter = (index: number) => {
|
||||
setHoverIndex(index);
|
||||
setHasInteracted(true);
|
||||
@@ -729,7 +797,7 @@ export function useMessageSearchAutocomplete({
|
||||
case 'channels': {
|
||||
const ch = option as Channel;
|
||||
const filter = requireCurrentFilter();
|
||||
const displayName = resolveSearchChannelDisplayName(ch);
|
||||
const displayName = resolveChannelSuggestionDisplayName(ch);
|
||||
const name = displayName === '' ? i18n._(UNNAMED_DESCRIPTOR) : displayName;
|
||||
replacement = replaceSearchTokenAtCursor({
|
||||
value,
|
||||
@@ -815,7 +883,7 @@ export function useMessageSearchAutocomplete({
|
||||
shouldSubmit = true;
|
||||
break;
|
||||
}
|
||||
const rowDisplayName = resolveSearchChannelDisplayName(row.channel);
|
||||
const rowDisplayName = resolveChannelSuggestionDisplayName(row.channel);
|
||||
const channelName = rowDisplayName === '' ? i18n._(UNNAMED_DESCRIPTOR) : rowDisplayName;
|
||||
replacement = replaceSearchTokenAtCursor({
|
||||
value,
|
||||
|
||||
@@ -9,14 +9,7 @@ import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type {IconProps} from '@phosphor-icons/react';
|
||||
import {
|
||||
ChatCenteredDotsIcon,
|
||||
CheckIcon,
|
||||
EnvelopeSimpleIcon,
|
||||
GlobeIcon,
|
||||
HashIcon,
|
||||
UsersIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {ChatCenteredDotsIcon, ChatCircleIcon, CheckIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
|
||||
@@ -26,7 +19,7 @@ const SEARCH_IN_DESCRIPTOR = msg({
|
||||
});
|
||||
const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
|
||||
current: HashIcon,
|
||||
all_dms: EnvelopeSimpleIcon,
|
||||
all_dms: ChatCircleIcon,
|
||||
open_dms: ChatCenteredDotsIcon,
|
||||
all_guilds: GlobeIcon,
|
||||
all: UsersIcon,
|
||||
|
||||
Reference in New Issue
Block a user