feat(web): space avatars with GIF support and live space list sync [CI]
This commit is contained in:
1 parent
96f92c2ba9
commit
ba0b6c258a
14 files changed
+153
-33
No files matched your search
@@ -12,6 +12,8 @@ export interface ChannelInfo {
|
||||
id: string;
|
||||
/** 服务端 channel settings 中的共享名称,加入者可见。 */
|
||||
name?: string;
|
||||
/** 空间头像(data URL,可以是 GIF 动图) */
|
||||
avatar?: string;
|
||||
mode: ChannelMode;
|
||||
role: 'owner' | 'member';
|
||||
maxMembers: number;
|
||||
@@ -51,6 +53,8 @@ export interface ChannelSettingsPatch {
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
/** 空字符串表示移除头像 */
|
||||
avatar?: string;
|
||||
maxMembers?: number;
|
||||
membersCanUpload?: boolean;
|
||||
ttlSeconds?: number;
|
||||
|
||||
@@ -152,6 +152,7 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
|
||||
const body: SettingsRequest = {};
|
||||
if (patch.name !== undefined) body.name = patch.name.trim();
|
||||
if (patch.avatar !== undefined) body.avatar = patch.avatar;
|
||||
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
|
||||
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
|
||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||
|
||||
@@ -10,6 +10,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
|
||||
creatorAccount: raw.creator_account ?? undefined,
|
||||
joinPolicy, passwordRequired: raw.password_required,
|
||||
name: raw.settings.name ?? undefined,
|
||||
avatar: raw.avatar ?? undefined,
|
||||
mode: raw.mode,
|
||||
role: raw.can_manage_structure ? 'owner' : 'member',
|
||||
canUpload: raw.can_upload,
|
||||
|
||||
@@ -146,3 +146,37 @@
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.avatarRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: grid;
|
||||
flex: 0 0 64px;
|
||||
place-items: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-2);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
color: var(--text);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.avatarSide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.avatarButtons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon, type IconName } from '@/ui/Icon';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { AvatarEditor } from '../home/AvatarEditor';
|
||||
import { checkAvatarSource } from '../home/avatarSource';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { RolesEditor } from './RolesEditor';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
|
||||
@@ -42,6 +46,10 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>('basic');
|
||||
const [avatar, setAvatar] = useState(channel.avatar);
|
||||
const [editing, setEditing] = useState<File>();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const spaceName = name.trim() || channel.name || '空间';
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const ttlOptions = withCurrent(info.choices.ttl_seconds, ttlSeconds);
|
||||
@@ -52,7 +60,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
setSaving(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
|
||||
await onSave({ name: name.trim(), ...(avatar !== channel.avatar ? { avatar: avatar ?? '' } : {}), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
|
||||
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
||||
onClose();
|
||||
} catch {
|
||||
@@ -62,7 +70,13 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
}
|
||||
}
|
||||
|
||||
const dirty = name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
|
||||
async function chooseAvatar(file?: File) {
|
||||
if (!file) return;
|
||||
try { await checkAvatarSource(file); setEditing(file); }
|
||||
catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); }
|
||||
}
|
||||
|
||||
const dirty = avatar !== channel.avatar || name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
|
||||
|| ttlSeconds !== channel.ttlSeconds || fileMaxDownloads !== channel.maxDownloads || joinPolicy !== channel.joinPolicy || joinPassword !== '' || clearPassword;
|
||||
const invalid = maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8);
|
||||
|
||||
@@ -74,8 +88,20 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
<Icon name={item.icon} size={18} /><span>{item.label}</span></button>)}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<section className={styles.pane} role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}>
|
||||
{editing ? <AvatarEditor kind="space" file={editing} name={spaceName} onCancel={() => setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} />
|
||||
: <section className={styles.pane} role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}>
|
||||
{tab === 'basic' ? <>
|
||||
<div className={styles.avatarRow}>
|
||||
{avatar ? <img className={styles.avatar} src={avatar} alt="空间头像" /> : <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(spaceName)[0]?.toLocaleUpperCase()}</span>}
|
||||
<div className={styles.avatarSide}>
|
||||
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择空间头像文件" hidden onChange={(event) => { void chooseAvatar(event.target.files?.[0]); event.target.value = ''; }} />
|
||||
<div className={styles.avatarButtons}>
|
||||
<Button icon="image" disabled={!owner} onClick={() => fileInput.current?.click()}>更换空间头像</Button>
|
||||
{avatar && owner ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
|
||||
</div>
|
||||
<span className={styles.note}>PNG、JPEG、WebP 或 GIF 动图,所有成员可见</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className={styles.field}><span>空间名称</span>
|
||||
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:项目组" onChange={(event) => setName(event.target.value)} /></label>
|
||||
<p className={styles.note}>空间名称和设置对加入者及服务端可见。</p>
|
||||
@@ -108,7 +134,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
<p className={styles.note}>空间主及受邀用户无需加入密码。修改规则不会移除已加入成员。</p>
|
||||
</> : null}
|
||||
{tab === 'roles' ? <RolesEditor /> : null}
|
||||
</section>
|
||||
</section>}
|
||||
{tab === 'roles' ? null : owner ? (
|
||||
<div className={styles.footer}>
|
||||
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-2);
|
||||
@@ -92,3 +94,18 @@
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.headerAvatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function SpaceRail({ id, onCreate, onJoin }: RailProps) {
|
||||
const name = channel.name || channel.id.slice(0, 8);
|
||||
return <button key={channel.id} type="button" className={`${styles.space} ${channel.id === id ? styles.current : ''}`}
|
||||
aria-current={channel.id === id ? 'page' : undefined} title={name} aria-label={name} onClick={() => navigate(channelPath(channel.id))}>
|
||||
{Array.from(name)[0]?.toLocaleUpperCase()}
|
||||
{channel.avatar ? <img className={styles.avatar} src={channel.avatar} alt="" /> : Array.from(name)[0]?.toLocaleUpperCase()}
|
||||
</button>;
|
||||
})}
|
||||
<button type="button" className={`${styles.space} ${styles.action}`} title="新建空间" aria-label="新建空间" disabled={!username} onClick={onCreate}><Icon name="plus" size={20} /></button>
|
||||
@@ -30,6 +30,7 @@ export function SpaceHeader() {
|
||||
const name = channel?.name || `${channel?.id.slice(0, 8) ?? ''}…`;
|
||||
return <div className={styles.header}>
|
||||
{channel?.mode === 'raw' ? <span className={styles.raw}>明文</span> : <Icon name="lock" size={14} />}
|
||||
{channel?.avatar ? <img className={styles.headerAvatar} src={channel.avatar} alt="" /> : null}
|
||||
<strong>{name}</strong>
|
||||
</div>;
|
||||
}
|
||||
@@ -141,3 +141,14 @@
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 空间头像:方形取景框,四角用圆角压暗 */
|
||||
.maskSquare {
|
||||
border-radius: var(--radius-lg);
|
||||
background: none;
|
||||
box-shadow: 0 0 0 999px var(--scrim);
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ interface View { zoom: number; x: number; y: number; quarter: number; fine: numb
|
||||
const FRESH: View = { zoom: 1, x: 0, y: 0, quarter: 0, fine: 0 };
|
||||
|
||||
/** 头像预览:同一份裁剪结果按实际使用的尺寸画出来 */
|
||||
function Preview({ image, crop, size }: { image: ImageBitmap; crop: Crop; size: number }) {
|
||||
function Preview({ image, crop, size, rounded }: { image: ImageBitmap; crop: Crop; size: number; rounded?: boolean }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
@@ -19,10 +19,15 @@ function Preview({ image, crop, size }: { image: ImageBitmap; crop: Crop; size:
|
||||
canvas.width = canvas.height = Math.round(size * (window.devicePixelRatio || 1));
|
||||
paintCrop(ctx, image, crop, VIEW, canvas.width);
|
||||
}, [image, crop, size]);
|
||||
return <canvas ref={ref} className={styles.avatar} style={{ width: size, height: size }} aria-hidden="true" />;
|
||||
return <canvas ref={ref} className={`${styles.avatar} ${rounded ? styles.rounded : ''}`} style={{ width: size, height: size }} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/** 空间头像的动图上限(data URL 字符数),和服务端 AvatarData.MaxSpaceGifChars 一致并留余量 */
|
||||
export const SPACE_GIF_LIMIT = 1_900_000;
|
||||
|
||||
interface Props {
|
||||
/** 个人头像是圆的;空间头像是圆角方块 */
|
||||
kind?: 'person' | 'space';
|
||||
file: File;
|
||||
/** 预览里显示的名称 */
|
||||
name: string;
|
||||
@@ -31,7 +36,8 @@ interface Props {
|
||||
}
|
||||
|
||||
/** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */
|
||||
export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
||||
export function AvatarEditor({ file, name, onCancel, onDone, kind = 'person' }: Props) {
|
||||
const space = kind === 'space';
|
||||
const [still, setStill] = useState<ImageBitmap>();
|
||||
// 动图:各帧(已取样)和当前播放到哪一帧;所有帧尺寸相同,裁剪几何只看尺寸
|
||||
const [frames, setFrames] = useState<GifFrame[]>([]);
|
||||
@@ -115,7 +121,7 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
||||
async function confirm() {
|
||||
if (!image || busy) return;
|
||||
setBusy(true);
|
||||
try { onDone(frames.length ? await exportGifAvatar(frames, crop, VIEW) : await exportAvatar(image, crop, VIEW)); }
|
||||
try { onDone(frames.length ? await exportGifAvatar(frames, crop, VIEW, space ? SPACE_GIF_LIMIT : undefined) : await exportAvatar(image, crop, VIEW)); }
|
||||
catch { setFailed(true); setBusy(false); }
|
||||
}
|
||||
|
||||
@@ -131,7 +137,7 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
||||
<div className={styles.stage} style={{ width: VIEW, height: VIEW }}>
|
||||
<canvas ref={stage} className={styles.canvas} style={{ width: VIEW, height: VIEW }} aria-label="拖动图片调整头像位置"
|
||||
onPointerDown={down} onPointerMove={move} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} />
|
||||
<span className={styles.mask} aria-hidden="true" />
|
||||
<span className={`${styles.mask} ${space ? styles.maskSquare : ''}`} aria-hidden="true" />
|
||||
</div>
|
||||
{file.type === 'image/gif' && !canDecodeGif() ? <p className={styles.note}>当前浏览器不支持动图头像,只会使用第一帧。</p> : null}
|
||||
<label className={styles.slider}><span>缩放</span>
|
||||
@@ -146,14 +152,25 @@ export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
||||
</div>
|
||||
<div className={styles.preview} aria-label="头像预览">
|
||||
<h3>预览</h3>
|
||||
<div className={styles.message}>
|
||||
{image ? <Preview image={image} crop={crop} size={40} /> : <span className={styles.avatar} />}
|
||||
<div><p><strong>{name}</strong><small>刚刚</small></p><p>大家好~</p></div>
|
||||
</div>
|
||||
<div className={styles.member}>
|
||||
{image ? <Preview image={image} crop={crop} size={32} /> : <span className={styles.avatarSmall} />}
|
||||
<div><strong>{name}</strong><small>在线</small></div>
|
||||
</div>
|
||||
{space ? <>
|
||||
<div className={styles.member}>
|
||||
{image ? <Preview image={image} crop={crop} size={44} rounded /> : <span className={styles.avatarSmall} />}
|
||||
<div><strong>空间列表</strong></div>
|
||||
</div>
|
||||
<div className={styles.member}>
|
||||
{image ? <Preview image={image} crop={crop} size={24} rounded /> : <span className={styles.avatarSmall} />}
|
||||
<div><strong>{name}</strong></div>
|
||||
</div>
|
||||
</> : <>
|
||||
<div className={styles.message}>
|
||||
{image ? <Preview image={image} crop={crop} size={40} /> : <span className={styles.avatar} />}
|
||||
<div><p><strong>{name}</strong><small>刚刚</small></p><p>大家好~</p></div>
|
||||
</div>
|
||||
<div className={styles.member}>
|
||||
{image ? <Preview image={image} crop={crop} size={32} /> : <span className={styles.avatarSmall} />}
|
||||
<div><strong>{name}</strong><small>在线</small></div>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
|
||||
@@ -4,19 +4,9 @@ import { useAccount } from '@/store/account';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { AvatarEditor } from './AvatarEditor';
|
||||
import { checkAvatarSource } from './avatarSource';
|
||||
import styles from './AccountSettings.module.css';
|
||||
|
||||
// 原图只做初步检查;裁剪后会重新编码为 256×256,体积由编辑器控制
|
||||
const MAX_SOURCE_BYTES = 20_000_000;
|
||||
|
||||
async function checkAvatarSource(file: File): Promise<void> {
|
||||
if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG、WebP 或 GIF 图片');
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192;
|
||||
bitmap.close();
|
||||
if (!valid) throw new Error('图片宽高不能超过 8192 像素');
|
||||
}
|
||||
|
||||
export function ProfileSettings() {
|
||||
const personal = useAccount((s) => s.personal);
|
||||
const username = useAccount((s) => s.username) ?? '';
|
||||
|
||||
@@ -77,12 +77,12 @@ function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: num
|
||||
}
|
||||
|
||||
/** 把每一帧按同样的裁剪导出成动图;默认 256 像素、保留每一帧;只有超过上限(约 4.5 MB)才逐步缩小尺寸、再隔帧 */
|
||||
export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, from: number): Promise<string> {
|
||||
export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, from: number, maxChars = MAX_GIF_DATA_URL): Promise<string> {
|
||||
for (const [size, step] of [[256, 1], [192, 1], [128, 1], [192, 2], [128, 2], [96, 2]] as const) {
|
||||
// 让出线程,编辑器的「处理中」能先画出来
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const url = encode(frames, crop, from, size, step);
|
||||
if (url.length <= MAX_GIF_DATA_URL) return url;
|
||||
if (url.length <= maxChars) return url;
|
||||
}
|
||||
throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF');
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// 原图只做初步检查;裁剪后会重新编码,体积由编辑器控制
|
||||
const MAX_SOURCE_BYTES = 20_000_000;
|
||||
|
||||
export async function checkAvatarSource(file: File): Promise<void> {
|
||||
if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG、WebP 或 GIF 图片');
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192;
|
||||
bitmap.close();
|
||||
if (!valid) throw new Error('图片宽高不能超过 8192 像素');
|
||||
}
|
||||
@@ -16,6 +16,8 @@ interface ChannelsState {
|
||||
join: (link: ParsedChannelLink, password?: string) => Promise<ChannelInfo>;
|
||||
hydrate: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
/** 打开着的空间收到最新信息(名称、头像、人数等)时,同步到列表里的那一项 */
|
||||
patch: (info: ChannelInfo) => void;
|
||||
forget: (id: string) => Promise<void>;
|
||||
setPendingLink: (link: ParsedChannelLink | null) => void;
|
||||
clearJustCreated: () => void;
|
||||
@@ -47,6 +49,7 @@ export const useChannels = create<ChannelsState>()((set, get) => ({
|
||||
return hydration;
|
||||
},
|
||||
refresh: async () => set({ list: await api.listSaved(), hydrated: true }),
|
||||
patch: (info) => set((s) => (s.list.some((c) => c.id === info.id) ? { list: s.list.map((c) => (c.id === info.id ? { ...c, ...info, avatar: info.avatar } : c)) } : s)),
|
||||
forget: async (id) => {
|
||||
await api.forget(id);
|
||||
set((s) => ({ list: s.list.filter((c) => c.id !== id) }));
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FileItem, Item } from '@/core-bridge/types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import { useAccount } from './account';
|
||||
import { useChannels } from './channels';
|
||||
import { initialData, reduce, withoutItem, type WorkspaceData } from './reduce';
|
||||
|
||||
/** 乐观发送:点发送即在本地画出占位项,后台再加密上传;worker 发布真实条目时带回 localId 替换占位项 */
|
||||
@@ -96,7 +97,11 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
|
||||
revokeInvitation: (username) => api.revokeInvitation(username),
|
||||
open: (channelId) => {
|
||||
useWorkspace.setState(initialData);
|
||||
return api.connect(channelId, (e) => useWorkspace.setState((s) => reduce(s, e)));
|
||||
return api.connect(channelId, (e) => {
|
||||
useWorkspace.setState((s) => reduce(s, e));
|
||||
// 空间名称、头像等改了:左侧空间列表是另一份投影,一起更新
|
||||
if (e.t === 'channel') useChannels.getState().patch(e.info);
|
||||
});
|
||||
},
|
||||
sendText: (text, threadId) => {
|
||||
const id = newLocalId();
|
||||
|
||||
Reference in new issue
Block a user