mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-15 07:42:28 +08:00
feat(workspace): add full-text search for daily memory files
Add backend search command that performs case-insensitive matching across all daily memory files, supporting both date and content queries. Frontend includes animated search bar (⌘F), debounced input, snippet display with match count badge, and search state preservation across edits.
This commit is contained in:
@@ -147,6 +147,141 @@ pub async fn write_daily_memory_file(filename: String, content: String) -> Resul
|
||||
.map_err(|e| format!("Failed to write daily memory file {filename}: {e}"))
|
||||
}
|
||||
|
||||
/// Find the largest index `<= i` that is a valid UTF-8 char boundary.
|
||||
/// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91).
|
||||
fn floor_char_boundary(s: &str, mut i: usize) -> usize {
|
||||
if i >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
while !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Find the smallest index `>= i` that is a valid UTF-8 char boundary.
|
||||
/// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91).
|
||||
fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
|
||||
if i >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
while !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Search result for daily memory full-text search.
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DailyMemorySearchResult {
|
||||
pub filename: String,
|
||||
pub date: String,
|
||||
pub size_bytes: u64,
|
||||
pub modified_at: u64,
|
||||
pub snippet: String,
|
||||
pub match_count: usize,
|
||||
}
|
||||
|
||||
/// Full-text search across all daily memory files.
|
||||
///
|
||||
/// Performs case-insensitive search on both the date field and file content.
|
||||
/// Returns results sorted by filename descending (newest first), each with a
|
||||
/// snippet showing ~120 characters of context around the first match.
|
||||
#[tauri::command]
|
||||
pub async fn search_daily_memory_files(
|
||||
query: String,
|
||||
) -> Result<Vec<DailyMemorySearchResult>, String> {
|
||||
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
|
||||
|
||||
if !memory_dir.exists() || query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results: Vec<DailyMemorySearchResult> = Vec::new();
|
||||
|
||||
let entries = std::fs::read_dir(&memory_dir)
|
||||
.map_err(|e| format!("Failed to read memory directory: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) if m.is_file() => m,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let date = name.trim_end_matches(".md").to_string();
|
||||
let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
|
||||
let content_lower = content.to_lowercase();
|
||||
|
||||
// Count matches in content
|
||||
let content_matches: Vec<usize> = content_lower
|
||||
.match_indices(&query_lower)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
// Also check date field
|
||||
let date_matches = date.to_lowercase().contains(&query_lower);
|
||||
|
||||
if content_matches.is_empty() && !date_matches {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build snippet around first content match (~120 chars of context)
|
||||
let snippet = if let Some(&first_pos) = content_matches.first() {
|
||||
let start = if first_pos > 50 {
|
||||
floor_char_boundary(&content, first_pos - 50)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len()));
|
||||
let mut s = String::new();
|
||||
if start > 0 {
|
||||
s.push_str("...");
|
||||
}
|
||||
s.push_str(&content[start..end]);
|
||||
if end < content.len() {
|
||||
s.push_str("...");
|
||||
}
|
||||
s
|
||||
} else {
|
||||
// Date-only match — use beginning of file as preview
|
||||
let end = ceil_char_boundary(&content, 120.min(content.len()));
|
||||
let mut s = content[..end].to_string();
|
||||
if end < content.len() {
|
||||
s.push_str("...");
|
||||
}
|
||||
s
|
||||
};
|
||||
|
||||
let size_bytes = meta.len();
|
||||
let modified_at = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
results.push(DailyMemorySearchResult {
|
||||
filename: name,
|
||||
date,
|
||||
size_bytes,
|
||||
modified_at,
|
||||
snippet,
|
||||
match_count: content_matches.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by filename descending (newest date first)
|
||||
results.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Delete a daily memory file (idempotent).
|
||||
#[tauri::command]
|
||||
pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {
|
||||
|
||||
@@ -1051,6 +1051,7 @@ pub fn run() {
|
||||
commands::read_daily_memory_file,
|
||||
commands::write_daily_memory_file,
|
||||
commands::delete_daily_memory_file,
|
||||
commands::search_daily_memory_files,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Calendar, Trash2, Plus } from "lucide-react";
|
||||
import { Calendar, Trash2, Plus, Search, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import { workspaceApi, type DailyMemoryFileInfo } from "@/lib/api/workspace";
|
||||
import {
|
||||
workspaceApi,
|
||||
type DailyMemoryFileInfo,
|
||||
type DailyMemorySearchResult,
|
||||
} from "@/lib/api/workspace";
|
||||
|
||||
interface DailyMemoryPanelProps {
|
||||
isOpen: boolean;
|
||||
@@ -46,6 +58,16 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
// Delete state
|
||||
const [deletingFile, setDeletingFile] = useState<string | null>(null);
|
||||
|
||||
// Search state
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<DailyMemorySearchResult[]>(
|
||||
[],
|
||||
);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Dark mode
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
@@ -61,6 +83,100 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Whether we are in active search mode (search open with a non-empty term)
|
||||
const isActiveSearch = useMemo(
|
||||
() => isSearchOpen && searchTerm.trim().length > 0,
|
||||
[isSearchOpen, searchTerm],
|
||||
);
|
||||
|
||||
// Debounced search execution
|
||||
const executeSearch = useCallback(
|
||||
async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const results = await workspaceApi.searchDailyMemoryFiles(query.trim());
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
console.error("Failed to search daily memory files:", err);
|
||||
toast.error(t("workspace.dailyMemory.searchFailed"));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Handle search input change with debounce
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchTerm(value);
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
void executeSearch(value);
|
||||
}, 300);
|
||||
},
|
||||
[executeSearch],
|
||||
);
|
||||
|
||||
// Open search bar
|
||||
const openSearch = useCallback(() => {
|
||||
setIsSearchOpen(true);
|
||||
// Focus input on next frame
|
||||
requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Close search bar and clear state
|
||||
const closeSearch = useCallback(() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearchTerm("");
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl+F to open search, Escape to close
|
||||
useEffect(() => {
|
||||
if (!isOpen || editingFile) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "f") {
|
||||
e.preventDefault();
|
||||
if (!isSearchOpen) {
|
||||
openSearch();
|
||||
} else {
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape" && isSearchOpen) {
|
||||
e.preventDefault();
|
||||
closeSearch();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, editingFile, isSearchOpen, openSearch, closeSearch]);
|
||||
|
||||
// Clean up debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load file list
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
@@ -142,24 +258,44 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
setEditingFile(null);
|
||||
}
|
||||
await loadFiles();
|
||||
// Re-trigger search if active
|
||||
if (isSearchOpen && searchTerm.trim()) {
|
||||
void executeSearch(searchTerm);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete daily memory file:", err);
|
||||
toast.error(t("workspace.dailyMemory.deleteFailed"));
|
||||
setDeletingFile(null);
|
||||
}
|
||||
}, [deletingFile, editingFile, loadFiles, t]);
|
||||
}, [
|
||||
deletingFile,
|
||||
editingFile,
|
||||
loadFiles,
|
||||
t,
|
||||
isSearchOpen,
|
||||
searchTerm,
|
||||
executeSearch,
|
||||
]);
|
||||
|
||||
// Back from edit mode to list mode
|
||||
// Back from edit mode to list mode — preserve search state
|
||||
const handleBackToList = useCallback(() => {
|
||||
setEditingFile(null);
|
||||
setContent("");
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
// Re-trigger search if active (file content may have changed)
|
||||
if (isSearchOpen && searchTerm.trim()) {
|
||||
void executeSearch(searchTerm);
|
||||
}
|
||||
}, [loadFiles, isSearchOpen, searchTerm, executeSearch]);
|
||||
|
||||
// Close panel entirely
|
||||
// Close panel entirely — clear search state
|
||||
const handleClose = useCallback(() => {
|
||||
setEditingFile(null);
|
||||
setContent("");
|
||||
setIsSearchOpen(false);
|
||||
setSearchTerm("");
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -214,24 +350,137 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Header with path and create button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{/* Header with path, search, and create button */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground shrink-0">
|
||||
~/.openclaw/workspace/memory/
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateToday}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t("workspace.dailyMemory.createToday")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={isSearchOpen ? closeSearch : openSearch}
|
||||
title={t("workspace.dailyMemory.searchScopeHint")}
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateToday}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t("workspace.dailyMemory.createToday")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{loadingList ? (
|
||||
{/* Search bar */}
|
||||
<AnimatePresence>
|
||||
{isSearchOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={t("workspace.dailyMemory.searchPlaceholder")}
|
||||
className="pl-8 pr-8 h-8 text-sm"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => handleSearchChange("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={closeSearch}
|
||||
className="text-xs text-muted-foreground h-8 px-2 shrink-0"
|
||||
>
|
||||
{t("workspace.dailyMemory.searchCloseHint")}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Content: search results or normal file list */}
|
||||
{isActiveSearch ? (
|
||||
// --- Search results ---
|
||||
searching ? (
|
||||
<div className="flex items-center justify-center h-48 text-muted-foreground">
|
||||
{t("workspace.dailyMemory.searching")}
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground gap-3 border-2 border-dashed border-border rounded-xl">
|
||||
<Search className="w-10 h-10 opacity-40" />
|
||||
<p className="text-sm">
|
||||
{t("workspace.dailyMemory.noSearchResults")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{searchResults.map((result) => (
|
||||
<button
|
||||
key={result.filename}
|
||||
onClick={() => openFile(result.filename)}
|
||||
className="w-full flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
|
||||
>
|
||||
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<Calendar className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
{result.date}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(result.sizeBytes)}
|
||||
</span>
|
||||
{result.matchCount > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
|
||||
{t("workspace.dailyMemory.matchCount", {
|
||||
count: result.matchCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result.snippet && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2 whitespace-pre-line">
|
||||
{result.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeletingFile(result.filename);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : // --- Normal file list ---
|
||||
loadingList ? (
|
||||
<div className="flex items-center justify-center h-48 text-muted-foreground">
|
||||
{t("prompts.loading")}
|
||||
</div>
|
||||
|
||||
@@ -1205,7 +1205,14 @@
|
||||
"deleteSuccess": "Daily memory file deleted",
|
||||
"deleteFailed": "Failed to delete daily memory file",
|
||||
"confirmDeleteTitle": "Delete Daily Memory",
|
||||
"confirmDeleteMessage": "Delete the daily memory for {{date}}? This cannot be undone."
|
||||
"confirmDeleteMessage": "Delete the daily memory for {{date}}? This cannot be undone.",
|
||||
"searchPlaceholder": "Search full content...",
|
||||
"searchScopeHint": "Full-text search across all daily memories. ⌘F",
|
||||
"searchCloseHint": "Esc to close",
|
||||
"noSearchResults": "No daily memories match your search.",
|
||||
"searching": "Searching...",
|
||||
"searchFailed": "Search failed",
|
||||
"matchCount": "{{count}} match(es)"
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
|
||||
@@ -1205,7 +1205,14 @@
|
||||
"deleteSuccess": "デイリーメモリーファイルを削除しました",
|
||||
"deleteFailed": "デイリーメモリーファイルの削除に失敗しました",
|
||||
"confirmDeleteTitle": "デイリーメモリーを削除",
|
||||
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。"
|
||||
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。",
|
||||
"searchPlaceholder": "全文検索...",
|
||||
"searchScopeHint": "すべてのデイリーメモリーを全文検索 ⌘F",
|
||||
"searchCloseHint": "Escで閉じる",
|
||||
"noSearchResults": "検索に一致するデイリーメモリーはありません。",
|
||||
"searching": "検索中...",
|
||||
"searchFailed": "検索に失敗しました",
|
||||
"matchCount": "{{count}}件一致"
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
|
||||
@@ -1205,7 +1205,14 @@
|
||||
"deleteSuccess": "每日记忆文件已删除",
|
||||
"deleteFailed": "删除每日记忆文件失败",
|
||||
"confirmDeleteTitle": "删除每日记忆",
|
||||
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。"
|
||||
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。",
|
||||
"searchPlaceholder": "搜索全文内容...",
|
||||
"searchScopeHint": "全文搜索所有每日记忆 ⌘F",
|
||||
"searchCloseHint": "Esc 关闭",
|
||||
"noSearchResults": "没有找到匹配的每日记忆。",
|
||||
"searching": "搜索中...",
|
||||
"searchFailed": "搜索失败",
|
||||
"matchCount": "{{count}} 处匹配"
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
|
||||
@@ -8,6 +8,15 @@ export interface DailyMemoryFileInfo {
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export interface DailyMemorySearchResult {
|
||||
filename: string;
|
||||
date: string;
|
||||
sizeBytes: number;
|
||||
modifiedAt: number;
|
||||
snippet: string;
|
||||
matchCount: number;
|
||||
}
|
||||
|
||||
export const workspaceApi = {
|
||||
async readFile(filename: string): Promise<string | null> {
|
||||
return invoke<string | null>("read_workspace_file", { filename });
|
||||
@@ -32,4 +41,12 @@ export const workspaceApi = {
|
||||
async deleteDailyMemoryFile(filename: string): Promise<void> {
|
||||
return invoke<void>("delete_daily_memory_file", { filename });
|
||||
},
|
||||
|
||||
async searchDailyMemoryFiles(
|
||||
query: string,
|
||||
): Promise<DailyMemorySearchResult[]> {
|
||||
return invoke<DailyMemorySearchResult[]>("search_daily_memory_files", {
|
||||
query,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user