reactions SSE!
This commit is contained in:
@@ -24,6 +24,7 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
|
|||||||
|
|
||||||
serverEvents.on("test", handleEvent);
|
serverEvents.on("test", handleEvent);
|
||||||
serverEvents.on("match", handleEvent);
|
serverEvents.on("match", handleEvent);
|
||||||
|
serverEvents.on("reaction", handleEvent);
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(match);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Indicator
|
<Indicator
|
||||||
disabled={!isStarted}
|
disabled={!isStarted}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useServerSuspenseQuery, useServerQuery } from "@/lib/tanstack-query/hooks";
|
|
||||||
import { getMatchReactions } from "./server";
|
|
||||||
|
|
||||||
export const matchKeys = {
|
|
||||||
list: ['matches', 'list'] as const,
|
|
||||||
details: (id: string) => ['matches', 'details', id] as const,
|
|
||||||
reactions: (id: string) => ['matches', 'reactions', id] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const matchQueries = {
|
|
||||||
reactions: (matchId: string) => ({
|
|
||||||
queryKey: matchKeys.reactions(matchId),
|
|
||||||
queryFn: () => getMatchReactions({ data: matchId }),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMatchReactions = (matchId: string) =>
|
|
||||||
useServerQuery(matchQueries.reactions(matchId));
|
|
||||||
@@ -153,7 +153,6 @@ export const startMatch = createServerFn()
|
|||||||
status: "started",
|
status: "started",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('emitting start match...')
|
|
||||||
serverEvents.emit("match", {
|
serverEvents.emit("match", {
|
||||||
type: "match",
|
type: "match",
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
@@ -300,7 +299,6 @@ export const toggleMatchReaction = createServerFn()
|
|||||||
serverEvents.emit("reaction", {
|
serverEvents.emit("reaction", {
|
||||||
type: "reaction",
|
type: "reaction",
|
||||||
matchId,
|
matchId,
|
||||||
tournamentId: match.tournament.id,
|
|
||||||
reactions,
|
reactions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
const { data: matches } = usePlayerMatches(id);
|
const { data: matches } = usePlayerMatches(id);
|
||||||
const { data: stats, isLoading: statsLoading } = usePlayerStats(id);
|
const { data: stats, isLoading: statsLoading } = usePlayerStats(id);
|
||||||
|
|
||||||
console.log(player.teams)
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Text,
|
Text,
|
||||||
Tabs,
|
Stack,
|
||||||
|
ScrollArea,
|
||||||
|
Paper,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
@@ -27,12 +29,12 @@ const EmojiBar = ({
|
|||||||
const toggleReaction = useToggleMatchReaction(matchId);
|
const toggleReaction = useToggleMatchReaction(matchId);
|
||||||
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [activeTab, setActiveTab] = useState<string | null>(null);
|
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
||||||
const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
|
const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const handleLongPressStart = (emoji: string) => {
|
const handleLongPressStart = (emoji: string) => {
|
||||||
longPressTimeout.current = setTimeout(() => {
|
longPressTimeout.current = setTimeout(() => {
|
||||||
setActiveTab(emoji);
|
setSelectedEmoji(emoji);
|
||||||
open();
|
open();
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
@@ -54,17 +56,29 @@ const EmojiBar = ({
|
|||||||
|
|
||||||
const hasReacted = useCallback((reaction: Reaction) => {
|
const hasReacted = useCallback((reaction: Reaction) => {
|
||||||
return reaction.players.map(p => p.id).includes(user?.id || "");
|
return reaction.players.map(p => p.id).includes(user?.id || "");
|
||||||
}, []);
|
}, [user?.id]);
|
||||||
|
|
||||||
|
// Get emojis the current user has reacted to
|
||||||
|
const userReactions = reactions?.filter(r => hasReacted(r)).map(r => r.emoji) || [];
|
||||||
|
|
||||||
if (!reactions) return;
|
if (!reactions) return;
|
||||||
|
|
||||||
console.log(reactions)
|
// Sort reactions by count (descending)
|
||||||
|
const sortedReactions = [...reactions].sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
// Group reactions: show first 3, group the rest
|
||||||
|
const visibleReactions = sortedReactions.slice(0, 3);
|
||||||
|
const groupedReactions = sortedReactions.slice(3);
|
||||||
|
|
||||||
|
const hasGrouped = groupedReactions.length > 0;
|
||||||
|
const groupedCount = groupedReactions.reduce((sum, r) => sum + r.count, 0);
|
||||||
|
const userHasReactedToGrouped = groupedReactions.some(r => hasReacted(r));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group gap="xs" wrap="wrap" justify="space-between">
|
<Group gap="xs" wrap="wrap" justify="space-between">
|
||||||
<Group gap="xs" wrap="wrap">
|
<Group gap="xs" wrap="wrap">
|
||||||
{reactions.map((reaction) => (
|
{visibleReactions.map((reaction) => (
|
||||||
<Button
|
<Button
|
||||||
key={reaction.emoji}
|
key={reaction.emoji}
|
||||||
variant={hasReacted(reaction) ? "filled" : "light"}
|
variant={hasReacted(reaction) ? "filled" : "light"}
|
||||||
@@ -95,31 +109,100 @@ const EmojiBar = ({
|
|||||||
</Group>
|
</Group>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{hasGrouped && (
|
||||||
|
<Button
|
||||||
|
variant={userHasReactedToGrouped ? "filled" : "light"}
|
||||||
|
color="gray"
|
||||||
|
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
||||||
|
size="compact-xs"
|
||||||
|
radius="xl"
|
||||||
|
onMouseDown={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
|
||||||
|
onMouseUp={handleLongPressEnd}
|
||||||
|
onMouseLeave={handleLongPressEnd}
|
||||||
|
onTouchStart={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
|
||||||
|
onTouchEnd={handleLongPressEnd}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEmoji(groupedReactions[0]?.emoji || "");
|
||||||
|
open();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
userSelect: "none",
|
||||||
|
WebkitUserSelect: "none",
|
||||||
|
MozUserSelect: "none",
|
||||||
|
msUserSelect: "none",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={2} align="center">
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "1px",
|
||||||
|
alignItems: "center",
|
||||||
|
fontSize: "10px",
|
||||||
|
lineHeight: 1
|
||||||
|
}}>
|
||||||
|
{groupedReactions.slice(0, 2).map((reaction) => (
|
||||||
|
<span key={reaction.emoji}>{reaction.emoji}</span>
|
||||||
|
))}
|
||||||
|
{groupedReactions.length > 2 && (
|
||||||
|
<Text size="8px" fw={600} c="dimmed">
|
||||||
|
+{groupedReactions.length - 2}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Text size="xs" fw={600}>
|
||||||
|
{groupedCount}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<EmojiPicker onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))} />
|
<EmojiPicker
|
||||||
|
onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))}
|
||||||
|
userReactions={userReactions}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Sheet title="Reactions" opened={opened} onChange={() => close()}>
|
<Sheet title="Reactions" opened={opened} onChange={() => close()}>
|
||||||
<Tabs value={activeTab || reactions[0]?.emoji} onChange={setActiveTab}>
|
<Stack gap="md">
|
||||||
<Tabs.List grow>
|
<ScrollArea w="100%" offsetScrollbars>
|
||||||
{reactions.map((reaction) => (
|
<Group gap="xs" wrap="nowrap" px="xs">
|
||||||
<Tabs.Tab key={reaction.emoji} value={reaction.emoji}>
|
{sortedReactions.map((reaction) => (
|
||||||
<Group gap="xs" align="center">
|
<Button
|
||||||
<Text size="lg">{reaction.emoji}</Text>
|
key={reaction.emoji}
|
||||||
<Text size="xs" c="dimmed">
|
variant={selectedEmoji === reaction.emoji ? "filled" : "light"}
|
||||||
{reaction.count}
|
color="gray"
|
||||||
</Text>
|
size="compact-sm"
|
||||||
</Group>
|
radius="xl"
|
||||||
</Tabs.Tab>
|
onClick={() => setSelectedEmoji(reaction.emoji)}
|
||||||
))}
|
style={{ flexShrink: 0 }}
|
||||||
</Tabs.List>
|
>
|
||||||
|
<Group gap={4} align="center">
|
||||||
|
<Text size="sm">{reaction.emoji}</Text>
|
||||||
|
<Text size="xs" fw={600}>
|
||||||
|
{reaction.count}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
{reactions.map((reaction) => (
|
{selectedEmoji && (
|
||||||
<Tabs.Panel key={reaction.emoji} value={reaction.emoji} pt="md">
|
<Paper p="md" withBorder radius="md">
|
||||||
<PlayerList players={reaction.players} />
|
<Group gap="sm" mb="md">
|
||||||
</Tabs.Panel>
|
<Text size="2xl">{selectedEmoji}</Text>
|
||||||
))}
|
<div>
|
||||||
</Tabs>
|
<Text size="lg" fw={600}>
|
||||||
|
{sortedReactions.find(r => r.emoji === selectedEmoji)?.count || 0}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<PlayerList players={sortedReactions.find(r => r.emoji === selectedEmoji)?.players || []} />
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,20 +5,28 @@ import { useState } from "react";
|
|||||||
interface EmojiPickerProps {
|
interface EmojiPickerProps {
|
||||||
onSelect: (emoji: string) => void;
|
onSelect: (emoji: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
userReactions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMOJIS = [
|
const EMOJIS = [
|
||||||
{ emoji: "😊", label: "smile" },
|
{ emoji: "🫡", label: "salute" },
|
||||||
{ emoji: "😢", label: "cry" },
|
{ emoji: "😭", label: "crying" },
|
||||||
|
{ emoji: "🫦", label: "lip" },
|
||||||
|
{ emoji: "🏗️", label: "crane" },
|
||||||
{ emoji: "👀", label: "eyes" },
|
{ emoji: "👀", label: "eyes" },
|
||||||
{ emoji: "🔥", label: "fire" },
|
{ emoji: "😱", label: "scream" },
|
||||||
{ emoji: "❤️", label: "heart" },
|
{ emoji: "🥹", label: "owo" },
|
||||||
{ emoji: "👑", label: "crown" },
|
{ emoji: "🤣", label: "rofl" },
|
||||||
|
{ emoji: "🤪", label: "crazy" },
|
||||||
|
{ emoji: "🤓", label: "nerd" },
|
||||||
|
{ emoji: "🥵", label: "hot" },
|
||||||
|
{ emoji: "🥶", label: "cold" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const EmojiPicker = ({
|
const EmojiPicker = ({
|
||||||
onSelect,
|
onSelect,
|
||||||
disabled = false
|
disabled = false,
|
||||||
|
userReactions = []
|
||||||
}: EmojiPickerProps) => {
|
}: EmojiPickerProps) => {
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
|
|
||||||
@@ -50,36 +58,43 @@ const EmojiPicker = ({
|
|||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|
||||||
<Popover.Dropdown p="xs">
|
<Popover.Dropdown p="xs">
|
||||||
<SimpleGrid cols={6} spacing={0}>
|
<SimpleGrid cols={6} spacing={4}>
|
||||||
{EMOJIS.map(({ emoji, label }) => (
|
{EMOJIS.map(({ emoji, label }) => {
|
||||||
<UnstyledButton
|
const hasReacted = userReactions.includes(emoji);
|
||||||
key={emoji}
|
return (
|
||||||
onClick={() => handleEmojiSelect(emoji)}
|
<UnstyledButton
|
||||||
style={{
|
key={emoji}
|
||||||
borderRadius: "var(--mantine-radius-sm)",
|
onClick={() => handleEmojiSelect(emoji)}
|
||||||
display: "flex",
|
style={{
|
||||||
alignItems: "center",
|
borderRadius: "var(--mantine-radius-sm)",
|
||||||
justifyContent: "center",
|
display: "flex",
|
||||||
minHeight: 36,
|
alignItems: "center",
|
||||||
minWidth: 36,
|
justifyContent: "center",
|
||||||
}}
|
minHeight: 36,
|
||||||
styles={{
|
minWidth: 36,
|
||||||
root: {
|
backgroundColor: hasReacted ? 'var(--mantine-primary-color-light)' : undefined,
|
||||||
'&:hover': {
|
border: hasReacted ? '1px solid var(--mantine-primary-color-filled)' : undefined,
|
||||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: hasReacted
|
||||||
|
? 'var(--mantine-primary-color-light-hover)'
|
||||||
|
: 'var(--mantine-color-gray-1)',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
transform: 'scale(0.95)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'&:active': {
|
}}
|
||||||
transform: 'scale(0.95)',
|
aria-label={label}
|
||||||
},
|
>
|
||||||
},
|
<Text size="lg" style={{ lineHeight: 1 }}>
|
||||||
}}
|
{emoji}
|
||||||
aria-label={label}
|
</Text>
|
||||||
>
|
</UnstyledButton>
|
||||||
<Text size="lg" style={{ lineHeight: 1 }}>
|
);
|
||||||
{emoji}
|
})}
|
||||||
</Text>
|
|
||||||
</UnstyledButton>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks";
|
import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
|
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { matchKeys } from "@/features/matches/queries";
|
|
||||||
|
|
||||||
export const reactionKeys = {
|
export const reactionKeys = {
|
||||||
match: (matchId: string) => ['reactions', 'match', matchId] as const,
|
match: (matchId: string) => ['reactions', 'match', matchId] as const,
|
||||||
@@ -26,9 +25,6 @@ export const useToggleMatchReaction = (matchId: string) => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: reactionKeys.match(matchId)
|
queryKey: reactionKeys.match(matchId)
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: matchKeys.reactions(matchId)
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -17,8 +17,6 @@ interface TeamCardProps {
|
|||||||
const TeamCard = ({ teamId }: TeamCardProps) => {
|
const TeamCard = ({ teamId }: TeamCardProps) => {
|
||||||
const { data: team, error } = useTeam(teamId);
|
const { data: team, error } = useTeam(teamId);
|
||||||
|
|
||||||
console.log(team)
|
|
||||||
|
|
||||||
if (error || !team) {
|
if (error || !team) {
|
||||||
return (
|
return (
|
||||||
<Paper p="sm" withBorder radius="md">
|
<Paper p="sm" withBorder radius="md">
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ const TeamForm = ({
|
|||||||
|
|
||||||
mutation(teamData, {
|
mutation(teamData, {
|
||||||
onSuccess: async (team: any) => {
|
onSuccess: async (team: any) => {
|
||||||
console.log(team)
|
|
||||||
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: teamKeys.details(team.id),
|
queryKey: teamKeys.details(team.id),
|
||||||
@@ -130,7 +129,6 @@ const TeamForm = ({
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
console.log('here for some reason', result)
|
|
||||||
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
tournamentKeys.details(result.team!.id),
|
tournamentKeys.details(result.team!.id),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap={0}>
|
<Stack px="sm" align="center" gap={0}>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
src={
|
src={
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { tournamentKeys, tournamentQueries } from "@/features/tournaments/queries";
|
import { tournamentKeys, tournamentQueries } from "@/features/tournaments/queries";
|
||||||
|
import { reactionKeys, reactionQueries } from "@/features/reactions/queries";
|
||||||
|
|
||||||
let newIdeasAvailable = false;
|
let newIdeasAvailable = false;
|
||||||
let newIdeasCallbacks: (() => void)[] = [];
|
let newIdeasCallbacks: (() => void)[] = [];
|
||||||
@@ -28,10 +29,13 @@ const eventHandlers: Record<string, EventHandler> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"match": (event, queryClient) => {
|
"match": (event, queryClient) => {
|
||||||
console.log(event);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
|
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
|
||||||
queryClient.invalidateQueries(tournamentQueries.current())
|
queryClient.invalidateQueries(tournamentQueries.current())
|
||||||
|
},
|
||||||
|
|
||||||
|
"reaction": (event, queryClient) => {
|
||||||
|
queryClient.invalidateQueries(reactionQueries.match(event.matchId));
|
||||||
|
queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,4 +13,9 @@ export type MatchEvent = {
|
|||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerEvent = TestEvent | MatchEvent;
|
export type ReactionEvent = {
|
||||||
|
type: "reaction";
|
||||||
|
matchId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerEvent = TestEvent | MatchEvent | ReactionEvent;
|
||||||
|
|||||||
@@ -80,8 +80,6 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getPlayerMatches(playerId: string): Promise<Match[]> {
|
async getPlayerMatches(playerId: string): Promise<Match[]> {
|
||||||
console.log('----------------')
|
|
||||||
console.log(playerId)
|
|
||||||
const player = await pb.collection("players").getOne(playerId.trim(), {
|
const player = await pb.collection("players").getOne(playerId.trim(), {
|
||||||
expand: "teams",
|
expand: "teams",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user