mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 00:16:46 +00:00
fix(agents): correct source selection in the agent form, chat and uploads
selectedSourceIdsFromAgent preferred the extra sources over the legacy single source instead of merging them. Since every save writes both fields, opening an agent that held both and saving -- even just a rename -- silently detached the primary source. Retrieval reads both, so merge them. The edit form also reported unsaved changes the moment it loaded: the snapshot taken after the fetch defaulted the retriever to "classic" and left the default model empty, while the effects that run straight after rewrite both. Normalise the snapshot through the same serializer and rules the effects apply. A source attached to a team-shared agent that the caller cannot list -- the owner's private source -- rendered no picker row, so it showed as attached with nothing to switch it off. Append rows for selected ids missing from the list, and memoise the label lookup the trigger and the picker now share. Uploading from the agent builder repointed the open conversation onto the new source and persisted it, because the upload modal always dispatched the chat selection. Gate that behind a prop the builder opts out of. The wiki source is created synchronously and never refreshed the source list, so the builder selected an id the picker could not render and the trigger read "External KB"; refresh on every completion path that skips ingest tracking. Removing the synthetic "Default" source made an empty source list reachable, which the stored-selection reconciler skipped, leaving a deleted source checked forever with no way to clear it. Treat an empty list as a real answer and only skip when the list has not loaded. Finally, finish the picker extraction: the chat input still hand-rolled the item mapping, so team users saw the same sources grouped in the agent builder and flat in chat. Point it at the shared helper, with its own locale keys, and make the sources link a router navigation instead of a full page reload.
This commit is contained in:
1 parent
c26b8a3403
commit
25987c8947
15 files changed
+215
-72
No files matched your search
@@ -235,32 +235,45 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
// source list; fall back to the owner-resolved name embedded in the agent
|
||||
// payload (source_details) so a team member viewing a shared agent sees the
|
||||
// source name instead of "External KB"; only then show the generic label.
|
||||
const resolveSourceLabel = (id: string): string => {
|
||||
const matchedDoc = sourceDocs?.find(
|
||||
(source) => sourceItemId(source) === id,
|
||||
);
|
||||
if (matchedDoc?.name) return matchedDoc.name;
|
||||
const detail = agent.source_details?.find((d) => d.id === id);
|
||||
if (detail?.name) return detail.name;
|
||||
return t('agents.form.externalKb');
|
||||
};
|
||||
|
||||
const sourceItems = useMemo(
|
||||
() =>
|
||||
toSourcePickerItems(
|
||||
sourceDocs,
|
||||
{
|
||||
own: t('agents.form.sourcePopup.groupOwn'),
|
||||
team: t('agents.form.sourcePopup.groupTeam'),
|
||||
},
|
||||
SourceIcon,
|
||||
),
|
||||
[sourceDocs, t],
|
||||
const resolveSourceLabel = useCallback(
|
||||
(id: string): string => {
|
||||
const matchedDoc = sourceDocs?.find(
|
||||
(source) => sourceItemId(source) === id,
|
||||
);
|
||||
if (matchedDoc?.name) return matchedDoc.name;
|
||||
const detail = agent.source_details?.find((d) => d.id === id);
|
||||
if (detail?.name) return detail.name;
|
||||
return t('agents.form.externalKb');
|
||||
},
|
||||
[agent.source_details, sourceDocs, t],
|
||||
);
|
||||
|
||||
const selectedSourceNames = Array.from(selectedSourceIds)
|
||||
.map((id) => resolveSourceLabel(id))
|
||||
.filter(Boolean);
|
||||
const sourceItems = useMemo(() => {
|
||||
const items = toSourcePickerItems(
|
||||
sourceDocs,
|
||||
{
|
||||
own: t('agents.form.sourcePopup.groupOwn'),
|
||||
team: t('agents.form.sourcePopup.groupTeam'),
|
||||
},
|
||||
SourceIcon,
|
||||
);
|
||||
// An attached source the caller can't list — an owner's private source on
|
||||
// a team-shared agent — still needs a row of its own, or it reads as
|
||||
// selected with nothing to switch it off.
|
||||
const listed = new Set(items.map((item) => item.id));
|
||||
const unlisted = Array.from(selectedSourceIds)
|
||||
.filter((id) => !listed.has(id))
|
||||
.map((id) => ({ id, label: resolveSourceLabel(id), icon: SourceIcon }));
|
||||
return [...items, ...unlisted];
|
||||
}, [resolveSourceLabel, selectedSourceIds, sourceDocs, t]);
|
||||
|
||||
const selectedSourceNames = useMemo(
|
||||
() =>
|
||||
Array.from(selectedSourceIds)
|
||||
.map((id) => resolveSourceLabel(id))
|
||||
.filter(Boolean),
|
||||
[resolveSourceLabel, selectedSourceIds],
|
||||
);
|
||||
const sourceTriggerLabel =
|
||||
selectedSourceIds.size === 0
|
||||
? t('agents.form.placeholders.selectSources')
|
||||
@@ -667,7 +680,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
setSelectedSourceIds(new Set(selectedSourceIdsFromAgent(data)));
|
||||
const agentSourceIds = selectedSourceIdsFromAgent(data);
|
||||
setSelectedSourceIds(new Set(agentSourceIds));
|
||||
|
||||
if (data.tool_details) setSelectedTools(data.tool_details);
|
||||
if (data.status === 'draft') setEffectiveMode('draft');
|
||||
@@ -679,16 +693,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
// Backfill required fields so older agents (created before
|
||||
// agent_type / prompt_id / models existed) don't fail
|
||||
// ``isPublishable()`` and leave Save permanently disabled.
|
||||
// Normalise exactly as the source and model effects below will, or the
|
||||
// form compares unequal to this snapshot and reports unsaved changes
|
||||
// before the user has touched anything.
|
||||
const agentModels: string[] = data.models || [];
|
||||
const normalized = {
|
||||
...data,
|
||||
agent_type: data.agent_type || 'classic',
|
||||
prompt_id: data.prompt_id || 'default',
|
||||
retriever: data.retriever || 'classic',
|
||||
retriever: agentSourceIds.length === 0 ? 'classic' : '',
|
||||
chunks: data.chunks || '2',
|
||||
tools: data.tools || [],
|
||||
sources: data.sources || [],
|
||||
models: data.models || [],
|
||||
default_model_id: data.default_model_id || '',
|
||||
...serializeAgentSources(agentSourceIds, sourceDocs),
|
||||
models: agentModels,
|
||||
default_model_id: agentModels.includes(data.default_model_id || '')
|
||||
? data.default_model_id
|
||||
: agentModels[0] || '',
|
||||
config: data.config || {},
|
||||
};
|
||||
setAgent(normalized);
|
||||
@@ -1468,6 +1488,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
renderTab={null}
|
||||
close={() => setUploadModalState('INACTIVE')}
|
||||
onSuccessfulUpload={handleUploadedSource}
|
||||
selectUploadedDoc={false}
|
||||
/>
|
||||
)}
|
||||
<AddPromptModal
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -55,7 +56,7 @@ import {
|
||||
partitionAttachmentFiles,
|
||||
} from '../constants/fileUpload';
|
||||
import { UserToolType } from '../settings/types';
|
||||
import { sourceItemId } from '../utils/sourceUtils';
|
||||
import { sourceItemId, toSourcePickerItems } from '../utils/sourceUtils';
|
||||
import { isChatToolVisible } from '../utils/toolUtils';
|
||||
|
||||
const generateId = (): string =>
|
||||
@@ -1460,12 +1461,17 @@ export default function MessageInput({
|
||||
void _docs;
|
||||
};
|
||||
|
||||
const sourceItems: MultiSelectPopoverItem[] = (sourceDocs || []).map(
|
||||
(doc) => ({
|
||||
id: sourceItemId(doc),
|
||||
label: doc.name,
|
||||
icon: SourceIcon,
|
||||
}),
|
||||
const sourceItems: MultiSelectPopoverItem[] = useMemo(
|
||||
() =>
|
||||
toSourcePickerItems(
|
||||
sourceDocs,
|
||||
{
|
||||
own: t('conversation.sources.groupOwn'),
|
||||
team: t('conversation.sources.groupTeam'),
|
||||
},
|
||||
SourceIcon,
|
||||
),
|
||||
[sourceDocs, t],
|
||||
);
|
||||
|
||||
const selectedSourceIds = (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import RedirectIcon from '../assets/redirect.svg';
|
||||
import { Button } from './ui/button';
|
||||
@@ -18,14 +19,14 @@ export default function SourcesPopoverFooter({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<a
|
||||
href="/settings/sources"
|
||||
<Link
|
||||
to="/settings/sources"
|
||||
className="text-primary inline-flex items-center gap-2 text-base font-medium"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{t('settings.sources.goToSources')}
|
||||
<img src={RedirectIcon} alt="" aria-hidden="true" className="h-3 w-3" />
|
||||
</a>
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -1089,7 +1089,9 @@
|
||||
"link": "Quellen-Link",
|
||||
"view_more": "{{count}} weitere Quellen",
|
||||
"noSourcesAvailable": "Keine Quellen verfügbar",
|
||||
"selectedCount": "{{count}} Quellen ausgewählt"
|
||||
"selectedCount": "{{count}} Quellen ausgewählt",
|
||||
"groupOwn": "Deine Quellen",
|
||||
"groupTeam": "Mit dem Team geteilt"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "Anhängen",
|
||||
|
||||
@@ -1094,7 +1094,9 @@
|
||||
"link": "Source link",
|
||||
"view_more": "{{count}} more sources",
|
||||
"noSourcesAvailable": "No sources available",
|
||||
"selectedCount": "{{count}} sources selected"
|
||||
"selectedCount": "{{count}} sources selected",
|
||||
"groupOwn": "Your sources",
|
||||
"groupTeam": "Shared with team"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "Attach",
|
||||
|
||||
@@ -1089,7 +1089,9 @@
|
||||
"link": "Enlace fuente",
|
||||
"view_more": "Ver {{count}} más fuentes",
|
||||
"noSourcesAvailable": "No hay fuentes disponibles",
|
||||
"selectedCount": "{{count}} fuentes seleccionadas"
|
||||
"selectedCount": "{{count}} fuentes seleccionadas",
|
||||
"groupOwn": "Tus fuentes",
|
||||
"groupTeam": "Compartidas con el equipo"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "Adjuntar",
|
||||
|
||||
@@ -1089,7 +1089,9 @@
|
||||
"link": "ソースリンク",
|
||||
"view_more": "さらに{{count}}個のソース",
|
||||
"noSourcesAvailable": "利用可能なソースがありません",
|
||||
"selectedCount": "{{count}} 件のソースを選択中"
|
||||
"selectedCount": "{{count}} 件のソースを選択中",
|
||||
"groupOwn": "自分のソース",
|
||||
"groupTeam": "チームと共有"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "添付",
|
||||
|
||||
@@ -1089,7 +1089,9 @@
|
||||
"link": "Ссылка на источник",
|
||||
"view_more": "ещё {{count}} источников",
|
||||
"noSourcesAvailable": "Нет доступных источников",
|
||||
"selectedCount": "Выбрано источников: {{count}}"
|
||||
"selectedCount": "Выбрано источников: {{count}}",
|
||||
"groupOwn": "Ваши источники",
|
||||
"groupTeam": "Общие с командой"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "Прикрепить",
|
||||
|
||||
@@ -1089,7 +1089,9 @@
|
||||
"link": "來源連結",
|
||||
"view_more": "查看更多 {{count}} 個來源",
|
||||
"noSourcesAvailable": "沒有可用的來源",
|
||||
"selectedCount": "已選擇 {{count}} 個來源"
|
||||
"selectedCount": "已選擇 {{count}} 個來源",
|
||||
"groupOwn": "我的來源",
|
||||
"groupTeam": "團隊共享"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "附件",
|
||||
|
||||
@@ -1089,7 +1089,9 @@
|
||||
"link": "来源链接",
|
||||
"view_more": "还有{{count}}个来源",
|
||||
"noSourcesAvailable": "没有可用的来源",
|
||||
"selectedCount": "已选择 {{count}} 个来源"
|
||||
"selectedCount": "已选择 {{count}} 个来源",
|
||||
"groupOwn": "我的来源",
|
||||
"groupTeam": "团队共享"
|
||||
},
|
||||
"attachments": {
|
||||
"attach": "附件",
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Doc } from '../models/misc';
|
||||
import type { RootState } from '../store';
|
||||
import reducer, {
|
||||
clearRoles,
|
||||
prefListenerMiddleware,
|
||||
selectIsAdmin,
|
||||
selectRoles,
|
||||
selectRolesResolved,
|
||||
setRoles,
|
||||
setSourceDocs,
|
||||
} from './preferenceSlice';
|
||||
|
||||
const baseState = () => reducer(undefined, { type: '@@INIT' });
|
||||
@@ -47,3 +51,54 @@ describe('roles selectors', () => {
|
||||
expect(selectRolesResolved(stateWith(['user'], false))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const sourceDoc = (overrides: Partial<Doc>): Doc => ({
|
||||
name: 'doc',
|
||||
date: '2026-01-01',
|
||||
model: 'm',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const storeWithListener = () =>
|
||||
configureStore({
|
||||
reducer: { preference: reducer },
|
||||
middleware: (getDefault) =>
|
||||
getDefault().prepend(prefListenerMiddleware.middleware),
|
||||
});
|
||||
|
||||
describe('setSourceDocs reconciler', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('prunes a stored selection when the source list comes back empty', () => {
|
||||
const stored = [sourceDoc({ id: 'gone', name: 'Deleted' })];
|
||||
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify(stored));
|
||||
const store = storeWithListener();
|
||||
|
||||
store.dispatch(setSourceDocs([]));
|
||||
|
||||
expect(store.getState().preference.selectedDocs).toEqual([]);
|
||||
expect(localStorage.getItem('DocsGPTRecentDocs')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a stored selection that is still in the list', () => {
|
||||
const kept = sourceDoc({ id: 'kept', name: 'Kept' });
|
||||
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify([kept]));
|
||||
const store = storeWithListener();
|
||||
|
||||
store.dispatch(setSourceDocs([kept, sourceDoc({ id: 'other' })]));
|
||||
|
||||
expect(store.getState().preference.selectedDocs).toEqual([kept]);
|
||||
});
|
||||
|
||||
it('leaves the selection alone while the list has not loaded', () => {
|
||||
const stored = [sourceDoc({ id: 'pending' })];
|
||||
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify(stored));
|
||||
const store = storeWithListener();
|
||||
|
||||
store.dispatch(setSourceDocs(null));
|
||||
|
||||
expect(localStorage.getItem('DocsGPTRecentDocs')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -214,14 +214,12 @@ prefListenerMiddleware.startListening({
|
||||
effect: (_action, listenerApi) => {
|
||||
const state = listenerApi.getState() as RootState;
|
||||
const sourceDocs = state.preference.sourceDocs;
|
||||
if (sourceDocs && sourceDocs.length > 0) {
|
||||
const validatedDocs = getLocalRecentDocs(sourceDocs);
|
||||
if (validatedDocs !== null) {
|
||||
listenerApi.dispatch(setSelectedDocs(validatedDocs));
|
||||
} else {
|
||||
listenerApi.dispatch(setSelectedDocs([]));
|
||||
}
|
||||
}
|
||||
// ``null`` means the list hasn't loaded; an empty array is a real answer
|
||||
// and still has to prune a stored selection, or a deleted last source
|
||||
// stays checked forever.
|
||||
if (!sourceDocs) return;
|
||||
const validatedDocs = getLocalRecentDocs(sourceDocs);
|
||||
listenerApi.dispatch(setSelectedDocs(validatedDocs ?? []));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ function Upload({
|
||||
renderTab = null,
|
||||
close,
|
||||
onSuccessfulUpload = () => undefined,
|
||||
selectUploadedDoc = true,
|
||||
}: {
|
||||
receivedFile: File[];
|
||||
setModalState: (state: ActiveState) => void;
|
||||
@@ -69,6 +70,12 @@ function Upload({
|
||||
close: () => void;
|
||||
/** Fires once the upload is ingested, with the id of the source it created. */
|
||||
onSuccessfulUpload?: (sourceId?: string) => void;
|
||||
/**
|
||||
* Whether a finished upload also becomes the chat's selected source. Callers
|
||||
* that only need the new source's id (the agent builder) pass ``false`` so
|
||||
* uploading never repoints the conversation the user left open.
|
||||
*/
|
||||
selectUploadedDoc?: boolean;
|
||||
}) {
|
||||
const token = useSelector(selectToken);
|
||||
const selectedDocs = useSelector(selectSelectedDocs);
|
||||
@@ -431,6 +438,32 @@ function Upload({
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
/** Re-read the source list into the store so a new source is pickable. */
|
||||
const refreshSourceDocs = useCallback(
|
||||
() =>
|
||||
getDocs(token).then((docs) => {
|
||||
dispatch(setSourceDocs(docs));
|
||||
return docs;
|
||||
}),
|
||||
[dispatch, token],
|
||||
);
|
||||
|
||||
/**
|
||||
* Finish an upload that never got an ingest task (the wiki source is created
|
||||
* synchronously). The list still has to be refreshed, or the caller selects
|
||||
* an id no picker can render.
|
||||
*/
|
||||
const finishUntrackedUpload = useCallback(
|
||||
(sourceId?: string) => {
|
||||
refreshSourceDocs()
|
||||
.catch((err) => {
|
||||
console.error('Post-upload source-list refresh failed:', err);
|
||||
})
|
||||
.finally(() => onSuccessfulUpload?.(sourceId));
|
||||
},
|
||||
[onSuccessfulUpload, refreshSourceDocs],
|
||||
);
|
||||
|
||||
/**
|
||||
* Wait for the source.ingest.* SSE pipeline to flip this task into a
|
||||
* terminal state, then run the post-completion side effects: refresh
|
||||
@@ -450,10 +483,9 @@ function Upload({
|
||||
if (handled) return;
|
||||
handled = true;
|
||||
if (status !== 'completed') return;
|
||||
getDocs(token)
|
||||
refreshSourceDocs()
|
||||
.then((docs) => {
|
||||
dispatch(setSourceDocs(docs));
|
||||
if (Array.isArray(docs)) {
|
||||
if (selectUploadedDoc && Array.isArray(docs)) {
|
||||
const existingDocIds = new Set(
|
||||
(Array.isArray(sourceDocs) ? sourceDocs : [])
|
||||
.map((doc: Doc) => doc?.id)
|
||||
@@ -540,7 +572,15 @@ function Upload({
|
||||
}
|
||||
});
|
||||
},
|
||||
[dispatch, onSuccessfulUpload, selectedDocs, sourceDocs, store, token],
|
||||
[
|
||||
dispatch,
|
||||
onSuccessfulUpload,
|
||||
refreshSourceDocs,
|
||||
selectedDocs,
|
||||
selectUploadedDoc,
|
||||
sourceDocs,
|
||||
store,
|
||||
],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
@@ -630,7 +670,7 @@ function Upload({
|
||||
updates: { status: 'completed', progress: 100 },
|
||||
}),
|
||||
);
|
||||
onSuccessfulUpload?.(parsed.source_id);
|
||||
finishUntrackedUpload(parsed.source_id);
|
||||
}
|
||||
} catch (error) {
|
||||
handleTaskFailure(clientTaskId);
|
||||
@@ -766,7 +806,7 @@ function Upload({
|
||||
updates: { status: 'completed', progress: 100 },
|
||||
}),
|
||||
);
|
||||
onSuccessfulUpload?.(response.source_id);
|
||||
finishUntrackedUpload(response.source_id);
|
||||
}
|
||||
} catch (error) {
|
||||
handleTaskFailure(clientTaskId);
|
||||
@@ -817,7 +857,7 @@ function Upload({
|
||||
},
|
||||
}),
|
||||
);
|
||||
onSuccessfulUpload?.(data.source_id);
|
||||
finishUntrackedUpload(data.source_id);
|
||||
})
|
||||
.catch(() => handleTaskFailure(clientTaskId));
|
||||
};
|
||||
|
||||
@@ -115,9 +115,15 @@ describe('serializeAgentSources', () => {
|
||||
});
|
||||
|
||||
describe('selectedSourceIdsFromAgent', () => {
|
||||
it('prefers the sources list over the single source', () => {
|
||||
it('merges the single source with the sources list', () => {
|
||||
expect(
|
||||
selectedSourceIdsFromAgent({ source: 'x', sources: ['a', 'b'] }),
|
||||
).toEqual(['x', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('keeps a source named in both fields once', () => {
|
||||
expect(
|
||||
selectedSourceIdsFromAgent({ source: 'a', sources: ['a', 'b'] }),
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
|
||||
@@ -56,18 +56,20 @@ export function serializeAgentSources(
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected source ids for a stored agent. ``sources`` wins over the legacy
|
||||
* single ``source``; the legacy ``"default"`` placeholder means no source.
|
||||
* Selected source ids for a stored agent: the legacy single ``source`` and the
|
||||
* ``sources`` list merged, because retrieval reads both. Preferring one over
|
||||
* the other would drop the omitted field on the next save. The legacy
|
||||
* ``"default"`` placeholder means no source.
|
||||
*/
|
||||
export function selectedSourceIdsFromAgent(agent: {
|
||||
source?: string;
|
||||
sources?: string[];
|
||||
}): string[] {
|
||||
const ids =
|
||||
agent.sources && agent.sources.length > 0
|
||||
? agent.sources
|
||||
: agent.source
|
||||
? [agent.source]
|
||||
: [];
|
||||
return ids.filter((id) => Boolean(id) && id !== 'default');
|
||||
const ids = [
|
||||
...(agent.source ? [agent.source] : []),
|
||||
...(agent.sources ?? []),
|
||||
];
|
||||
return Array.from(new Set(ids)).filter(
|
||||
(id) => Boolean(id) && id !== 'default',
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user