generated from nhcarrigan/template
feat: parse and display rate_limit_event messages from Claude CLI
Closes #155 - Add RateLimitInfo struct and RateLimitEvent variant to ClaudeMessage - Emit rate-limit OutputEvent with human-readable message in wsl_bridge - Add rate-limit line type to TerminalLine union and Terminal rendering - Display rate-limit lines in amber with [rate-limit] prefix - Add Terminal.test.ts with 28 tests for getLineClass, getLinePrefix, formatTime, isToolContentLong, and truncateToolContent
This commit is contained in:
@@ -63,6 +63,26 @@ pub struct PermissionDenial {
|
|||||||
pub tool_input: serde_json::Value,
|
pub tool_input: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rate limit information from a `rate_limit_event` message.
|
||||||
|
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct RateLimitInfo {
|
||||||
|
#[serde(default)]
|
||||||
|
pub requests_limit: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub requests_remaining: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub requests_reset: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens_limit: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens_remaining: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens_reset: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retry_after_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum ClaudeMessage {
|
pub enum ClaudeMessage {
|
||||||
@@ -100,6 +120,11 @@ pub enum ClaudeMessage {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
usage: Option<UsageInfo>,
|
usage: Option<UsageInfo>,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "rate_limit_event")]
|
||||||
|
RateLimitEvent {
|
||||||
|
#[serde(default)]
|
||||||
|
rate_limit_info: RateLimitInfo,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -446,4 +471,77 @@ mod tests {
|
|||||||
assert!(serialized.contains("\"input_tokens\":100"));
|
assert!(serialized.contains("\"input_tokens\":100"));
|
||||||
assert!(serialized.contains("\"output_tokens\":50"));
|
assert!(serialized.contains("\"output_tokens\":50"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_info_default() {
|
||||||
|
let info = RateLimitInfo::default();
|
||||||
|
assert!(info.requests_limit.is_none());
|
||||||
|
assert!(info.requests_remaining.is_none());
|
||||||
|
assert!(info.requests_reset.is_none());
|
||||||
|
assert!(info.tokens_limit.is_none());
|
||||||
|
assert!(info.tokens_remaining.is_none());
|
||||||
|
assert!(info.tokens_reset.is_none());
|
||||||
|
assert!(info.retry_after_ms.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_empty_info() {
|
||||||
|
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_no_info() {
|
||||||
|
// rate_limit_info field is optional via #[serde(default)]
|
||||||
|
let json = r#"{"type":"rate_limit_event"}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_with_data() {
|
||||||
|
let json = r#"{
|
||||||
|
"type": "rate_limit_event",
|
||||||
|
"rate_limit_info": {
|
||||||
|
"requests_limit": 1000,
|
||||||
|
"requests_remaining": 0,
|
||||||
|
"requests_reset": "2024-01-01T00:01:00Z",
|
||||||
|
"tokens_limit": 50000,
|
||||||
|
"tokens_remaining": 0,
|
||||||
|
"tokens_reset": "2024-01-01T00:01:00Z",
|
||||||
|
"retry_after_ms": 60000
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||||
|
assert_eq!(rate_limit_info.requests_limit, Some(1000));
|
||||||
|
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
rate_limit_info.requests_reset,
|
||||||
|
Some("2024-01-01T00:01:00Z".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
|
||||||
|
} else {
|
||||||
|
panic!("Expected RateLimitEvent variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_ignores_unknown_fields() {
|
||||||
|
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
|
||||||
|
let json = r#"{
|
||||||
|
"type": "rate_limit_event",
|
||||||
|
"rate_limit_info": {
|
||||||
|
"requests_remaining": 0,
|
||||||
|
"some_future_field": "some_value"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||||
|
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||||
|
} else {
|
||||||
|
panic!("Expected RateLimitEvent variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1521,6 +1521,23 @@ fn process_json_line(
|
|||||||
emit_state_change(app, state, None, conversation_id.clone());
|
emit_state_change(app, state, None, conversation_id.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ClaudeMessage::RateLimitEvent { rate_limit_info } => {
|
||||||
|
tracing::warn!("Rate limit event received: {:?}", rate_limit_info);
|
||||||
|
|
||||||
|
let content = format_rate_limit_message(rate_limit_info);
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:output",
|
||||||
|
OutputEvent {
|
||||||
|
line_type: "rate-limit".to_string(),
|
||||||
|
content,
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ClaudeMessage::User { message } => {
|
ClaudeMessage::User { message } => {
|
||||||
// Increment message count for user messages
|
// Increment message count for user messages
|
||||||
stats.write().increment_messages();
|
stats.write().increment_messages();
|
||||||
@@ -1629,6 +1646,35 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_rate_limit_message(info: &crate::types::RateLimitInfo) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
|
||||||
|
if let (Some(remaining), Some(limit)) = (info.requests_remaining, info.requests_limit) {
|
||||||
|
parts.push(format!("requests: {}/{}", remaining, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(remaining), Some(limit)) = (info.tokens_remaining, info.tokens_limit) {
|
||||||
|
parts.push(format!("tokens: {}/{}", remaining, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(reset) = &info.requests_reset {
|
||||||
|
parts.push(format!("resets at {}", reset));
|
||||||
|
} else if let Some(reset) = &info.tokens_reset {
|
||||||
|
parts.push(format!("resets at {}", reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(retry_ms) = info.retry_after_ms {
|
||||||
|
let secs = retry_ms / 1000;
|
||||||
|
parts.push(format!("retry after {}s", secs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
"Rate limit reached".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Rate limit reached — {}", parts.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||||
// Helper function to check if a path is a memory file
|
// Helper function to check if a path is a memory file
|
||||||
fn is_memory_path(path: &str) -> bool {
|
fn is_memory_path(path: &str) -> bool {
|
||||||
|
|||||||
@@ -93,6 +93,8 @@
|
|||||||
return "terminal-error";
|
return "terminal-error";
|
||||||
case "thinking":
|
case "thinking":
|
||||||
return "terminal-thinking";
|
return "terminal-thinking";
|
||||||
|
case "rate-limit":
|
||||||
|
return "terminal-rate-limit";
|
||||||
default:
|
default:
|
||||||
return "terminal-default";
|
return "terminal-default";
|
||||||
}
|
}
|
||||||
@@ -110,6 +112,8 @@
|
|||||||
return "[tool]";
|
return "[tool]";
|
||||||
case "error":
|
case "error":
|
||||||
return "[error]";
|
return "[error]";
|
||||||
|
case "rate-limit":
|
||||||
|
return "[rate-limit]";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -362,6 +366,10 @@
|
|||||||
color: var(--terminal-error, #f87171);
|
color: var(--terminal-error, #f87171);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-rate-limit {
|
||||||
|
color: var(--terminal-rate-limit, #fb923c);
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-default {
|
.terminal-default {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Terminal Component Tests
|
||||||
|
*
|
||||||
|
* Tests the pure helper functions extracted from the Terminal component:
|
||||||
|
* - getLineClass: maps line types to CSS class names
|
||||||
|
* - getLinePrefix: maps line types to display prefixes
|
||||||
|
* - formatTime: formats a Date as "HH:MM AM/PM"
|
||||||
|
* - isToolContentLong: checks if tool content exceeds collapse threshold
|
||||||
|
* - truncateToolContent: truncates long tool content with ellipsis
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] rate-limit lines appear in amber
|
||||||
|
* - [ ] error lines appear in red
|
||||||
|
* - [ ] tool lines appear in purple
|
||||||
|
* - [ ] system lines appear in grey italic
|
||||||
|
* - [ ] user lines appear in cyan
|
||||||
|
* - [ ] assistant lines appear in primary text colour
|
||||||
|
* - [ ] long tool content is collapsed by default with a toggle button
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// Mirror functions from Terminal.svelte for isolated testing
|
||||||
|
|
||||||
|
function getLineClass(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case "user":
|
||||||
|
return "terminal-user";
|
||||||
|
case "assistant":
|
||||||
|
return "terminal-assistant";
|
||||||
|
case "system":
|
||||||
|
return "terminal-system italic";
|
||||||
|
case "tool":
|
||||||
|
return "terminal-tool";
|
||||||
|
case "error":
|
||||||
|
return "terminal-error";
|
||||||
|
case "thinking":
|
||||||
|
return "terminal-thinking";
|
||||||
|
case "rate-limit":
|
||||||
|
return "terminal-rate-limit";
|
||||||
|
default:
|
||||||
|
return "terminal-default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinePrefix(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case "user":
|
||||||
|
return ">";
|
||||||
|
case "assistant":
|
||||||
|
return "";
|
||||||
|
case "system":
|
||||||
|
return "[system]";
|
||||||
|
case "tool":
|
||||||
|
return "[tool]";
|
||||||
|
case "error":
|
||||||
|
return "[error]";
|
||||||
|
case "rate-limit":
|
||||||
|
return "[rate-limit]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_COLLAPSE_THRESHOLD = 60;
|
||||||
|
|
||||||
|
function isToolContentLong(content: string): boolean {
|
||||||
|
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateToolContent(content: string): string {
|
||||||
|
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("getLineClass", () => {
|
||||||
|
it("returns terminal-user for user lines", () => {
|
||||||
|
expect(getLineClass("user")).toBe("terminal-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-assistant for assistant lines", () => {
|
||||||
|
expect(getLineClass("assistant")).toBe("terminal-assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-system italic for system lines", () => {
|
||||||
|
expect(getLineClass("system")).toBe("terminal-system italic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-tool for tool lines", () => {
|
||||||
|
expect(getLineClass("tool")).toBe("terminal-tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-error for error lines", () => {
|
||||||
|
expect(getLineClass("error")).toBe("terminal-error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-thinking for thinking lines", () => {
|
||||||
|
expect(getLineClass("thinking")).toBe("terminal-thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-rate-limit for rate-limit lines", () => {
|
||||||
|
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-default for unknown line types", () => {
|
||||||
|
expect(getLineClass("unknown")).toBe("terminal-default");
|
||||||
|
expect(getLineClass("")).toBe("terminal-default");
|
||||||
|
expect(getLineClass("random-future-type")).toBe("terminal-default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getLinePrefix", () => {
|
||||||
|
it("returns > for user lines", () => {
|
||||||
|
expect(getLinePrefix("user")).toBe(">");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for assistant lines", () => {
|
||||||
|
expect(getLinePrefix("assistant")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [system] for system lines", () => {
|
||||||
|
expect(getLinePrefix("system")).toBe("[system]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [tool] for tool lines", () => {
|
||||||
|
expect(getLinePrefix("tool")).toBe("[tool]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [error] for error lines", () => {
|
||||||
|
expect(getLinePrefix("error")).toBe("[error]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [rate-limit] for rate-limit lines", () => {
|
||||||
|
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for thinking lines (no prefix)", () => {
|
||||||
|
expect(getLinePrefix("thinking")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for unknown line types", () => {
|
||||||
|
expect(getLinePrefix("unknown")).toBe("");
|
||||||
|
expect(getLinePrefix("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTime", () => {
|
||||||
|
it("formats time in 12-hour format with AM/PM", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 14, 35);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats afternoon times correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 14, 35);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("02:35");
|
||||||
|
expect(formatted.toUpperCase()).toContain("PM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats morning times correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 9, 5);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("09:05");
|
||||||
|
expect(formatted.toUpperCase()).toContain("AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats midnight correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 0, 0);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("12:00");
|
||||||
|
expect(formatted.toUpperCase()).toContain("AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats noon correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 12, 0);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("12:00");
|
||||||
|
expect(formatted.toUpperCase()).toContain("PM");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isToolContentLong", () => {
|
||||||
|
it("returns false for content at or below the threshold", () => {
|
||||||
|
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
|
||||||
|
expect(isToolContentLong(exactThreshold)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for content exceeding the threshold", () => {
|
||||||
|
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
|
||||||
|
expect(isToolContentLong(overThreshold)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for short content", () => {
|
||||||
|
expect(isToolContentLong("short")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for empty content", () => {
|
||||||
|
expect(isToolContentLong("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("truncateToolContent", () => {
|
||||||
|
it("truncates content to the threshold length with an ellipsis", () => {
|
||||||
|
const long = "x".repeat(100);
|
||||||
|
const result = truncateToolContent(long);
|
||||||
|
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
|
||||||
|
const short = "hello";
|
||||||
|
const result = truncateToolContent(short);
|
||||||
|
expect(result).toBe("hello…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the unicode ellipsis character (not three dots)", () => {
|
||||||
|
const long = "x".repeat(100);
|
||||||
|
const result = truncateToolContent(long);
|
||||||
|
expect(result.endsWith("…")).toBe(true);
|
||||||
|
expect(result.endsWith("...")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-2
@@ -323,7 +323,7 @@ export async function initializeTauriListeners() {
|
|||||||
if (conversation_id) {
|
if (conversation_id) {
|
||||||
claudeStore.addLineToConversation(
|
claudeStore.addLineToConversation(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking" | "rate-limit",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData,
|
costData,
|
||||||
@@ -332,7 +332,7 @@ export async function initializeTauriListeners() {
|
|||||||
} else {
|
} else {
|
||||||
// Fallback to active conversation if no conversation_id provided
|
// Fallback to active conversation if no conversation_id provided
|
||||||
claudeStore.addLine(
|
claudeStore.addLine(
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking" | "rate-limit",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData,
|
costData,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface TerminalLine {
|
export interface TerminalLine {
|
||||||
id: string;
|
id: string;
|
||||||
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
|
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking" | "rate-limit";
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user