Implement Rust web sidebar title and tree interactions
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiSession {
|
||||
pub session_id: String,
|
||||
pub workspace_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub document_id: Option<String>,
|
||||
pub owner: AiRuntimeOwner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AiRuntimeOwner {
|
||||
RustWebHermes,
|
||||
CompatReactIsland,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiToolCall {
|
||||
pub call_id: String,
|
||||
pub tool_name: String,
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiEvent {
|
||||
pub event_id: String,
|
||||
pub session_id: String,
|
||||
pub kind: AiEventKind,
|
||||
#[serde(default)]
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AiEventKind {
|
||||
SessionCreated,
|
||||
ToolCallPlanned,
|
||||
ToolCallApplied,
|
||||
StructuredWriteCompleted,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiStructuredWriteResult {
|
||||
pub ok: bool,
|
||||
pub write_kind: AiStructuredWriteKind,
|
||||
pub command_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub revision: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AiStructuredWriteKind {
|
||||
SummaryNode,
|
||||
AiNoteNode,
|
||||
ReferenceEdge,
|
||||
PageBody,
|
||||
}
|
||||
@@ -20,7 +20,8 @@ pub use model::{
|
||||
};
|
||||
pub use tiptap::{
|
||||
EditorBlockDocumentTiptapBridge, EditorBlockDocumentTiptapError, TiptapBlockType,
|
||||
TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapNode, TiptapTaskItemAttrs,
|
||||
TiptapBlockquoteAttrs, TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapListAttrs,
|
||||
TiptapListItemAttrs, TiptapMark, TiptapNode, TiptapParagraphAttrs, TiptapTaskItemAttrs,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
pub mod command;
|
||||
pub mod common;
|
||||
pub mod ai;
|
||||
pub mod editor;
|
||||
pub mod governance;
|
||||
pub mod kernel;
|
||||
pub mod mindmap;
|
||||
pub mod page_aggregate;
|
||||
pub mod query;
|
||||
pub mod search;
|
||||
pub mod tool;
|
||||
|
||||
pub use ai::{
|
||||
AiEvent, AiEventKind, AiRuntimeOwner, AiSession, AiStructuredWriteKind,
|
||||
AiStructuredWriteResult, AiToolCall,
|
||||
};
|
||||
pub use command::{
|
||||
CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
|
||||
DeleteBlock, DeleteDocumentPage, DuplicateDocumentPage, EmbedBlock, InsertBlock, MoveBlock,
|
||||
@@ -42,12 +49,23 @@ pub use kernel::{
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
|
||||
};
|
||||
pub use mindmap::{MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode};
|
||||
pub use mindmap::{
|
||||
MindmapCommand, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
|
||||
MindmapProjection, MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner,
|
||||
MindmapTreeNode,
|
||||
};
|
||||
pub use page_aggregate::{
|
||||
PageAggregateProjection, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout,
|
||||
PageOptions, PagePermissions, PageStats, PageTree,
|
||||
};
|
||||
pub use query::{
|
||||
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
|
||||
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks, ListSidebarDataset,
|
||||
QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
|
||||
};
|
||||
pub use search::{
|
||||
SearchHighlight, SearchProjectionOwner, SearchQuery, SearchResultProjection, SearchScope,
|
||||
};
|
||||
pub use tool::{
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
||||
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
||||
@@ -232,4 +250,116 @@ mod tests {
|
||||
assert!(recovery.write_toolset);
|
||||
assert_eq!(recovery.tool_names, &["event_replay", "index_rebuild"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_aggregate_projection_contract_covers_kernel_owned_fields() {
|
||||
let projection = PageAggregateProjection {
|
||||
schema: "mnote.page_aggregate.v1".into(),
|
||||
projection_version: 1,
|
||||
source: PageAggregateSource::KernelProjection,
|
||||
page_id: "page_1".into(),
|
||||
parent_id: Some("root".into()),
|
||||
title: "Rust 页面".into(),
|
||||
path: vec!["root".into(), "page_1".into()],
|
||||
sidebar_tree_membership: vec!["workspace-sidebar".into()],
|
||||
body_ref: Some("page_1:body".into()),
|
||||
layout_options: serde_json::json!({"wideLayout": true}),
|
||||
updated_at: Some("2026-04-29T00:00:00Z".into()),
|
||||
identity: page_aggregate::PageIdentity {
|
||||
document_id: "page_1".into(),
|
||||
workspace_id: "ws_demo".into(),
|
||||
},
|
||||
head: page_aggregate::PageHead {
|
||||
title: "Rust 页面".into(),
|
||||
updated_at: serde_json::json!("2026-04-29T00:00:00Z"),
|
||||
permissions: page_aggregate::PagePermissions {
|
||||
read_only: false,
|
||||
disable_download: false,
|
||||
disable_copy: false,
|
||||
},
|
||||
},
|
||||
layout: page_aggregate::PageLayout {
|
||||
page_options: page_aggregate::PageOptions::default(),
|
||||
},
|
||||
body: page_aggregate::PageBody {
|
||||
content: serde_json::json!([]),
|
||||
revision: serde_json::json!(7),
|
||||
conflict_detection_key: serde_json::json!("page_1:7"),
|
||||
},
|
||||
tree: page_aggregate::PageTree {
|
||||
page_subtree: serde_json::json!({"rootNodeId": "page_1"}),
|
||||
},
|
||||
stats: page_aggregate::PageStats::default(),
|
||||
};
|
||||
|
||||
assert_eq!(projection.source, PageAggregateSource::KernelProjection);
|
||||
assert_eq!(projection.projection_version, 1);
|
||||
assert_eq!(projection.page_id, "page_1");
|
||||
assert_eq!(projection.identity.document_id, "page_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_projection_contract_marks_owner_and_scope() {
|
||||
let result = SearchResultProjection {
|
||||
id: "page_1".into(),
|
||||
title: "Rust 搜索".into(),
|
||||
snippet: "Rust Web".into(),
|
||||
updated_at: Some("2026-04-29T00:00:00Z".into()),
|
||||
created_at: None,
|
||||
match_field: "title".into(),
|
||||
has_ocr: false,
|
||||
public_path: "/documents/page_1".into(),
|
||||
node_id: Some("page_1".into()),
|
||||
subtree_root_id: Some("page_1".into()),
|
||||
evidence: vec![],
|
||||
score: 3.0,
|
||||
projection_owner: SearchProjectionOwner::RustKernel,
|
||||
};
|
||||
let query = SearchQuery {
|
||||
query: "Rust".into(),
|
||||
workspace_id: "ws_demo".into(),
|
||||
scope: SearchScope::Workspace,
|
||||
page_id: None,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
assert_eq!(query.scope, SearchScope::Workspace);
|
||||
assert_eq!(result.projection_owner, SearchProjectionOwner::RustKernel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_protocol_contract_exposes_structured_write_result() {
|
||||
let result = AiStructuredWriteResult {
|
||||
ok: true,
|
||||
write_kind: AiStructuredWriteKind::SummaryNode,
|
||||
command_name: "tree.node.create".into(),
|
||||
target_id: Some("summary_1".into()),
|
||||
revision: Some(2),
|
||||
};
|
||||
|
||||
assert_eq!(result.write_kind, AiStructuredWriteKind::SummaryNode);
|
||||
assert_eq!(result.command_name, "tree.node.create");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mindmap_projection_and_command_contract_cover_kernel_truth() {
|
||||
let projection = MindmapProjection {
|
||||
schema: "mnote.mindmap_projection.v1".into(),
|
||||
map_id: "mind_1".into(),
|
||||
root_node: "root".into(),
|
||||
nodes: vec![],
|
||||
edges: vec![],
|
||||
layout_hints: serde_json::json!({"layout": "right"}),
|
||||
revision: 1,
|
||||
owner: MindmapProjectionOwner::RustKernel,
|
||||
};
|
||||
let command = MindmapCommand::RenameNode {
|
||||
map_id: "mind_1".into(),
|
||||
node_id: "root".into(),
|
||||
title: "新标题".into(),
|
||||
};
|
||||
|
||||
assert_eq!(projection.owner, MindmapProjectionOwner::RustKernel);
|
||||
assert!(matches!(command, MindmapCommand::RenameNode { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,96 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapProjection {
|
||||
pub schema: String,
|
||||
pub map_id: String,
|
||||
pub root_node: String,
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<MindmapProjectionNode>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<MindmapProjectionEdge>,
|
||||
#[serde(default)]
|
||||
pub layout_hints: Value,
|
||||
pub revision: u64,
|
||||
pub owner: MindmapProjectionOwner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapProjectionNode {
|
||||
pub node_id: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapProjectionEdge {
|
||||
pub edge_id: String,
|
||||
pub from_node_id: String,
|
||||
pub to_node_id: String,
|
||||
pub edge_kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum MindmapProjectionOwner {
|
||||
RustKernel,
|
||||
CompatBlob,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum MindmapCommand {
|
||||
CreateNode {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "parentId", alias = "parent_id")]
|
||||
parent_id: String,
|
||||
node: MindmapNodeInput,
|
||||
},
|
||||
RenameNode {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
title: String,
|
||||
},
|
||||
MoveNode {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
#[serde(rename = "newParentId", alias = "new_parent_id")]
|
||||
new_parent_id: String,
|
||||
#[serde(rename = "sortOrder", alias = "sort_order")]
|
||||
sort_order: Option<i64>,
|
||||
},
|
||||
DeleteNode {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
},
|
||||
SetLayout {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "layoutHints", alias = "layout_hints")]
|
||||
layout_hints: Value,
|
||||
},
|
||||
AttachPageRef {
|
||||
#[serde(rename = "mapId", alias = "map_id")]
|
||||
map_id: String,
|
||||
#[serde(rename = "nodeId", alias = "node_id")]
|
||||
node_id: String,
|
||||
#[serde(rename = "pageId", alias = "page_id")]
|
||||
page_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MindmapNodeRef {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PageAggregateSource {
|
||||
KernelProjection,
|
||||
CompatMetaContentJoin,
|
||||
Fixture,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageAggregateProjection {
|
||||
pub schema: String,
|
||||
pub projection_version: u32,
|
||||
pub source: PageAggregateSource,
|
||||
pub page_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<String>,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub path: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub sidebar_tree_membership: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body_ref: Option<String>,
|
||||
pub layout_options: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
pub identity: PageIdentity,
|
||||
pub head: PageHead,
|
||||
pub layout: PageLayout,
|
||||
pub body: PageBody,
|
||||
pub tree: PageTree,
|
||||
pub stats: PageStats,
|
||||
}
|
||||
|
||||
impl PageAggregateProjection {
|
||||
pub const SCHEMA: &'static str = "mnote.page_aggregate.v1";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
pub fn source_label(&self) -> &'static str {
|
||||
match self.source {
|
||||
PageAggregateSource::KernelProjection => "rust-kernel",
|
||||
PageAggregateSource::CompatMetaContentJoin => "compat-join",
|
||||
PageAggregateSource::Fixture => "fixture",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageIdentity {
|
||||
pub document_id: String,
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageHead {
|
||||
pub title: String,
|
||||
pub updated_at: Value,
|
||||
pub permissions: PagePermissions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PagePermissions {
|
||||
pub read_only: bool,
|
||||
pub disable_download: bool,
|
||||
pub disable_copy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageLayout {
|
||||
pub page_options: PageOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageOptions {
|
||||
pub wide_layout: bool,
|
||||
pub small_text: bool,
|
||||
pub layout_density: String,
|
||||
pub show_heading_numbers: bool,
|
||||
pub show_toc: bool,
|
||||
pub show_structure: bool,
|
||||
pub protect_editing: bool,
|
||||
pub show_word_count: bool,
|
||||
pub collapse_backlinks: bool,
|
||||
pub page_font: String,
|
||||
pub hide_child_pages: bool,
|
||||
pub show_block_ref_count: bool,
|
||||
pub embed_default_block_id: Value,
|
||||
}
|
||||
|
||||
impl Default for PageOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wide_layout: false,
|
||||
small_text: false,
|
||||
layout_density: "normal".into(),
|
||||
show_heading_numbers: true,
|
||||
show_toc: false,
|
||||
show_structure: false,
|
||||
protect_editing: false,
|
||||
show_word_count: true,
|
||||
collapse_backlinks: false,
|
||||
page_font: "default".into(),
|
||||
hide_child_pages: false,
|
||||
show_block_ref_count: false,
|
||||
embed_default_block_id: Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageBody {
|
||||
pub content: Value,
|
||||
pub revision: Value,
|
||||
pub conflict_detection_key: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageTree {
|
||||
pub page_subtree: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageStats {
|
||||
pub word_count: u64,
|
||||
pub character_count: u64,
|
||||
pub block_count: u64,
|
||||
pub todo_total: u64,
|
||||
pub todo_done: u64,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchQuery {
|
||||
pub query: String,
|
||||
pub workspace_id: String,
|
||||
pub scope: SearchScope,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_id: Option<String>,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchScope {
|
||||
Workspace,
|
||||
CurrentPage,
|
||||
Pinned,
|
||||
Recent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResultProjection {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub snippet: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
pub match_field: String,
|
||||
pub has_ocr: bool,
|
||||
pub public_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subtree_root_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub evidence: Vec<SearchHighlight>,
|
||||
pub score: f64,
|
||||
pub projection_owner: SearchProjectionOwner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchHighlight {
|
||||
pub kind: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<String>,
|
||||
pub snippet: String,
|
||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||
pub meta: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SearchProjectionOwner {
|
||||
RustKernel,
|
||||
CompatIndex,
|
||||
}
|
||||
Reference in New Issue
Block a user