mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-03-28 17:42:58 +01:00
messages, refresh button
This commit is contained in:
@@ -7,8 +7,9 @@ export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const limit = parseInt(searchParams.get("limit") || "20", 10);
|
||||
const before = searchParams.get("before") || undefined;
|
||||
const after = searchParams.get("after") || undefined;
|
||||
const channelId = searchParams.get("channel_id") || undefined;
|
||||
const messages = await getLatestChatMessages({ limit, before, channelId } as { limit?: number, before?: string, channelId?: string });
|
||||
const messages = await getLatestChatMessages({ limit, before, after, channelId } as { limit?: number, before?: string, after?: string, channelId?: string });
|
||||
return NextResponse.json(messages);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to fetch chat messages" }, { status: 500 });
|
||||
|
||||
145
src/app/messages/page.tsx
Normal file
145
src/app/messages/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { useConfig } from "@/components/ConfigContext";
|
||||
import { decryptMeshcoreGroupMessage } from "@/lib/meshcore_decrypt";
|
||||
import { getChannelIdFromKey } from "@/lib/meshcore";
|
||||
import ChatMessageItem, { ChatMessage } from "@/components/ChatMessageItem";
|
||||
import RefreshButton from "@/components/RefreshButton";
|
||||
|
||||
// Messages page: displays all chat messages from all channels with infinite scrolling. If a message cannot be decrypted, shows a row explaining the reason.
|
||||
|
||||
const PAGE_SIZE = 40;
|
||||
|
||||
function formatLocalTime(utcString: string): string {
|
||||
const utcDate = new Date(utcString + (utcString.endsWith('Z') ? '' : 'Z'));
|
||||
return utcDate.toLocaleString();
|
||||
}
|
||||
|
||||
export default function MessagesPage() {
|
||||
const { config } = useConfig();
|
||||
const meshcoreKeys = useMemo(() => [
|
||||
"izOH6cXN6mrJ5e26oRXNcg==", // Public key always included
|
||||
...(config?.meshcoreKeys?.map((k: any) => k.privateKey) || [])
|
||||
], [config]);
|
||||
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [lastBefore, setLastBefore] = useState<string | undefined>(undefined);
|
||||
const [autoRefreshing, setAutoRefreshing] = useState(false);
|
||||
const autoRefreshTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Infinite scroll
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
if (
|
||||
window.innerHeight + window.scrollY >= document.body.offsetHeight - 300 &&
|
||||
!loading && hasMore
|
||||
) {
|
||||
fetchMessages(lastBefore);
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", onScroll);
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
// eslint-disable-next-line
|
||||
}, [loading, hasMore, lastBefore]);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
setHasMore(true);
|
||||
setLastBefore(undefined);
|
||||
fetchMessages(undefined, true);
|
||||
// eslint-disable-next-line
|
||||
}, [meshcoreKeys.join(",")]);
|
||||
|
||||
// Poll for new messages every 10s
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
if (messages.length === 0) return;
|
||||
const latest = messages[0]?.ingest_timestamp;
|
||||
if (!latest) return;
|
||||
try {
|
||||
setAutoRefreshing(true);
|
||||
let url = `/api/chat?limit=${PAGE_SIZE}&after=${encodeURIComponent(latest)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
// Only prepend messages that are not already present
|
||||
const existingKeys = new Set(messages.map((m) => m.ingest_timestamp + m.origin));
|
||||
const newMessages = data.filter((msg: any) => !existingKeys.has(msg.ingest_timestamp + msg.origin));
|
||||
if (newMessages.length > 0) {
|
||||
setMessages((prev) => [...newMessages, ...prev]);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// Show auto-refresh spinner for 1s
|
||||
if (autoRefreshTimeout.current) clearTimeout(autoRefreshTimeout.current);
|
||||
autoRefreshTimeout.current = setTimeout(() => setAutoRefreshing(false), 1000);
|
||||
}, 10000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (autoRefreshTimeout.current) clearTimeout(autoRefreshTimeout.current);
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
const fetchMessages = async (before?: string, replace = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = `/api/chat?limit=${PAGE_SIZE}`;
|
||||
if (before) url += `&before=${encodeURIComponent(before)}`;
|
||||
// No channel_id: fetch all channels
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setMessages((prev) => replace ? data : [...prev, ...data]);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
if (data.length > 0) {
|
||||
setLastBefore(data[data.length - 1].ingest_timestamp);
|
||||
}
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch {
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 px-4" style={{ position: 'relative' }}>
|
||||
{/* Header row with title and refresh button */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">All Messages</h1>
|
||||
<RefreshButton
|
||||
onClick={() => fetchMessages(undefined, true)}
|
||||
loading={loading}
|
||||
autoRefreshing={autoRefreshing}
|
||||
title={autoRefreshing ? "Auto-refreshing..." : "Refresh messages"}
|
||||
ariaLabel="Refresh messages"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
{/* Add keyframes for spin animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex flex-col gap-0 border rounded-lg bg-white dark:bg-neutral-900 shadow divide-y divide-gray-200 dark:divide-neutral-800">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="text-gray-400 text-center py-8">No chat messages found.</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessageItem key={msg.ingest_timestamp + msg.origin + i} msg={msg} showErrorRow showChannelId={true} />
|
||||
))}
|
||||
{loading && (
|
||||
<div className="text-center py-4 text-gray-400">Loading...</div>
|
||||
)}
|
||||
{!hasMore && messages.length > 0 && (
|
||||
<div className="text-center py-4 text-gray-400">No more messages.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,7 @@ import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { useConfig } from "./ConfigContext";
|
||||
import { decryptMeshcoreGroupMessage } from "../lib/meshcore_decrypt";
|
||||
import { getChannelIdFromKey } from "../lib/meshcore";
|
||||
|
||||
interface ChatMessage {
|
||||
ingest_timestamp: string;
|
||||
origin: string;
|
||||
mesh_timestamp: string;
|
||||
packet: string;
|
||||
path_len: number;
|
||||
path: string;
|
||||
channel_hash: string;
|
||||
mac: string;
|
||||
encrypted_message: string;
|
||||
}
|
||||
import ChatMessageItem, { ChatMessage } from "./ChatMessageItem";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -30,71 +19,6 @@ function formatLocalTime(utcString: string): string {
|
||||
return utcDate.toLocaleString();
|
||||
}
|
||||
|
||||
function ChatMessageItem({ msg }: { msg: ChatMessage }) {
|
||||
const { config } = useConfig();
|
||||
const knownKeys = [
|
||||
...(config?.meshcoreKeys?.map((k: any) => k.privateKey) || []),
|
||||
"izOH6cXN6mrJ5e26oRXNcg==", // Always include public key
|
||||
];
|
||||
const [parsed, setParsed] = useState<any | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await decryptMeshcoreGroupMessage({
|
||||
encrypted_message: msg.encrypted_message,
|
||||
mac: msg.mac,
|
||||
channel_hash: msg.channel_hash,
|
||||
knownKeys,
|
||||
parse: true,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setParsed(result);
|
||||
if (result === null) {
|
||||
console.warn("Meshcore message could not be parsed", { msg });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setParsed(null);
|
||||
console.error("Error during Meshcore decryption/parsing", { msg, err });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [msg.encrypted_message, msg.mac, msg.channel_hash, knownKeys.join(",")]);
|
||||
|
||||
if (parsed) {
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
|
||||
<div className="text-xs text-gray-400 flex items-center gap-2">
|
||||
{formatLocalTime(new Date(parsed.timestamp * 1000).toISOString())}
|
||||
<span className="text-xs text-gray-500">type: {parsed.msgType}</span>
|
||||
</div>
|
||||
<div className="break-all whitespace-pre-wrap">
|
||||
<span className="font-bold text-blue-800 dark:text-blue-300">{parsed.sender}</span>
|
||||
{parsed.sender && ": "}
|
||||
<span>{parsed.text}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
|
||||
<div className="text-xs text-gray-400 flex items-center gap-2">
|
||||
{formatLocalTime(msg.ingest_timestamp)}
|
||||
</div>
|
||||
<div className="font-mono break-all whitespace-pre-wrap">
|
||||
{formatHex(msg.encrypted_message)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatBox() {
|
||||
const { config } = useConfig();
|
||||
const meshcoreKeys = [
|
||||
|
||||
109
src/components/ChatMessageItem.tsx
Normal file
109
src/components/ChatMessageItem.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useConfig } from "./ConfigContext";
|
||||
import { decryptMeshcoreGroupMessage } from "../lib/meshcore_decrypt";
|
||||
|
||||
export interface ChatMessage {
|
||||
ingest_timestamp: string;
|
||||
origin: string;
|
||||
mesh_timestamp: string;
|
||||
packet: string;
|
||||
path_len: number;
|
||||
path: string;
|
||||
channel_hash: string;
|
||||
mac: string;
|
||||
encrypted_message: string;
|
||||
}
|
||||
|
||||
function formatHex(hex: string): string {
|
||||
return hex.replace(/(.{2})/g, "$1 ").trim();
|
||||
}
|
||||
|
||||
function formatLocalTime(utcString: string): string {
|
||||
const utcDate = new Date(utcString + (utcString.endsWith('Z') ? '' : 'Z'));
|
||||
return utcDate.toLocaleString();
|
||||
}
|
||||
|
||||
export default function ChatMessageItem({ msg, showErrorRow, showChannelId }: { msg: ChatMessage, showErrorRow?: boolean, showChannelId?: boolean }) {
|
||||
const { config } = useConfig();
|
||||
const knownKeys = [
|
||||
...(config?.meshcoreKeys?.map((k: any) => k.privateKey) || []),
|
||||
"izOH6cXN6mrJ5e26oRXNcg==", // Always include public key
|
||||
];
|
||||
const [parsed, setParsed] = useState<any | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const result = await decryptMeshcoreGroupMessage({
|
||||
encrypted_message: msg.encrypted_message,
|
||||
mac: msg.mac,
|
||||
channel_hash: msg.channel_hash,
|
||||
knownKeys,
|
||||
parse: true,
|
||||
});
|
||||
if (!cancelled) {
|
||||
if (result === null) {
|
||||
setParsed(null);
|
||||
setError("Could not decrypt message with any known key.");
|
||||
} else {
|
||||
setParsed(result);
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [msg.encrypted_message, msg.mac, msg.channel_hash, knownKeys.join(",")]);
|
||||
|
||||
if (parsed) {
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
|
||||
<div className="text-xs text-gray-400 flex items-center gap-2">
|
||||
{formatLocalTime(new Date(parsed.timestamp * 1000).toISOString())}
|
||||
<span className="text-xs text-gray-500">type: {parsed.msgType}</span>
|
||||
{showChannelId && (
|
||||
<span className="text-xs text-purple-600 dark:text-purple-300 ml-2">channel: {msg.channel_hash}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all whitespace-pre-wrap">
|
||||
<span className="font-bold text-blue-800 dark:text-blue-300">{parsed.sender}</span>
|
||||
{parsed.sender && ": "}
|
||||
<span>{parsed.text}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (showErrorRow) {
|
||||
return (
|
||||
<div className="border-b border-red-200 dark:border-red-800 pb-2 mb-2 bg-red-50 dark:bg-red-900/30">
|
||||
<div className="text-xs text-red-600 dark:text-red-300 flex items-center gap-2">
|
||||
Error: {error}
|
||||
{showChannelId && (
|
||||
<span className="text-xs text-purple-600 dark:text-purple-300 ml-2">channel: {msg.channel_hash}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
|
||||
</div>
|
||||
);
|
||||
}else{
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
|
||||
<div className="text-xs text-gray-400 flex items-center gap-2">
|
||||
{formatLocalTime(msg.ingest_timestamp)}
|
||||
{showChannelId && (
|
||||
<span className="text-xs text-purple-600 dark:text-purple-300 ml-2">channel: {msg.channel_hash}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full h-5 bg-gray-200 dark:bg-neutral-800 rounded animate-pulse my-2" />
|
||||
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,17 +14,16 @@ export default function Header({ configButtonRef }: HeaderProps) {
|
||||
<header className="w-full flex items-center justify-between px-6 py-3 bg-white dark:bg-neutral-900 shadow z-20">
|
||||
<nav className="flex gap-6 items-center">
|
||||
<Link href="/" className="font-bold text-lg">MeshExplorer</Link>
|
||||
<Link href="/about">About</Link>
|
||||
<Link href="/docs">Docs</Link>
|
||||
<Link href="/messages">Messages</Link>
|
||||
</nav>
|
||||
<button
|
||||
ref={configButtonRef || contextButtonRef}
|
||||
onClick={openConfig}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
aria-label="Open configuration menu"
|
||||
aria-label="Open settings menu"
|
||||
>
|
||||
<Cog6ToothIcon className="h-6 w-6" />
|
||||
<span className="hidden sm:inline">Config</span>
|
||||
<span className="hidden sm:inline">Settings</span>
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||
import { useConfig } from "./ConfigContext";
|
||||
import moment from "moment";
|
||||
import RefreshButton from "@/components/RefreshButton";
|
||||
|
||||
const DEFAULT = {
|
||||
lat: 47.6062, // Seattle
|
||||
@@ -369,46 +370,12 @@ export default function MapView() {
|
||||
<div style={{ width: "100%", height: "100%", position: "relative" }}>
|
||||
{/* Only Refresh Button Row */}
|
||||
<div style={{ position: "absolute", top: 16, right: 16, zIndex: 1000, display: 'flex', alignItems: 'center' }}>
|
||||
<button
|
||||
<RefreshButton
|
||||
onClick={() => bounds && fetchNodes(bounds)}
|
||||
disabled={loading || !bounds}
|
||||
style={{
|
||||
background: "#fff",
|
||||
color: "#2563eb",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
width: 40,
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.10)",
|
||||
opacity: loading ? 0.7 : 1,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
transition: "background 0.2s, opacity 0.2s",
|
||||
fontSize: 22,
|
||||
padding: 0,
|
||||
}}
|
||||
aria-label="Refresh map nodes"
|
||||
loading={loading || !bounds}
|
||||
title="Refresh map nodes"
|
||||
>
|
||||
{/* Refresh Icon (SVG) */}
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={loading ? { animation: 'spin 1s linear infinite' } : {}}
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M1 20v-6h6"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.13-3.36L23 10M1 14l4.36 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
ariaLabel="Refresh map nodes"
|
||||
/>
|
||||
</div>
|
||||
{/* Add keyframes for spin animation */}
|
||||
<style>{`
|
||||
|
||||
72
src/components/RefreshButton.tsx
Normal file
72
src/components/RefreshButton.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
|
||||
interface RefreshButtonProps {
|
||||
onClick: () => void;
|
||||
loading: boolean;
|
||||
autoRefreshing?: boolean;
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
small?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function RefreshButton({
|
||||
onClick,
|
||||
loading,
|
||||
autoRefreshing = false,
|
||||
title = "Refresh",
|
||||
ariaLabel = "Refresh",
|
||||
small = false,
|
||||
disabled = false,
|
||||
}: RefreshButtonProps) {
|
||||
const size = small ? 32 : 40;
|
||||
const iconSize = small ? 18 : 22;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading || disabled}
|
||||
style={{
|
||||
background: "#fff",
|
||||
color: "#2563eb",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
width: size,
|
||||
height: size,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.10)",
|
||||
opacity: loading ? 0.7 : 1,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
transition: "background 0.2s, opacity 0.2s",
|
||||
fontSize: iconSize,
|
||||
padding: 0,
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
>
|
||||
<svg
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={(loading || autoRefreshing) ? { animation: 'spin 1s linear infinite' } : {}}
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M1 20v-6h6"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.13-3.36L23 10M1 14l4.36 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -45,13 +45,17 @@ export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTyp
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function getLatestChatMessages({ limit = 20, before, channelId }: { limit?: number, before?: string, channelId?: string } = {}) {
|
||||
export async function getLatestChatMessages({ limit = 20, before, after, channelId }: { limit?: number, before?: string, after?: string, channelId?: string } = {}) {
|
||||
let where = [];
|
||||
const params: Record<string, any> = { limit };
|
||||
if (before) {
|
||||
where.push('ingest_timestamp < {before:DateTime64}');
|
||||
params.before = before;
|
||||
}
|
||||
if (after) {
|
||||
where.push('ingest_timestamp > {after:DateTime64}');
|
||||
params.after = after;
|
||||
}
|
||||
if (channelId) {
|
||||
where.push('channel_hash = {channelId:String}');
|
||||
params.channelId = channelId;
|
||||
|
||||
Reference in New Issue
Block a user