mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 02:42:25 +08:00
feat(core): support open doc in ai session history (#13035)
Close [AI-240 <img width="533" alt="截屏2025-07-04 18 04 39" src="https://github.com/user-attachments/assets/726a54b6-3bdb-4e70-9cda-4671d83ae5bd" /> ](https://linear.app/affine-design/issue/AI-240) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced chat toolbar and session history with the ability to open specific documents directly from the chat interface. * Added tooltips and improved click handling for clearer user interactions in chat session and document lists. * **Bug Fixes** * Prevented redundant actions when attempting to open already active sessions or documents. * **Style** * Improved tooltip formatting and visual styling for error messages and tooltips. * Refined hover effects and layout in chat session history for better clarity. * **Refactor** * Updated tooltip configuration for more precise positioning and behavior. * **Chores** * Minor updates to property defaults for tooltips and chat panel components. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: fengmk2 <fengmk2@gmail.com>
This commit is contained in:
@@ -190,7 +190,10 @@ export class Tooltip extends LitElement {
|
|||||||
middleware: [
|
middleware: [
|
||||||
this.autoFlip && flip({ padding: AUTO_FLIP_PADDING }),
|
this.autoFlip && flip({ padding: AUTO_FLIP_PADDING }),
|
||||||
this.autoShift && shift({ padding: AUTO_SHIFT_PADDING }),
|
this.autoShift && shift({ padding: AUTO_SHIFT_PADDING }),
|
||||||
offset((this.arrow ? TRIANGLE_HEIGHT : 0) + this.offset),
|
offset({
|
||||||
|
mainAxis: (this.arrow ? TRIANGLE_HEIGHT : 0) + this.offsetY,
|
||||||
|
crossAxis: this.offsetX,
|
||||||
|
}),
|
||||||
arrow({
|
arrow({
|
||||||
element: portalRoot.shadowRoot!.querySelector('.arrow')!,
|
element: portalRoot.shadowRoot!.querySelector('.arrow')!,
|
||||||
}),
|
}),
|
||||||
@@ -264,7 +267,7 @@ export class Tooltip extends LitElement {
|
|||||||
* Show a triangle arrow pointing to the reference element.
|
* Show a triangle arrow pointing to the reference element.
|
||||||
*/
|
*/
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor arrow = true;
|
accessor arrow = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* changes the placement of the floating element in order to keep it in view,
|
* changes the placement of the floating element in order to keep it in view,
|
||||||
@@ -303,7 +306,10 @@ export class Tooltip extends LitElement {
|
|||||||
* See https://floating-ui.com/docs/offset
|
* See https://floating-ui.com/docs/offset
|
||||||
*/
|
*/
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor offset = 4;
|
accessor offsetY = 6;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor offsetX = 0;
|
||||||
|
|
||||||
@property({ attribute: 'tip-position' })
|
@property({ attribute: 'tip-position' })
|
||||||
accessor placement: Placement = 'top';
|
accessor placement: Placement = 'top';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
|
import type { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import type {
|
import type {
|
||||||
ContextEmbedStatus,
|
ContextEmbedStatus,
|
||||||
CopilotSessionType,
|
CopilotSessionType,
|
||||||
@@ -98,6 +99,9 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
|
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor affineWorkbenchService!: WorkbenchService;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor session: CopilotSessionType | null | undefined;
|
accessor session: CopilotSessionType | null | undefined;
|
||||||
|
|
||||||
@@ -137,34 +141,72 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
<ai-chat-toolbar
|
<ai-chat-toolbar
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
.workspaceId=${this.doc.workspace.id}
|
.workspaceId=${this.doc.workspace.id}
|
||||||
|
.docId=${this.doc.id}
|
||||||
.onNewSession=${this.newSession}
|
.onNewSession=${this.newSession}
|
||||||
.onTogglePin=${this.togglePin}
|
.onTogglePin=${this.togglePin}
|
||||||
.onOpenSession=${this.openSession}
|
.onOpenSession=${this.openSession}
|
||||||
|
.onOpenDoc=${this.openDoc}
|
||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
.notification=${notification}
|
.notification=${notification}
|
||||||
></ai-chat-toolbar>
|
></ai-chat-toolbar>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly getSessionIdFromUrl = () => {
|
||||||
|
if (this.affineWorkbenchService) {
|
||||||
|
const { workbench } = this.affineWorkbenchService;
|
||||||
|
const location = workbench.location$.value;
|
||||||
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const sessionId = searchParams.get('sessionId');
|
||||||
|
if (sessionId) {
|
||||||
|
workbench.activeView$.value.updateQueryString(
|
||||||
|
{ sessionId: undefined },
|
||||||
|
{ replace: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly setSession = (
|
||||||
|
session: CopilotSessionType | null | undefined
|
||||||
|
) => {
|
||||||
|
this.session = session ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
private readonly initSession = async () => {
|
private readonly initSession = async () => {
|
||||||
if (!AIProvider.session) {
|
if (!AIProvider.session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sessionId = this.getSessionIdFromUrl();
|
||||||
const pinSessions = await AIProvider.session.getSessions(
|
const pinSessions = await AIProvider.session.getSessions(
|
||||||
this.doc.workspace.id,
|
this.doc.workspace.id,
|
||||||
undefined,
|
undefined,
|
||||||
{ pinned: true, limit: 1 }
|
{ pinned: true, limit: 1 }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Array.isArray(pinSessions) && pinSessions[0]) {
|
if (Array.isArray(pinSessions) && pinSessions[0]) {
|
||||||
|
// pinned session
|
||||||
this.session = pinSessions[0];
|
this.session = pinSessions[0];
|
||||||
|
} else if (sessionId) {
|
||||||
|
// sessionId from url
|
||||||
|
const session = await AIProvider.session.getSession(
|
||||||
|
this.doc.workspace.id,
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
this.setSession(session);
|
||||||
} else {
|
} else {
|
||||||
|
// latest doc session
|
||||||
const docSessions = await AIProvider.session.getSessions(
|
const docSessions = await AIProvider.session.getSessions(
|
||||||
this.doc.workspace.id,
|
this.doc.workspace.id,
|
||||||
this.doc.id,
|
this.doc.id,
|
||||||
{ action: false, fork: false, limit: 1 }
|
{ action: false, fork: false, limit: 1 }
|
||||||
);
|
);
|
||||||
|
// sessions is descending ordered by updatedAt
|
||||||
// the first item is the latest session
|
// the first item is the latest session
|
||||||
this.session = docSessions?.[0] ?? null;
|
const session = docSessions?.[0];
|
||||||
|
this.setSession(session);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,7 +228,7 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
this.doc.workspace.id,
|
this.doc.workspace.id,
|
||||||
sessionId
|
sessionId
|
||||||
);
|
);
|
||||||
this.session = session ?? null;
|
this.setSession(session);
|
||||||
}
|
}
|
||||||
return this.session;
|
return this.session;
|
||||||
};
|
};
|
||||||
@@ -197,7 +239,7 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
this.doc.workspace.id,
|
this.doc.workspace.id,
|
||||||
options.sessionId
|
options.sessionId
|
||||||
);
|
);
|
||||||
this.session = session ?? null;
|
this.setSession(session);
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly newSession = () => {
|
private readonly newSession = () => {
|
||||||
@@ -208,12 +250,31 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly openSession = async (sessionId: string) => {
|
private readonly openSession = async (sessionId: string) => {
|
||||||
|
if (this.session?.id === sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.resetPanel();
|
this.resetPanel();
|
||||||
const session = await AIProvider.session?.getSession(
|
const session = await AIProvider.session?.getSession(
|
||||||
this.doc.workspace.id,
|
this.doc.workspace.id,
|
||||||
sessionId
|
sessionId
|
||||||
);
|
);
|
||||||
this.session = session ?? null;
|
this.setSession(session);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly openDoc = async (docId: string, sessionId: string) => {
|
||||||
|
if (this.doc.id === docId) {
|
||||||
|
if (this.session?.id === sessionId || this.session?.pinned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.openSession(sessionId);
|
||||||
|
} else if (this.affineWorkbenchService) {
|
||||||
|
const { workbench } = this.affineWorkbenchService;
|
||||||
|
if (this.session?.pinned) {
|
||||||
|
workbench.open(`/${docId}`, { at: 'active' });
|
||||||
|
} else {
|
||||||
|
workbench.open(`/${docId}?sessionId=${sessionId}`, { at: 'active' });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly togglePin = async () => {
|
private readonly togglePin = async () => {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor workspaceId!: string;
|
accessor workspaceId!: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor docId: string | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onNewSession!: () => void;
|
accessor onNewSession!: () => void;
|
||||||
|
|
||||||
@@ -32,6 +35,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onOpenSession!: (sessionId: string) => void;
|
accessor onOpenSession!: (sessionId: string) => void;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor onOpenDoc!: (docId: string, sessionId: string) => void;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor docDisplayConfig!: DocDisplayConfig;
|
accessor docDisplayConfig!: DocDisplayConfig;
|
||||||
|
|
||||||
@@ -80,9 +86,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="chat-toolbar-icon" @click=${this.onTogglePin}>
|
<div class="chat-toolbar-icon" @click=${this.onTogglePin}>
|
||||||
${pinned ? PinedIcon() : PinIcon()}
|
${pinned ? PinedIcon() : PinIcon()}
|
||||||
<affine-tooltip
|
<affine-tooltip>
|
||||||
>${pinned ? 'Unpin this Chat' : 'Pin this Chat'}</affine-tooltip
|
${pinned ? 'Unpin this Chat' : 'Pin this Chat'}
|
||||||
>
|
</affine-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="chat-toolbar-icon history-button"
|
class="chat-toolbar-icon history-button"
|
||||||
@@ -126,12 +132,24 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly onSessionClick = async (sessionId: string) => {
|
private readonly onSessionClick = async (sessionId: string) => {
|
||||||
|
if (this.session?.id === sessionId) {
|
||||||
|
this.notification?.toast('You are already in this chat');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const confirm = await this.unpinConfirm();
|
const confirm = await this.unpinConfirm();
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
this.onOpenSession(sessionId);
|
this.onOpenSession(sessionId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly onDocClick = async (docId: string, sessionId: string) => {
|
||||||
|
if (this.docId === docId && this.session?.id === sessionId) {
|
||||||
|
this.notification?.toast('You are already in this chat');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.onOpenDoc(docId, sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
private readonly toggleHistoryMenu = () => {
|
private readonly toggleHistoryMenu = () => {
|
||||||
if (this.abortController) {
|
if (this.abortController) {
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
@@ -150,6 +168,7 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
.workspaceId=${this.workspaceId}
|
.workspaceId=${this.workspaceId}
|
||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
.onSessionClick=${this.onSessionClick}
|
.onSessionClick=${this.onSessionClick}
|
||||||
|
.onDocClick=${this.onDocClick}
|
||||||
.notification=${this.notification}
|
.notification=${this.notification}
|
||||||
></ai-session-history>
|
></ai-session-history>
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -45,17 +45,22 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-session-item {
|
.ai-session-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-session-item:hover {
|
.ai-session-item:hover:not(:has(.ai-session-doc:hover)) {
|
||||||
|
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-session-doc:hover {
|
||||||
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||||
border-color: ${unsafeCSSVarV2('layer/insideBorder/border')};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-session-title {
|
.ai-session-title {
|
||||||
@@ -75,6 +80,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
border-radius: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@@ -110,6 +116,9 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onSessionClick!: (sessionId: string) => void;
|
accessor onSessionClick!: (sessionId: string) => void;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor onDocClick!: (docId: string, sessionId: string) => void;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor notification: NotificationService | null | undefined;
|
accessor notification: NotificationService | null | undefined;
|
||||||
|
|
||||||
@@ -194,10 +203,20 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="ai-session-item"
|
class="ai-session-item"
|
||||||
@click=${() => this.onSessionClick(session.sessionId)}
|
@click=${(e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.onSessionClick(session.sessionId);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="ai-session-title">${session.sessionId}</div>
|
<div class="ai-session-title">
|
||||||
${session.docId ? this.renderSessionDoc(session.docId) : nothing}
|
${session.sessionId}
|
||||||
|
<affine-tooltip .offsetX=${60}>
|
||||||
|
Click to open this chat
|
||||||
|
</affine-tooltip>
|
||||||
|
</div>
|
||||||
|
${session.docId
|
||||||
|
? this.renderSessionDoc(session.docId, session.sessionId)
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
@@ -205,12 +224,19 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderSessionDoc(docId: string) {
|
private renderSessionDoc(docId: string, sessionId: string) {
|
||||||
const getIcon = this.docDisplayConfig.getIcon(docId);
|
const getIcon = this.docDisplayConfig.getIcon(docId);
|
||||||
const docIcon = typeof getIcon === 'function' ? getIcon() : getIcon;
|
const docIcon = typeof getIcon === 'function' ? getIcon() : getIcon;
|
||||||
return html`<div class="ai-session-doc">
|
return html`<div
|
||||||
|
class="ai-session-doc"
|
||||||
|
@click=${(e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.onDocClick(docId, sessionId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
${docIcon}
|
${docIcon}
|
||||||
<span class="doc-title">${this.docDisplayConfig.getTitle(docId)}</span>
|
<span class="doc-title"> ${this.docDisplayConfig.getTitle(docId)} </span>
|
||||||
|
<affine-tooltip>Open this doc</affine-tooltip>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,9 +155,9 @@ export class AIErrorWrapper extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
>
|
>
|
||||||
${this.actionText}
|
${this.actionText}
|
||||||
${this.actionTooltip
|
${this.actionTooltip
|
||||||
? html`<affine-tooltip tip-position="top"
|
? html`<affine-tooltip tip-position="top">
|
||||||
>${this.actionTooltip}</affine-tooltip
|
${this.actionTooltip}
|
||||||
>`
|
</affine-tooltip>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,13 +182,12 @@ export class AIPanelError extends WithDisposable(LitElement) {
|
|||||||
() => {
|
() => {
|
||||||
const tip = this.config.error?.message;
|
const tip = this.config.error?.message;
|
||||||
const error = tip
|
const error = tip
|
||||||
? html`<span class="error-tip"
|
? html`<span class="error-tip">
|
||||||
>An error occurred<affine-tooltip
|
An error occurred
|
||||||
tip-position="bottom-start"
|
<affine-tooltip tip-position="bottom-start">
|
||||||
.arrow=${false}
|
${tip}
|
||||||
>${tip}</affine-tooltip
|
</affine-tooltip>
|
||||||
></span
|
</span>`
|
||||||
>`
|
|
||||||
: 'An error occurred';
|
: 'An error occurred';
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -251,9 +251,9 @@ export class AIPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
@pointerdown=${stopPropagation}
|
@pointerdown=${stopPropagation}
|
||||||
>
|
>
|
||||||
${PublishIcon()}
|
${PublishIcon()}
|
||||||
<affine-tooltip .offset=${12}
|
<affine-tooltip .offsetY=${12}>
|
||||||
>Toggle Network Search</affine-tooltip
|
Toggle Network Search
|
||||||
>
|
</affine-tooltip>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
@@ -265,7 +265,7 @@ export class AIPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
>
|
>
|
||||||
${SendIcon()}
|
${SendIcon()}
|
||||||
${this._hasContent
|
${this._hasContent
|
||||||
? html`<affine-tooltip .offset=${12}>Send to AI</affine-tooltip>`
|
? html`<affine-tooltip .offsetY=${12}>Send to AI</affine-tooltip>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
|||||||
chatPanelRef.current.affineWorkspaceDialogService = framework.get(
|
chatPanelRef.current.affineWorkspaceDialogService = framework.get(
|
||||||
WorkspaceDialogService
|
WorkspaceDialogService
|
||||||
);
|
);
|
||||||
|
chatPanelRef.current.affineWorkbenchService =
|
||||||
|
framework.get(WorkbenchService);
|
||||||
|
|
||||||
containerRef.current?.append(chatPanelRef.current);
|
containerRef.current?.append(chatPanelRef.current);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user