feat(commands): add /change slash command to display latest CHANGELOG entry
`/change` reads the most recent `## [version]` section from the workspace `CHANGELOG.md` (or the bundled release-notes copy when no workspace changelog is available) and renders it inline in the TUI. On non-English locales the command also queues a model-side translation request so localised users see the changelog text in their UI language; with no API key configured, the offline path returns the section verbatim with a brief explanatory header. Lets users discover what changed in the version they just upgraded into without leaving the chat — and keeps the v0.8.32 release-notes flow consistent with `deepseek update`'s newly-fixed sibling-TUI refresh: now both binaries match the version, and `/change` shows what that version actually delivered. Resolved a `clippy::needless_range_loop` warning in the section extractor (idiomatic `iter().enumerate().skip(...).find(...)` instead of an indexed range loop) so the harvest passes the workspace's `-D warnings` clippy gate without touching the contributor's design. Harvested from PR #1416 by @zhuangbiaowei Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,18 @@ have to work with?" — and the answer is now closer to "everything
|
||||
you'd reach for from a shell, including the document formats the
|
||||
real world uses."
|
||||
|
||||
### Added
|
||||
|
||||
- **`/change` slash command** displays the most recent
|
||||
CHANGELOG.md version section from inside the TUI, so users can
|
||||
see what they just upgraded into without leaving the chat
|
||||
(harvested from PR #1416 by **@zhuangbiaowei**). The command
|
||||
works against the bundled release-notes copy when no workspace
|
||||
CHANGELOG is available, and on non-English locales it requests
|
||||
a model-side translation of the section so localised users see
|
||||
the changelog in their UI language. Pure offline fallback when
|
||||
no API key is configured.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`deepseek update` now refreshes the companion TUI binary
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! `/change` command — show the latest changelog entry, translated to the
|
||||
//! user's locale when it is not English.
|
||||
//!
|
||||
//! Usage: `/change`
|
||||
//!
|
||||
//! Uses the DeepSeek-TUI changelog embedded at compile time, extracts the
|
||||
//! most recent version section, and displays it. When the UI locale is not
|
||||
//! English and the current session can reach a model, the command also fires a
|
||||
//! `SendMessage` action that asks the model to translate the changelog into
|
||||
//! the user's language.
|
||||
|
||||
use crate::localization::{Locale, MessageId, tr};
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Maximum length of the changelog excerpt we'll show inline (characters).
|
||||
/// If the changelog section exceeds this, we truncate and show a notice.
|
||||
/// 4096 chars is large enough for most version entries.
|
||||
const MAX_INLINE_CHANGELOG_CHARS: usize = 4096;
|
||||
const DEEPSEEK_TUI_CHANGELOG: &str = include_str!("../../../../CHANGELOG.md");
|
||||
|
||||
/// Execute the `/change` command.
|
||||
pub fn change(app: &mut App) -> CommandResult {
|
||||
let latest_section = match extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return CommandResult::error(
|
||||
"Could not find a version section in the bundled DeepSeek-TUI changelog. \
|
||||
Expected a line starting with `## [`. "
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let locale = app.ui_locale;
|
||||
let header = tr(locale, MessageId::CmdChangeHeader);
|
||||
|
||||
let section_text = inline_changelog_section(&latest_section);
|
||||
|
||||
// If the user's locale is English, just display.
|
||||
// Otherwise, also ask the model to translate.
|
||||
if locale == Locale::En {
|
||||
CommandResult::message(format!(
|
||||
"{header}\n─────────────────────────────\n{section_text}"
|
||||
))
|
||||
} else if app.offline_mode || app.onboarding_needs_api_key {
|
||||
let fallback = tr(locale, MessageId::CmdChangeTranslationUnavailable);
|
||||
CommandResult::message(format!(
|
||||
"{header}\n\
|
||||
─────────────────────────────\n\
|
||||
{fallback}\n\n\
|
||||
{section_text}"
|
||||
))
|
||||
} else {
|
||||
let queued = tr(locale, MessageId::CmdChangeTranslationQueued);
|
||||
let display_text = format!(
|
||||
"{header}\n\
|
||||
─────────────────────────────\n\
|
||||
{queued}\n\n\
|
||||
{section_text}"
|
||||
);
|
||||
let lang_name = match locale {
|
||||
Locale::ZhHans => "Simplified Chinese (中文)",
|
||||
Locale::Ja => "Japanese (日本語)",
|
||||
Locale::PtBr => "Brazilian Portuguese (Português)",
|
||||
// Fallback — should never reach here since we check En above.
|
||||
Locale::En => "English",
|
||||
};
|
||||
|
||||
let translation_prompt = format!(
|
||||
"Translate the following changelog into {lang_name}. \
|
||||
Keep all markdown formatting, version numbers, dates, \
|
||||
contributor names, and code references intact. \
|
||||
Output ONLY the translated changelog, no preamble or commentary.\n\n\
|
||||
{latest_section}"
|
||||
);
|
||||
|
||||
CommandResult::with_message_and_action(
|
||||
display_text,
|
||||
AppAction::SendMessage(translation_prompt),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_changelog_section(section: &str) -> String {
|
||||
if section.len() <= MAX_INLINE_CHANGELOG_CHARS {
|
||||
return section.to_string();
|
||||
}
|
||||
|
||||
let truncated: String = section.chars().take(MAX_INLINE_CHANGELOG_CHARS).collect();
|
||||
format!(
|
||||
"{truncated}\n\
|
||||
\n\
|
||||
[... {} characters omitted from the bundled DeepSeek-TUI changelog]",
|
||||
section.len() - MAX_INLINE_CHANGELOG_CHARS
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract the latest version section from CHANGELOG.md content.
|
||||
///
|
||||
/// Looks for the first `## [version] - date` heading and returns all lines
|
||||
/// from that heading up to the next `## [` heading (or end of file).
|
||||
/// Leading and trailing whitespace is trimmed.
|
||||
fn extract_latest_changelog_section(content: &str) -> Option<String> {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut start_idx: Option<usize> = None;
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("## [") {
|
||||
start_idx = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let start = start_idx?;
|
||||
|
||||
// Find the next `## [` heading (or end)
|
||||
let end = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start + 1)
|
||||
.find(|(_, line)| line.trim().starts_with("## ["))
|
||||
.map_or(lines.len(), |(i, _)| i);
|
||||
|
||||
let section = lines[start..end].join("\n");
|
||||
let section = section.trim().to_string();
|
||||
|
||||
if section.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(section)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::localization::Locale;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
fn make_app(tmpdir: &tempfile::TempDir, locale: Locale, has_api_key: bool) -> App {
|
||||
let mut config = Config::default();
|
||||
if has_api_key {
|
||||
config.api_key = Some("test-key".to_string());
|
||||
}
|
||||
let mut app = App::new(
|
||||
TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
config_path: None,
|
||||
config_profile: None,
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
initial_input: None,
|
||||
},
|
||||
&config,
|
||||
);
|
||||
app.ui_locale = locale;
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_latest_section_finds_first_version() {
|
||||
let content = "\n\
|
||||
## [0.8.26] - 2026-05-09\n\
|
||||
\n\
|
||||
A security + polish release.\n\
|
||||
\n\
|
||||
### Fixed\n\
|
||||
\n\
|
||||
- Fixed something\n\
|
||||
\n\
|
||||
## [0.8.25] - 2026-05-09\n\
|
||||
\n\
|
||||
A stabilization release.\n";
|
||||
let section = extract_latest_changelog_section(content).expect("should find a section");
|
||||
assert!(section.contains("0.8.26"));
|
||||
assert!(section.contains("Fixed something"));
|
||||
assert!(!section.contains("0.8.25"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_latest_section_handles_0_8_29_style_fixture() {
|
||||
let content = "\n\
|
||||
# Changelog\n\
|
||||
\n\
|
||||
## [0.8.29] - 2026-05-11\n\
|
||||
\n\
|
||||
Release candidate polish.\n\
|
||||
\n\
|
||||
### Added\n\
|
||||
- New note-management command.\n\
|
||||
\n\
|
||||
## [0.8.28] - 2026-05-10\n\
|
||||
\n\
|
||||
Previous release.\n";
|
||||
let section = extract_latest_changelog_section(content).expect("should find a section");
|
||||
assert!(section.contains("0.8.29"));
|
||||
assert!(section.contains("2026-05-11"));
|
||||
assert!(section.contains("New note-management command"));
|
||||
assert!(!section.contains("0.8.28"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_latest_section_returns_none_for_empty_content() {
|
||||
assert!(extract_latest_changelog_section("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_latest_section_returns_none_for_no_version_headers() {
|
||||
let content = "# Just a heading\n\nSome text\n";
|
||||
assert!(extract_latest_changelog_section(content).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_latest_section_handles_single_version() {
|
||||
let content = "\n## [0.8.26] - 2026-05-09\n\nOnly one version.\n";
|
||||
let section = extract_latest_changelog_section(content).expect("should find a section");
|
||||
assert!(section.contains("0.8.26"));
|
||||
assert!(section.contains("Only one version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_latest_section_handles_subheadings() {
|
||||
let content = "\n\
|
||||
## [0.8.26] - 2026-05-09\n\
|
||||
\n\
|
||||
### Added\n\
|
||||
- New feature A\n\
|
||||
\n\
|
||||
### Fixed\n\
|
||||
- Fixed bug B\n\
|
||||
\n\
|
||||
## [0.8.25] - 2026-05-09\n\
|
||||
";
|
||||
let section = extract_latest_changelog_section(content).expect("should find a section");
|
||||
assert!(section.contains("New feature A"));
|
||||
assert!(section.contains("Fixed bug B"));
|
||||
assert!(!section.contains("0.8.25"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_uses_bundled_release_notes_without_workspace_changelog() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut app = make_app(&tmp, Locale::En, false);
|
||||
let result = change(&mut app);
|
||||
assert!(!result.is_error);
|
||||
let msg = result.message.expect("should have a message");
|
||||
let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
|
||||
.expect("bundled changelog should have a release section");
|
||||
assert!(msg.contains(expected.lines().next().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_ignores_workspace_changelog() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("CHANGELOG.md"),
|
||||
"\n## [9.9.9] - 2099-01-01\n\nWorkspace changelog.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = make_app(&tmp, Locale::En, false);
|
||||
let result = change(&mut app);
|
||||
assert!(!result.is_error);
|
||||
let msg = result.message.expect("should have a message");
|
||||
assert!(!msg.contains("9.9.9"));
|
||||
assert!(!msg.contains("Workspace changelog"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_in_english_returns_message_without_action() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut app = make_app(&tmp, Locale::En, true);
|
||||
let result = change(&mut app);
|
||||
assert!(!result.is_error);
|
||||
let msg = result.message.expect("should have a message");
|
||||
let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
|
||||
.expect("bundled changelog should have a release section");
|
||||
assert!(msg.contains(expected.lines().next().unwrap()));
|
||||
assert!(
|
||||
result.action.is_none(),
|
||||
"English locale should not send translation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_in_non_english_also_sends_translation_action() {
|
||||
for (locale, _label) in [
|
||||
(Locale::ZhHans, "zh-Hans"),
|
||||
(Locale::Ja, "ja"),
|
||||
(Locale::PtBr, "pt-BR"),
|
||||
] {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut app = make_app(&tmp, locale, true);
|
||||
let result = change(&mut app);
|
||||
assert!(!result.is_error, "Failed for locale {locale:?}");
|
||||
let msg = result.message.expect("should have a message");
|
||||
assert!(msg.contains(tr(locale, MessageId::CmdChangeTranslationQueued)));
|
||||
assert!(
|
||||
matches!(result.action, Some(AppAction::SendMessage(_))),
|
||||
"Non-English locale should send translation, got {:?}",
|
||||
result.action
|
||||
);
|
||||
if let Some(AppAction::SendMessage(prompt)) = &result.action {
|
||||
let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
|
||||
.expect("bundled changelog should have a release section");
|
||||
assert!(prompt.contains(expected.lines().next().unwrap()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_in_non_english_without_api_key_uses_explicit_fallback() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut app = make_app(&tmp, Locale::ZhHans, false);
|
||||
let result = change(&mut app);
|
||||
assert!(!result.is_error);
|
||||
let msg = result.message.expect("should have a message");
|
||||
assert!(msg.contains(tr(
|
||||
Locale::ZhHans,
|
||||
MessageId::CmdChangeTranslationUnavailable
|
||||
)));
|
||||
assert!(
|
||||
result.action.is_none(),
|
||||
"missing API key should not send translation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_in_non_english_offline_uses_explicit_fallback() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut app = make_app(&tmp, Locale::Ja, true);
|
||||
app.offline_mode = true;
|
||||
let result = change(&mut app);
|
||||
assert!(!result.is_error);
|
||||
let msg = result.message.expect("should have a message");
|
||||
assert!(msg.contains(tr(Locale::Ja, MessageId::CmdChangeTranslationUnavailable)));
|
||||
assert!(
|
||||
result.action.is_none(),
|
||||
"offline mode should not send translation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_latest_ignores_lines_before_first_version() {
|
||||
let content = "\n\
|
||||
# Changelog\n\
|
||||
\n\
|
||||
Some intro text.\n\
|
||||
\n\
|
||||
## [0.8.26] - 2026-05-09\n\
|
||||
\n\
|
||||
Content\n\
|
||||
";
|
||||
let section = extract_latest_changelog_section(content).expect("should find a section");
|
||||
assert!(section.contains("0.8.26"));
|
||||
assert!(!section.contains("Changelog"));
|
||||
assert!(!section.contains("intro text"));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
mod anchor;
|
||||
mod attachment;
|
||||
mod change;
|
||||
mod config;
|
||||
mod core;
|
||||
mod cycle;
|
||||
@@ -386,6 +387,12 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
usage: "/diff",
|
||||
description_id: MessageId::CmdDiffDescription,
|
||||
},
|
||||
CommandInfo {
|
||||
name: "change",
|
||||
aliases: &[],
|
||||
usage: "/change",
|
||||
description_id: MessageId::CmdChangeDescription,
|
||||
},
|
||||
CommandInfo {
|
||||
name: "undo",
|
||||
aliases: &[],
|
||||
@@ -558,6 +565,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"tokens" => debug::tokens(app),
|
||||
"cost" => debug::cost(app),
|
||||
"cache" => debug::cache(app, arg),
|
||||
|
||||
// ChangeLog command
|
||||
"change" => change::change(app),
|
||||
"system" => debug::system_prompt(app),
|
||||
"context" | "ctx" => debug::context(app),
|
||||
"edit" => debug::edit(app),
|
||||
|
||||
@@ -217,6 +217,10 @@ pub enum MessageId {
|
||||
CmdAttachDescription,
|
||||
CmdAnchorDescription,
|
||||
CmdCacheDescription,
|
||||
CmdChangeDescription,
|
||||
CmdChangeHeader,
|
||||
CmdChangeTranslationQueued,
|
||||
CmdChangeTranslationUnavailable,
|
||||
CmdClearDescription,
|
||||
CmdCompactDescription,
|
||||
CmdConfigDescription,
|
||||
@@ -498,6 +502,10 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::CmdCacheHeader,
|
||||
MessageId::CmdCacheNoData,
|
||||
MessageId::CmdCacheTotals,
|
||||
MessageId::CmdChangeDescription,
|
||||
MessageId::CmdChangeHeader,
|
||||
MessageId::CmdChangeTranslationQueued,
|
||||
MessageId::CmdChangeTranslationUnavailable,
|
||||
MessageId::CmdCostReport,
|
||||
MessageId::CmdTokensCacheBoth,
|
||||
MessageId::CmdTokensCacheHitOnly,
|
||||
@@ -783,6 +791,14 @@ fn english(id: MessageId) -> &'static str {
|
||||
MessageId::CmdCacheDescription => {
|
||||
"Show DeepSeek prefix-cache hit/miss stats for the last N turns"
|
||||
}
|
||||
MessageId::CmdChangeDescription => "Show the latest changelog entry",
|
||||
MessageId::CmdChangeHeader => "Latest Changelog",
|
||||
MessageId::CmdChangeTranslationQueued => {
|
||||
"English release notes are shown below. A translated version will be requested next; if the provider is unavailable, this English text is the fallback."
|
||||
}
|
||||
MessageId::CmdChangeTranslationUnavailable => {
|
||||
"English release notes are shown below. Translation is unavailable because the current session has no API key or is offline."
|
||||
}
|
||||
MessageId::CmdClearDescription => "Clear conversation history",
|
||||
MessageId::CmdCompactDescription => {
|
||||
"Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)"
|
||||
@@ -1118,6 +1134,14 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdCacheDescription => {
|
||||
"直近 N ターンの DeepSeek プレフィックスキャッシュのヒット/ミス統計を表示"
|
||||
}
|
||||
MessageId::CmdChangeDescription => "最新の更新履歴を表示",
|
||||
MessageId::CmdChangeHeader => "最新の更新履歴",
|
||||
MessageId::CmdChangeTranslationQueued => {
|
||||
"英語のリリースノートを以下に表示します。次に翻訳を依頼します。プロバイダーを利用できない場合は、この英語版がフォールバックです。"
|
||||
}
|
||||
MessageId::CmdChangeTranslationUnavailable => {
|
||||
"英語のリリースノートを以下に表示します。現在のセッションに API キーがないかオフラインのため、翻訳は利用できません。"
|
||||
}
|
||||
MessageId::CmdClearDescription => "会話履歴をクリア",
|
||||
MessageId::CmdCompactDescription => {
|
||||
"コンテキスト圧縮で容量を確保(旧式:v0.6.6 以降はサイクル再起動を推奨)"
|
||||
@@ -1443,6 +1467,14 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdAnchorDescription => "钉选关键事实,在压缩后自动注入上下文",
|
||||
MessageId::CmdAttachDescription => "附加图片或视频媒体;文本文件或目录请使用 @path",
|
||||
MessageId::CmdCacheDescription => "显示最近 N 轮的 DeepSeek 前缀缓存命中/未命中统计",
|
||||
MessageId::CmdChangeDescription => "显示最新的更新日志",
|
||||
MessageId::CmdChangeHeader => "最新更新日志",
|
||||
MessageId::CmdChangeTranslationQueued => {
|
||||
"下面显示英文发布说明。接下来会请求模型翻译;如果当前提供商不可用,这段英文内容就是备用结果。"
|
||||
}
|
||||
MessageId::CmdChangeTranslationUnavailable => {
|
||||
"下面显示英文发布说明。当前会话没有 API Key 或处于离线状态,无法翻译。"
|
||||
}
|
||||
MessageId::CmdClearDescription => "清除对话历史",
|
||||
MessageId::CmdCompactDescription => {
|
||||
"触发上下文压缩以释放空间(旧版命令;v0.6.6 起建议改用循环重启)"
|
||||
@@ -1736,6 +1768,14 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdCacheDescription => {
|
||||
"Exibir estatísticas de hit/miss do cache de prefixo DeepSeek nas últimas N rodadas"
|
||||
}
|
||||
MessageId::CmdChangeDescription => "Mostrar a entrada mais recente do changelog",
|
||||
MessageId::CmdChangeHeader => "Changelog Mais Recente",
|
||||
MessageId::CmdChangeTranslationQueued => {
|
||||
"As notas de versao em ingles aparecem abaixo. Uma versao traduzida sera solicitada em seguida; se o provedor estiver indisponivel, este texto em ingles sera o fallback."
|
||||
}
|
||||
MessageId::CmdChangeTranslationUnavailable => {
|
||||
"As notas de versao em ingles aparecem abaixo. A traducao esta indisponivel porque a sessao atual nao tem chave de API ou esta offline."
|
||||
}
|
||||
MessageId::CmdClearDescription => "Limpar o histórico da conversa",
|
||||
MessageId::CmdCompactDescription => {
|
||||
"Compactar o contexto para liberar espaço (legado; a v0.6.6 prefere o reinício de ciclo)"
|
||||
|
||||
Reference in New Issue
Block a user