feat(frontend): add attachment_ids to message creation flow and types
This commit is contained in:
parent
7736869fc4
commit
e43d9fc8bf
@ -4434,6 +4434,7 @@ export type RoomMessageCreateRequest = {
|
|||||||
content_type?: string | null;
|
content_type?: string | null;
|
||||||
thread_id?: string | null;
|
thread_id?: string | null;
|
||||||
in_reply_to?: string | null;
|
in_reply_to?: string | null;
|
||||||
|
attachment_ids?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomMessageListResponse = {
|
export type RoomMessageListResponse = {
|
||||||
@ -4456,6 +4457,7 @@ export type RoomMessageResponse = {
|
|||||||
send_at: string;
|
send_at: string;
|
||||||
revoked?: string | null;
|
revoked?: string | null;
|
||||||
revoked_by?: string | null;
|
revoked_by?: string | null;
|
||||||
|
attachment_ids?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomMessageUpdateRequest = {
|
export type RoomMessageUpdateRequest = {
|
||||||
|
|||||||
@ -5,14 +5,15 @@
|
|||||||
* Supports @mentions, file uploads, emoji picker, and rich message AST.
|
* Supports @mentions, file uploads, emoji picker, and rich message AST.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||||
import { IMEditor } from './editor/IMEditor';
|
import { IMEditor } from './editor/IMEditor';
|
||||||
import { useRoom } from '@/contexts';
|
import { useRoom } from '@/contexts';
|
||||||
import type { MessageAST } from './editor/types';
|
import type { MessageAST } from './editor/types';
|
||||||
|
import type { IMEditorHandle } from './editor/IMEditor';
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
onSend: (content: string) => void;
|
onSend: (content: string, attachmentIds?: string[]) => void;
|
||||||
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
||||||
onCancelReply?: () => void;
|
onCancelReply?: () => void;
|
||||||
}
|
}
|
||||||
@ -22,13 +23,27 @@ export interface MessageInputHandle {
|
|||||||
clearContent: () => void;
|
clearContent: () => void;
|
||||||
getContent: () => string;
|
getContent: () => string;
|
||||||
insertMention: (type: string, id: string, label: string) => void;
|
insertMention: (type: string, id: string, label: string) => void;
|
||||||
|
getAttachmentIds: () => string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
||||||
{ roomName, onSend, replyingTo, onCancelReply },
|
{ roomName, onSend, replyingTo, onCancelReply },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const { members } = useRoom();
|
const { members, activeRoomId } = useRoom();
|
||||||
|
|
||||||
|
// Ref passed to the inner IMEditor
|
||||||
|
const innerEditorRef = useRef<IMEditorHandle | null>(null);
|
||||||
|
|
||||||
|
// Expose a subset of IMEditorHandle (plus getAttachmentIds) as MessageInputHandle
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => innerEditorRef.current?.focus(),
|
||||||
|
clearContent: () => innerEditorRef.current?.clearContent(),
|
||||||
|
getContent: () => innerEditorRef.current?.getContent() ?? '',
|
||||||
|
insertMention: (type: string, id: string, label: string) =>
|
||||||
|
innerEditorRef.current?.insertMention(type, id, label),
|
||||||
|
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
|
||||||
|
}), []);
|
||||||
|
|
||||||
// Transform room data into MentionItems
|
// Transform room data into MentionItems
|
||||||
const mentionItems = {
|
const mentionItems = {
|
||||||
@ -43,11 +58,12 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
commands: [], // TODO: add slash commands
|
commands: [], // TODO: add slash commands
|
||||||
};
|
};
|
||||||
|
|
||||||
// File upload handler — integrate with your upload API
|
// File upload handler — POST to /rooms/{room_id}/upload
|
||||||
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
|
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
|
||||||
|
if (!activeRoomId) throw new Error('No active room');
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
const res = await fetch(`/rooms/${activeRoomId}/upload`, { method: 'POST', body: formData });
|
||||||
if (!res.ok) throw new Error('Upload failed');
|
if (!res.ok) throw new Error('Upload failed');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
@ -59,7 +75,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<IMEditor
|
<IMEditor
|
||||||
ref={ref}
|
ref={innerEditorRef}
|
||||||
replyingTo={replyingTo}
|
replyingTo={replyingTo}
|
||||||
onCancelReply={onCancelReply}
|
onCancelReply={onCancelReply}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
|||||||
@ -6,12 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
|
import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
|
||||||
import { useEditor, EditorContent, Extension } from '@tiptap/react';
|
import {EditorContent, Extension, useEditor} from '@tiptap/react';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import {CustomEmojiNode} from './EmojiNode';
|
import {CustomEmojiNode} from './EmojiNode';
|
||||||
import type { MentionItem, MessageAST, MentionType } from './types';
|
import type {MentionItem, MentionType, MessageAST} from './types';
|
||||||
import { Paperclip, Smile, Send, X } from 'lucide-react';
|
import {Paperclip, Send, Smile, X} from 'lucide-react';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {COMMON_EMOJIS} from '../../shared';
|
import {COMMON_EMOJIS} from '../../shared';
|
||||||
import {useTheme} from '@/contexts';
|
import {useTheme} from '@/contexts';
|
||||||
@ -36,6 +36,7 @@ export interface IMEditorHandle {
|
|||||||
clearContent: () => void;
|
clearContent: () => void;
|
||||||
getContent: () => string;
|
getContent: () => string;
|
||||||
insertMention: (type: string, id: string, label: string) => void;
|
insertMention: (type: string, id: string, label: string) => void;
|
||||||
|
getAttachmentIds: () => string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Color System (Google AI Studio / Linear palette, no Discord) ────────────
|
// ─── Color System (Google AI Studio / Linear palette, no Discord) ────────────
|
||||||
@ -114,7 +115,9 @@ function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect:
|
|||||||
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{color: p.textMuted}}>
|
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{color: p.textMuted}}>
|
||||||
Emoji
|
Emoji
|
||||||
</span>
|
</span>
|
||||||
<button onClick={onClose} className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors" style={{ color: p.icon }}>
|
<button onClick={onClose}
|
||||||
|
className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors"
|
||||||
|
style={{color: p.icon}}>
|
||||||
<X size={11}/>
|
<X size={11}/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -223,7 +226,8 @@ function MentionDropdown({
|
|||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{badge && (
|
{badge && (
|
||||||
<span className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
|
<span
|
||||||
|
className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -305,7 +309,10 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
let ts = from;
|
let ts = from;
|
||||||
for (let i = from - 1; i >= 1; i--) {
|
for (let i = from - 1; i >= 1; i--) {
|
||||||
const c = text[i - 1];
|
const c = text[i - 1];
|
||||||
if (c === '@') { ts = i; break; }
|
if (c === '@') {
|
||||||
|
ts = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (/\s/.test(c)) break;
|
if (/\s/.test(c)) break;
|
||||||
}
|
}
|
||||||
const q = text.slice(ts - 1, from);
|
const q = text.slice(ts - 1, from);
|
||||||
@ -347,8 +354,19 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
uploadFile = result.file;
|
uploadFile = result.file;
|
||||||
}
|
}
|
||||||
const res = await onUploadFile(uploadFile);
|
const res = await onUploadFile(uploadFile);
|
||||||
editor.chain().focus().insertContent({ type: 'file', attrs: { id: res.id, name: uploadFile.name, url: res.url, size: uploadFile.size, type: uploadFile.type, status: 'done' } }).insertContent(' ').run();
|
editor.chain().focus().insertContent({
|
||||||
} catch { /* ignore */ }
|
type: 'file',
|
||||||
|
attrs: {
|
||||||
|
id: res.id,
|
||||||
|
name: uploadFile.name,
|
||||||
|
url: res.url,
|
||||||
|
size: uploadFile.size,
|
||||||
|
type: uploadFile.type,
|
||||||
|
status: 'done'
|
||||||
|
}
|
||||||
|
}).insertContent(' ').run();
|
||||||
|
} catch { /* ignore */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const send = () => {
|
const send = () => {
|
||||||
@ -368,6 +386,21 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
const mentionStr = `@[${type}:${id}:${label}] `;
|
const mentionStr = `@[${type}:${id}:${label}] `;
|
||||||
editor.chain().focus().insertContent(mentionStr).run();
|
editor.chain().focus().insertContent(mentionStr).run();
|
||||||
},
|
},
|
||||||
|
getAttachmentIds: () => {
|
||||||
|
if (!editor) return [];
|
||||||
|
const json = editor.getJSON();
|
||||||
|
const ids: string[] = [];
|
||||||
|
const walk = (node: Record<string, unknown>) => {
|
||||||
|
if (node['type'] === 'file' && node['attrs']) {
|
||||||
|
const attrs = node['attrs'] as Record<string, unknown>;
|
||||||
|
if (attrs['id']) ids.push(String(attrs['id']));
|
||||||
|
}
|
||||||
|
const children = node['content'] as Record<string, unknown>[] | undefined;
|
||||||
|
if (children) children.forEach(walk);
|
||||||
|
};
|
||||||
|
walk(json as Record<string, unknown>);
|
||||||
|
return ids;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const hasContent = !!editor && !editor.isEmpty;
|
const hasContent = !!editor && !editor.isEmpty;
|
||||||
@ -379,20 +412,28 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
: 'none';
|
: 'none';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col" ref={wrapRef}>
|
<div className="relative flex flex-col mt-2 ml-3 mr-3 mb-1" ref={wrapRef}>
|
||||||
{/* Reply strip */}
|
|
||||||
{replyingTo && (
|
{replyingTo && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 px-4 py-2.5"
|
className="flex items-center gap-3 px-4 py-2.5"
|
||||||
style={{ background: p.replyBg, borderRadius: '12px 12px 0 0', borderBottom: `1px solid ${p.border}` }}
|
style={{
|
||||||
|
background: p.replyBg,
|
||||||
|
borderRadius: '12px 12px 0 0',
|
||||||
|
borderBottom: `1px solid ${p.border}`
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
<span className="text-[11px] font-semibold shrink-0" style={{ color: p.borderFocus }}>Replying to</span>
|
<span className="text-[11px] font-semibold shrink-0"
|
||||||
<span className="truncate text-sm font-medium" style={{ color: p.text }}>{replyingTo.display_name}</span>
|
style={{color: p.borderFocus}}>Replying to</span>
|
||||||
<span className="truncate shrink-1 text-sm" style={{ color: p.textMuted }}>— {replyingTo.content}</span>
|
<span className="truncate text-sm font-medium"
|
||||||
|
style={{color: p.text}}>{replyingTo.display_name}</span>
|
||||||
|
<span className="truncate shrink-1 text-sm"
|
||||||
|
style={{color: p.textMuted}}>— {replyingTo.content}</span>
|
||||||
</div>
|
</div>
|
||||||
{onCancelReply && (
|
{onCancelReply && (
|
||||||
<button onClick={onCancelReply} className="flex items-center justify-center w-6 h-6 rounded-full cursor-pointer shrink-0 transition-colors" style={{ color: p.icon }}>
|
<button onClick={onCancelReply}
|
||||||
|
className="flex items-center justify-center w-6 h-6 rounded-full cursor-pointer shrink-0 transition-colors"
|
||||||
|
style={{color: p.icon}}>
|
||||||
<X size={13}/>
|
<X size={13}/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -435,14 +476,23 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowEmoji(v => !v); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowEmoji(v => !v);
|
||||||
|
}}
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
|
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
|
||||||
style={{ color: showEmoji ? p.iconHover : p.icon, background: showEmoji ? p.bgActive : 'transparent' }}
|
style={{
|
||||||
|
color: showEmoji ? p.iconHover : p.icon,
|
||||||
|
background: showEmoji ? p.bgActive : 'transparent'
|
||||||
|
}}
|
||||||
title="Emoji"
|
title="Emoji"
|
||||||
>
|
>
|
||||||
<Smile size={18}/>
|
<Smile size={18}/>
|
||||||
</button>
|
</button>
|
||||||
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => { editor?.chain().focus().insertContent(emoji).insertContent(' ').run(); setShowEmoji(false); }} p={p} />}
|
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => {
|
||||||
|
editor?.chain().focus().insertContent(emoji).insertContent(' ').run();
|
||||||
|
setShowEmoji(false);
|
||||||
|
}} p={p}/>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@ -451,7 +501,12 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
title="Attach file"
|
title="Attach file"
|
||||||
>
|
>
|
||||||
<Paperclip size={18}/>
|
<Paperclip size={18}/>
|
||||||
<input type="file" className="hidden" onChange={e => { e.stopPropagation(); const f = e.target.files?.[0]; if (f && onUploadFile) void doUpload(f); e.target.value = ''; }} />
|
<input type="file" className="hidden" onChange={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f && onUploadFile) void doUpload(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -459,7 +514,10 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="text-[11px]" style={{color: p.textSubtle}}>↵ send</span>
|
<span className="text-[11px]" style={{color: p.textSubtle}}>↵ send</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); send(); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
send();
|
||||||
|
}}
|
||||||
disabled={!hasContent}
|
disabled={!hasContent}
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-150 cursor-pointer"
|
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-150 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -63,6 +63,8 @@ export type MessageWithMeta = RoomMessageResponse & {
|
|||||||
/** True for messages sent by the current user that haven't been confirmed by the server */
|
/** True for messages sent by the current user that haven't been confirmed by the server */
|
||||||
isOptimistic?: boolean;
|
isOptimistic?: boolean;
|
||||||
reactions?: ReactionGroup[];
|
reactions?: ReactionGroup[];
|
||||||
|
/** Attachment IDs for files uploaded with this message */
|
||||||
|
attachment_ids?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoomWithCategory = RoomResponse & {
|
export type RoomWithCategory = RoomResponse & {
|
||||||
@ -119,7 +121,7 @@ interface RoomContextValue {
|
|||||||
isTransitioningRoom: boolean;
|
isTransitioningRoom: boolean;
|
||||||
nextCursor: number | null;
|
nextCursor: number | null;
|
||||||
loadMore: (cursor?: number | null) => void;
|
loadMore: (cursor?: number | null) => void;
|
||||||
sendMessage: (content: string, contentType?: string, inReplyTo?: string) => Promise<void>;
|
sendMessage: (content: string, contentType?: string, inReplyTo?: string, attachmentIds?: string[]) => Promise<void>;
|
||||||
editMessage: (messageId: string, content: string) => Promise<void>;
|
editMessage: (messageId: string, content: string) => Promise<void>;
|
||||||
revokeMessage: (messageId: string) => Promise<void>;
|
revokeMessage: (messageId: string) => Promise<void>;
|
||||||
updateReadSeq: (seq: number) => Promise<void>;
|
updateReadSeq: (seq: number) => Promise<void>;
|
||||||
@ -837,7 +839,7 @@ export function RoomProvider({
|
|||||||
const sendingRef = useRef(false);
|
const sendingRef = useRef(false);
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (content: string, contentType = 'text', inReplyTo?: string) => {
|
async (content: string, contentType = 'text', inReplyTo?: string, attachmentIds?: string[]) => {
|
||||||
const client = wsClientRef.current;
|
const client = wsClientRef.current;
|
||||||
if (!activeRoomId || !client) return;
|
if (!activeRoomId || !client) return;
|
||||||
if (sendingRef.current) return;
|
if (sendingRef.current) return;
|
||||||
@ -861,6 +863,7 @@ export function RoomProvider({
|
|||||||
thread_id: inReplyTo,
|
thread_id: inReplyTo,
|
||||||
in_reply_to: inReplyTo,
|
in_reply_to: inReplyTo,
|
||||||
reactions: [],
|
reactions: [],
|
||||||
|
attachment_ids: attachmentIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessages((prev) => [...prev, optimisticMsg]);
|
setMessages((prev) => [...prev, optimisticMsg]);
|
||||||
@ -869,6 +872,7 @@ export function RoomProvider({
|
|||||||
const confirmedMsg = await client.messageCreate(activeRoomId, content, {
|
const confirmedMsg = await client.messageCreate(activeRoomId, content, {
|
||||||
contentType,
|
contentType,
|
||||||
inReplyTo,
|
inReplyTo,
|
||||||
|
attachmentIds,
|
||||||
});
|
});
|
||||||
// Replace optimistic message with server-confirmed one
|
// Replace optimistic message with server-confirmed one
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
|
|||||||
@ -622,6 +622,7 @@ export class RoomWsClient {
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
inReplyTo?: string;
|
inReplyTo?: string;
|
||||||
|
attachmentIds?: string[];
|
||||||
},
|
},
|
||||||
): Promise<RoomMessageResponse> {
|
): Promise<RoomMessageResponse> {
|
||||||
return this.request<RoomMessageResponse>('message.create', {
|
return this.request<RoomMessageResponse>('message.create', {
|
||||||
@ -630,6 +631,7 @@ export class RoomWsClient {
|
|||||||
content_type: options?.contentType,
|
content_type: options?.contentType,
|
||||||
thread_id: options?.threadId,
|
thread_id: options?.threadId,
|
||||||
in_reply_to: options?.inReplyTo,
|
in_reply_to: options?.inReplyTo,
|
||||||
|
attachment_ids: options?.attachmentIds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -91,6 +91,7 @@ export interface WsRequestParams {
|
|||||||
min_score?: number;
|
min_score?: number;
|
||||||
query?: string;
|
query?: string;
|
||||||
message_ids?: string[];
|
message_ids?: string[];
|
||||||
|
attachment_ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WsResponse {
|
export interface WsResponse {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user