v29 第 29 批(9 条):日程定位/复选框提示/名册错乱三处真因修复 + 读书落盘 + 导航四设置 + 笔记阅读态与共享
前 4 条是"上批改了但用户仍报"的,这次先拿真机证据再动手,三处真因如下:
1. 首页没定位到红线 —— 真根因:定位脚本的 PHP 条件是
`if (!$todayEvents || empty($timed))`,**今天只有全天日程时整段脚本不渲染**
(实测 hasScript=False、scrollTop=0),而 .ag-grid(0–24h) 与红线都在。
有分时段日程时定位其实完全正确(偏差 0px)。已改成 `if (!$todayEvents)`。
2. 任务复选框提示还在 —— 真根因:`_task_check.php` 里那句无障碍文字
`<span class="visually-hidden">` 用的类名**在 app.css 里从来没有定义过**
(项目一直只用 .sr-only)→ 没有规则 = 普通 span = 直接可见。
已把两个类名合并到同一条规则(clip 隐藏,仍可读屏)。
3. 名册完全错乱 —— 真根因:v29 重写平铺列表时**漏了一个 `</div>`**(闭合 .person),
浏览器把下一张卡嵌进上一张里,实测 .person-main 只剩 24~151px(本该 ~800px)。
`php -l` 与全部静态断言都抓不到,只有真机量 DOM 才现形。已补上闭合。
另把 v28 引入的多列 grid 改回单列(它与 content-visibility 冲突)+窄屏动作区折行。
4. 读书慢 —— 按用户给的思路「做一个同步,然后落盘」:
新增 weread_snapshots 表 + App\WeReadCache(按人+数据集存 JSON);
渲染路径改走 notebooksCached()/readStatsCached()(永不联网);
同步从"渲染之前"挪到"**响应之后**"(queueBackgroundSync + fastcgi_finish_request)。
5. 导航设置改成右上角**下拉菜单 + 四视图**(addr/general/board/engine),
表单带 back 域 → 保存后回原来那一项。
🔴 顺带修掉一个真 bug:`if (!$inSettings && $canWrite)` 的 endif 漂到了文件末尾,
把 board/engine 两个视图也包进了"非设置态" → 打开时标题对、**内容是空的**。
6. 笔记列表缩略图:cover → contain + 固定高度(按比例完整显示,不裁切)。
7. 笔记段落编辑器:加 Markdown 工具条 + 斜线命令(行首打 /co → 回车补全代码块),
两者共用同一份命令表。
8. 笔记点进来默认**阅读态**(图文混排、附件在最下方、图片四档宽度、点「编辑」才可改);
清单块并入 Markdown —— 但**历史 list 块转写成 `- 项` 文本,老数据不丢**。
9. 笔记共享改成**逐人 + 权限**(note_shares,与 resource_shares 同构);
可见性收敛成全站唯一的 Note::VIS_COND;迁移用一次性哨兵。
This commit is contained in:
+234
-31
@@ -43,8 +43,15 @@ final class Note
|
||||
/** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */
|
||||
public const ATTACH = 'note';
|
||||
|
||||
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */
|
||||
public const BLOCK_KINDS = ['text', 'image', 'list'];
|
||||
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text)
|
||||
*
|
||||
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
|
||||
* 把段落做的完善一点就可以了。」
|
||||
* ⇒ 收成 `text` / `image` 两种。
|
||||
* ⚠️ 老库里可能还有 `kind = 'list'` 的行 —— **不要**把它们当非法值删掉,
|
||||
* 见 `cleanBlocks()` 里对历史 list 块的**转写**(转成 Markdown 列表文本)。
|
||||
* 白名单只决定"新写入时允许什么",不决定"老数据怎么活"。 */
|
||||
public const BLOCK_KINDS = ['text', 'image'];
|
||||
|
||||
/**
|
||||
* 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类)
|
||||
@@ -59,6 +66,22 @@ final class Note
|
||||
/** 列表页默认显示多少条(超过给"加载更多") */
|
||||
public const LIST_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* **可见性的唯一判据**(v29 第 9 条)。
|
||||
*
|
||||
* 「这条笔记是我写的」**或**「有一条指向我的共享记录」。
|
||||
* ⚠️ 全站(列表 / 计数 / 详情 / 标签云)都拼这**同一个**串 ——
|
||||
* 各写各的迟早出现"列表里看得到、点进去 404"这种鬼故事。
|
||||
* ⚠️ 绑定参数顺序:调用方在它**前面**绑 `$viewer`(对应 n.person_id)。
|
||||
* ⚠️ 用 EXISTS 而不是 JOIN:共享给多个人时 JOIN 会让同一条笔记出现多次。
|
||||
*/
|
||||
public const VIS_COND =
|
||||
'(n.person_id = ? OR EXISTS (SELECT 1 FROM note_shares s
|
||||
WHERE s.note_id = n.id AND s.person_id = ?))';
|
||||
|
||||
/** 共享权限的白名单(与 resources 同一套语义) */
|
||||
public const PERMS = ['read', 'write'];
|
||||
|
||||
// =====================================================================
|
||||
// 读
|
||||
// =====================================================================
|
||||
@@ -81,12 +104,22 @@ final class Note
|
||||
int $limit = 0,
|
||||
int $offset = 0
|
||||
): array {
|
||||
/* 🔴 v29 用户第 9 条:可见性判据换成**逐人共享**:
|
||||
「我写的」**或**「有一条指向我的共享记录」。
|
||||
⚠️ 这是全站唯一的可见性定义(列表 / 计数 / 详情 / 标签云都读它)——
|
||||
v27 那个 `is_shared = 1`(共享给全家)已不再是判据,
|
||||
升级时它被展开成逐人记录(见 Schema 的迁移)。
|
||||
⚠️ 用 EXISTS 而不是 JOIN `note_shares`:JOIN 会让"共享给 3 个人"的
|
||||
笔记在列表里出现 3 次(每条共享一行)。EXISTS 只判"有没有"。 */
|
||||
$vis = self::VIS_COND;
|
||||
$sql = 'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color,
|
||||
CASE WHEN n.person_id = ? THEN 1 ELSE 0 END AS is_mine
|
||||
FROM notes n
|
||||
JOIN persons p ON p.id = n.person_id
|
||||
WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)';
|
||||
$bind = [$viewer, $familyId, $viewer];
|
||||
WHERE n.family_id = ? AND ' . $vis;
|
||||
// ⚠️ VIS_COND 里有**两个** `?`(可见性判据里 person_id 出现两次),
|
||||
// 所以 $viewer 要绑两遍。漏一个会让后面的关键词绑定整体错位。
|
||||
$bind = [$viewer, $familyId, $viewer, $viewer];
|
||||
|
||||
$kw = trim($keyword);
|
||||
if ($kw !== '') {
|
||||
@@ -123,8 +156,8 @@ final class Note
|
||||
public static function countVisible(int $familyId, int $viewer, string $keyword = '', string $tag = ''): int
|
||||
{
|
||||
$sql = 'SELECT COUNT(*) FROM notes n
|
||||
WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)';
|
||||
$bind = [$familyId, $viewer];
|
||||
WHERE n.family_id = ? AND ' . self::VIS_COND;
|
||||
$bind = [$familyId, $viewer, $viewer];
|
||||
$kw = trim($keyword);
|
||||
if ($kw !== '') {
|
||||
$sql .= ' AND (n.title LIKE ?
|
||||
@@ -162,10 +195,7 @@ final class Note
|
||||
"SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id"
|
||||
);
|
||||
foreach ($st->fetchAll() as $b) {
|
||||
// v28:text 块同时算好 Markdown 渲染结果(列表页要显示富文本)
|
||||
$b['html'] = (string)$b['kind'] === 'text'
|
||||
? Markdown::render((string)$b['content'])
|
||||
: '';
|
||||
$b['html'] = self::blockHtml((string)$b['kind'], (string)$b['content']);
|
||||
$blocks[(int)$b['note_id']][] = $b;
|
||||
}
|
||||
|
||||
@@ -228,9 +258,9 @@ final class Note
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color
|
||||
FROM notes n JOIN persons p ON p.id = n.person_id
|
||||
WHERE n.id = ? AND n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)'
|
||||
);
|
||||
$st->execute([$id, $familyId, $viewer]);
|
||||
WHERE n.id = ? AND n.family_id = ? AND ' . self::VIS_COND);
|
||||
// ⚠️ VIS_COND 两个 `?`,$viewer 绑两遍(顺序:id / family / person_id / person_id)
|
||||
$st->execute([$id, $familyId, $viewer, $viewer]);
|
||||
$r = $st->fetch();
|
||||
if (!$r) {
|
||||
return null;
|
||||
@@ -296,11 +326,7 @@ final class Note
|
||||
$st->execute([$noteId]);
|
||||
$rows = $st->fetchAll();
|
||||
foreach ($rows as &$r) {
|
||||
$kind = (string)$r['kind'];
|
||||
// 只有段落支持 Markdown(清单走自己的渲染、图片走 media)
|
||||
$r['html'] = $kind === 'text'
|
||||
? Markdown::render((string)$r['content'])
|
||||
: '';
|
||||
$r['html'] = self::blockHtml((string)$r['kind'], (string)$r['content']);
|
||||
}
|
||||
unset($r);
|
||||
return $rows;
|
||||
@@ -329,10 +355,10 @@ final class Note
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT t.tag, COUNT(DISTINCT n.id) AS n
|
||||
FROM note_tags t JOIN notes n ON n.id = t.note_id
|
||||
WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)
|
||||
WHERE n.family_id = ? AND ' . self::VIS_COND . '
|
||||
GROUP BY t.tag ORDER BY n DESC, t.tag ASC'
|
||||
);
|
||||
$st->execute([$familyId, $viewer]);
|
||||
$st->execute([$familyId, $viewer, $viewer]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
@@ -369,13 +395,15 @@ final class Note
|
||||
return 0;
|
||||
}
|
||||
$db = Database::get();
|
||||
// ⚠️ v29:新建时**一律不共享**(`is_shared` 恒 0)——
|
||||
// 共享改成"保存完在详情页选人 + 权限"(与资源同一个交互),
|
||||
// 新建表单里不再有那个勾选框。共享表记录由 setShare 单独维护。
|
||||
$db->prepare(
|
||||
'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at)
|
||||
VALUES (?,?,?,?,NOW(),NOW())'
|
||||
VALUES (?,?,?,0,NOW(),NOW())'
|
||||
)->execute([
|
||||
$familyId, $personId,
|
||||
mb_substr($title, 0, 200, 'UTF-8'),
|
||||
!empty($d['is_shared']) ? 1 : 0,
|
||||
]);
|
||||
$id = (int)$db->lastInsertId();
|
||||
self::writeBlocks($id, $blocks);
|
||||
@@ -386,15 +414,22 @@ final class Note
|
||||
}
|
||||
|
||||
/**
|
||||
* 改一条笔记。**仅作者本人可改**(共享出去只给别人看,不给别人改)。
|
||||
* @return bool 是否改到了(改不到 = 不是我的/不存在)
|
||||
* 改一条笔记。
|
||||
*
|
||||
* 🔴 v29 用户第 9 条:改成**有 write 权限就能改** —— 用户要的是
|
||||
* 「和资源类似…选择是只读还是可以读写」,那就得让"可读写"真的能写。
|
||||
* v27 的"仅作者可改"(共享出去只给别人看)是当时只有"共享给全家"这一个
|
||||
* 开关时的简化;现在有了逐人权限,就该按权限来。
|
||||
* ⚠️ 删除**仍然只限作者**(见 destroy):删是不可逆的,
|
||||
* "可读写"给的是编辑权,不是销毁权。这条差异要显式写在按钮上。
|
||||
* @return bool 是否改到了(改不到 = 看不到 / 没有写权限)
|
||||
*/
|
||||
public static function update(int $familyId, int $id, int $personId, array $d): bool
|
||||
{
|
||||
$db = Database::get();
|
||||
$st = $db->prepare('SELECT id FROM notes WHERE id = ? AND family_id = ? AND person_id = ?');
|
||||
$st->execute([$id, $familyId, $personId]);
|
||||
if (!$st->fetchColumn()) {
|
||||
// 先按**可见性**取出来(含 family 校验),再用 permOf 判能不能改
|
||||
$note = self::find($familyId, $id, $personId);
|
||||
if (!$note || !self::canWrite($note, $personId)) {
|
||||
return false;
|
||||
}
|
||||
$title = trim((string)($d['title'] ?? ''));
|
||||
@@ -404,9 +439,11 @@ final class Note
|
||||
if ($title === '' && !$blocks) {
|
||||
$title = '(空笔记)';
|
||||
}
|
||||
$db->prepare(
|
||||
'UPDATE notes SET title = ?, is_shared = ?, updated_at = NOW() WHERE id = ?'
|
||||
)->execute([mb_substr($title, 0, 200, 'UTF-8'), !empty($d['is_shared']) ? 1 : 0, $id]);
|
||||
// ⚠️ 不再写 `is_shared`:它的语义已被 note_shares 取代(逐人 + 权限)。
|
||||
// 老列留着不动(升级迁移时读过它一次),但**不再由这里改** ——
|
||||
// 否则会出现"列里写着 1、共享表里却没有记录"这种自相矛盾的状态。
|
||||
$db->prepare('UPDATE notes SET title = ?, updated_at = NOW() WHERE id = ?')
|
||||
->execute([mb_substr($title, 0, 200, 'UTF-8'), $id]);
|
||||
// 块与标签都是"整组替换":与 trip_nodes 的错误清单不同,笔记的块没有历史价值,
|
||||
// 全量重写最简单可靠(不会出现"删掉的块下一轮又回来")。
|
||||
self::writeBlocks($id, $blocks);
|
||||
@@ -455,6 +492,95 @@ final class Note
|
||||
)->execute([$familyId]);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 共享(v29 用户第 9 条)—— 与 App\Resource 同一套语义
|
||||
// =====================================================================
|
||||
|
||||
/** 这条笔记共享给了谁(带权限与对方的名字/头像) */
|
||||
public static function shares(int $noteId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color
|
||||
FROM note_shares s JOIN persons p ON p.id = s.person_id
|
||||
WHERE s.note_id = ? ORDER BY p.id'
|
||||
);
|
||||
$st->execute([$noteId]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
/** 一条笔记共享给了几个人的映射(列表页一次性取,避免每条一次查询) */
|
||||
public static function shareCounts(array $noteIds): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', $noteIds)));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$in = implode(',', $ids);
|
||||
$out = [];
|
||||
foreach (Database::get()->query(
|
||||
"SELECT note_id, COUNT(*) AS n FROM note_shares
|
||||
WHERE note_id IN ($in) GROUP BY note_id"
|
||||
)->fetchAll() as $r) {
|
||||
$out[(int)$r['note_id']] = (int)$r['n'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设共享。
|
||||
*
|
||||
* ⚠️ 作者本人**不进**共享表 —— 他本来就全能,
|
||||
* 写进去只会让"共享给谁"的列表里冒出自己(同 Resource::setShare)。
|
||||
*/
|
||||
public static function setShare(int $noteId, int $ownerId, int $personId, string $perm): void
|
||||
{
|
||||
if ($personId <= 0 || $personId === $ownerId) {
|
||||
self::dropShare($noteId, $personId);
|
||||
return;
|
||||
}
|
||||
Database::get()->prepare(
|
||||
'INSERT INTO note_shares (note_id, person_id, perm) VALUES (?,?,?)
|
||||
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
|
||||
)->execute([$noteId, $personId, in_array($perm, self::PERMS, true) ? $perm : 'read']);
|
||||
}
|
||||
|
||||
public static function dropShare(int $noteId, int $personId): void
|
||||
{
|
||||
Database::get()->prepare('DELETE FROM note_shares WHERE note_id = ? AND person_id = ?')
|
||||
->execute([$noteId, $personId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我对某条笔记的权限:'owner' / 'write' / 'read' / ''(看不到)。
|
||||
*
|
||||
* ⚠️ 这是**唯一**的权限判定(页面按钮、服务端写入校验都读它)——
|
||||
* 两处各写一套的话,迟早出现"按钮没显示但接口能改"(或者反过来)。
|
||||
*/
|
||||
public static function permOf(array $note, int $viewer): string
|
||||
{
|
||||
if ((int)$note['person_id'] === $viewer) {
|
||||
return 'owner';
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT perm FROM note_shares WHERE note_id = ? AND person_id = ?'
|
||||
);
|
||||
$st->execute([(int)$note['id'], $viewer]);
|
||||
$p = (string)($st->fetchColumn() ?: '');
|
||||
return in_array($p, self::PERMS, true) ? $p : '';
|
||||
}
|
||||
|
||||
/** 能改吗(作者或 write 权限)。删除**只限作者** —— 见 destroy()。 */
|
||||
public static function canWrite(array $note, int $viewer): bool
|
||||
{
|
||||
$p = self::permOf($note, $viewer);
|
||||
return $p === 'owner' || $p === 'write';
|
||||
}
|
||||
|
||||
public static function permWord(string $perm): string
|
||||
{
|
||||
return $perm === 'owner' ? '本人' : ($perm === 'write' ? '可读写' : '只读');
|
||||
}
|
||||
|
||||
/** 删除(仅作者本人)。块/标签/附件靠外键 ON DELETE CASCADE 一起走。 */
|
||||
public static function destroy(int $familyId, int $id, int $personId): void
|
||||
{
|
||||
@@ -498,13 +624,83 @@ final class Note
|
||||
// 内部:块 / 标签的清洗与写入
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* 一个块的渲染结果(HTML)。**全站唯一**的块渲染入口。
|
||||
*
|
||||
* 🔴 v29 第 8 条:清单块并入 Markdown 之后,这里要**同时认老数据**:
|
||||
* 库里可能还躺着 `kind = 'list'` 的块(v27/v28 写的),
|
||||
* 用户没编辑过它们 → 仍是 list → 必须照样渲染出列表,不能变成空白。
|
||||
* ⚠️ 走同一套 `listToMarkdown` 转换(与 cleanBlocks 共用),
|
||||
* 这样"老块在页面上"与"老库保存一次之后的文本"长得完全一样。
|
||||
*
|
||||
* @return string 图片块返回 ''(图片不用 HTML,由模板直接出 <img>)
|
||||
*/
|
||||
public static function blockHtml(string $kind, string $content): string
|
||||
{
|
||||
if ($kind === 'image') {
|
||||
return '';
|
||||
}
|
||||
if ($kind === 'list') {
|
||||
// 历史清单块 → 转成 Markdown 列表再渲染(数据不丢,样子不变)
|
||||
return Markdown::render(self::listToMarkdown($content));
|
||||
}
|
||||
return Markdown::render($content);
|
||||
}
|
||||
|
||||
/** 历史清单块 → Markdown 文本的**对外**入口(模板把老块放进编辑器时用)。
|
||||
*
|
||||
* ⚠️ 为什么要暴露它:编辑态要把"库里的块"变成"编辑器里的文本",
|
||||
* 而 list 块的转换逻辑只该有一份(`listToMarkdown`)。
|
||||
* 模板直接调它,比在模板里再写一遍正则可靠得多。
|
||||
* @param string $content 清单块的原始内容(每行一项,缩进表层级)
|
||||
*/
|
||||
public static function blockHtmlOut(string $content): string
|
||||
{
|
||||
return self::listToMarkdown($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史清单块 → Markdown 无序列表文本(v29 收掉清单块时用)。
|
||||
*
|
||||
* 约定(v27 定下的):每行一项,**行首缩进表示层级**。
|
||||
* 这里按缩进深度换算成 Markdown 的缩进(同样表层级),最多 3 层 ——
|
||||
* 更深的层级在 20px 的行高里已经看不出来了,硬转反而让源码难读。
|
||||
*/
|
||||
private static function listToMarkdown(string $content): string
|
||||
{
|
||||
$lines = [];
|
||||
foreach (preg_split('/\R/u', $content) ?: [] as $line) {
|
||||
$raw = rtrim($line, " \t");
|
||||
if (trim($raw) === '') {
|
||||
continue;
|
||||
}
|
||||
$indent = strlen($raw) - strlen(ltrim($raw, " \t"));
|
||||
$depth = min(3, (int)floor($indent / 2));
|
||||
$lines[] = str_repeat(' ', $depth) . '- ' . trim($raw);
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗前端提交的块:
|
||||
* · kind 归一(只认白名单里的三种,其它一律当 text)
|
||||
* · kind 归一(只认白名单里的两种,其它一律当 text)
|
||||
* · **历史 list 块转成 Markdown 列表文本**(见下面的说明)
|
||||
* · 内容裁剪(去首尾空白;图片块 content = 附件 key)
|
||||
* · 丢掉**空块**(用户加了块又没填,不该留一堆空段)
|
||||
* · 上限 MAX_BLOCKS
|
||||
*
|
||||
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
|
||||
* 把段落做的完善一点就可以了。」
|
||||
* ⇒ 块类型收成 `text` / `image` 两种。
|
||||
* ⚠️ **但绝不丢历史数据**:老库里已经有 `kind = 'list'` 的块。
|
||||
* 直接不再认它 → 那些块的正文会在用户下次保存时**静默消失**
|
||||
* (最糟的一种数据丢失:用户什么都没做,内容自己没了)。
|
||||
* 所以这里把 list 块**转写**成 Markdown 的无序列表(每个非空行前加 `- `),
|
||||
* 并归一成 text 块 —— 渲染出来长得一模一样(`- x` 就是列表),
|
||||
* 而用户以后可以在它上面用 Markdown 继续写。
|
||||
* ⚠️ 列表的**层级**也保留:原来"行首缩进"表示层级,转写时按
|
||||
* 缩进深度换算成 Markdown 的缩进(Markdown 同样是缩进表层级)。
|
||||
*
|
||||
* @return array<int,array{kind:string,content:string}>
|
||||
*/
|
||||
private static function cleanBlocks($raw): array
|
||||
@@ -522,6 +718,13 @@ final class Note
|
||||
$kind = 'text';
|
||||
}
|
||||
$content = (string)($b['content'] ?? '');
|
||||
|
||||
// ---- 历史 list 块 → Markdown 无序列表(v29,见上面的说明)----
|
||||
if ($kind === 'list') {
|
||||
$content = self::listToMarkdown($content);
|
||||
$kind = 'text';
|
||||
}
|
||||
|
||||
// 图片块 content 是附件 key:不允许换行/首尾空白(它要当文件名查)
|
||||
$content = $kind === 'image'
|
||||
? trim(preg_replace('/\s+/', '', $content) ?? '')
|
||||
|
||||
Reference in New Issue
Block a user