mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-26 17:54:14 +00:00
feat: synthetic widgets getter for SubgraphNode (proxy-widget-v2) (#8856)
## Summary Replace the Proxy-based proxy widget system with a store-driven architecture where `promotionStore` and `widgetValueStore` are the single sources of truth for subgraph widget promotion and widget values, and `SubgraphNode.widgets` is a synthetic getter composing lightweight `PromotedWidgetView` objects from store state. ## Motivation The subgraph widget promotion system previously scattered state across multiple unsynchronized layers: - **Persistence**: `node.properties.proxyWidgets` (tuples on the LiteGraph node) - **Runtime**: Proxy-based `proxyWidget.ts` with `Overlay` objects, `DisconnectedWidget` singleton, and `isProxyWidget` type guards - **UI**: Each Vue component independently calling `parseProxyWidgets()` via `customRef` hacks - **Mutation flags**: Imperative `widget.promoted = true/false` set on `subgraph-opened` events This led to 4+ independent parsings of the same data, complex cache invalidation, and no reactive contract between the promotion state and the rendering layer. Widget values were similarly owned by LiteGraph with no Vue-reactive backing. The core principle driving these changes: **Vue owns truth**. Pinia stores are the canonical source; LiteGraph objects delegate to stores via getters/setters; Vue components react to store state directly. ## Changes ### New stores (single sources of truth) - **`promotionStore`** — Reactive `Map<NodeId, PromotionEntry[]>` tracking which interior widgets are promoted on which SubgraphNode instances. Graph-scoped by root graph ID to prevent cross-workflow state collision. Replaces `properties.proxyWidgets` parsing, `customRef` hacks, `widget.promoted` mutation, and the `subgraph-opened` event listener. - **`widgetValueStore`** — Graph-scoped `Map<WidgetKey, WidgetState>` that is the canonical owner of widget values. `BaseWidget.value` delegates to this store via getter/setter when a node ID is assigned. Eliminates the need for Proxy-based value forwarding. ### Synthetic widgets getter (SubgraphNode) `SubgraphNode.widgets` is now a getter that reads `promotionStore.getPromotions(rootGraphId, nodeId)` and returns cached `PromotedWidgetView` objects. No stubs, no Proxies, no fake widgets persisted in the array. The setter is a no-op — mutations go through `promotionStore`. ### PromotedWidgetView A class behind a `createPromotedWidgetView` factory, implementing the `PromotedWidgetView` interface. Delegates value/type/options/drawing to the resolved interior widget and stores. Owns positional state (`y`, `computedHeight`) for canvas layout. Cached by `PromotedWidgetViewManager` for object-identity stability across frames. ### DOM widget promotion Promoted DOM widgets (textarea, image upload, etc.) render on the SubgraphNode surface via `positionOverride` in `domWidgetStore`. `DomWidgets.vue` checks for overrides and uses the SubgraphNode's coordinates instead of the interior node's. ### Promoted previews New `usePromotedPreviews` composable resolves image/audio/video preview widgets from promoted entries, enabling SubgraphNodes to display previews of interior preview nodes. ### Deleted - `proxyWidget.ts` (257 lines) — Proxy handler, `Overlay`, `newProxyWidget`, `isProxyWidget` - `DisconnectedWidget.ts` (39 lines) — Singleton Proxy target - `useValueTransform.ts` (32 lines) — Replaced by store delegation ### Key architectural changes - `BaseWidget.value` getter/setter delegates to `widgetValueStore` when node ID is set - `LGraph.add()` reordered: `node.graph` assigned before widget `setNodeId` (enables store registration) - `LGraph.clear()` cleans up graph-scoped stores to prevent stale entries across workflow switches - `promotionStore` and `widgetValueStore` state nested under root graph UUID for multi-workflow isolation - `SubgraphNode.serialize()` writes promotions back to `properties.proxyWidgets` for persistence compatibility - Legacy `-1` promotion entries resolved and migrated on first load with dev warning ## Test coverage - **3,700+ lines of new/updated tests** across 36 test files - **Unit**: `promotionStore.test.ts`, `widgetValueStore.test.ts`, `promotedWidgetView.test.ts` (921 lines), `subgraphNodePromotion.test.ts`, `proxyWidgetUtils.test.ts`, `DomWidgets.test.ts`, `PromotedWidgetViewManager.test.ts`, `usePromotedPreviews.test.ts`, `resolvePromotedWidget.test.ts`, `subgraphPseudoWidgetCache.test.ts` - **E2E**: `subgraphPromotion.spec.ts` (622 lines) — promote/demote, manual/auto promotion, paste preservation, seed control augmentation, image preview promotion; `imagePreview.spec.ts` extended with multi-promoted-preview coverage - **Fixtures**: 2 new subgraph workflow fixtures for preview promotion scenarios ## Review focus - Graph-scoped store keying (`rootGraphId`) — verify isolation across workflows/tabs and cleanup on `LGraph.clear()` - `PromotedWidgetView` positional stability — `_arrangeWidgets` writes to `y`/`computedHeight` on cached objects; getter returns fresh array but stable object references - DOM widget position override lifecycle — overrides set on promote, cleared on demote/removal/subgraph navigation - Legacy `-1` entry migration — resolved and written back on first load; unresolvable entries dropped with dev warning - Serialization round-trip — `promotionStore` state → `properties.proxyWidgets` on serialize, hydrated back on configure ## Diff breakdown (excluding lockfile) - 153 files changed, ~7,500 insertions, ~1,900 deletions (excluding pnpm-lock.yaml churn) - ~3,700 lines are tests - ~300 lines deleted (proxyWidget.ts, DisconnectedWidget.ts, useValueTransform.ts) <!-- Fixes #ISSUE_NUMBER --> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8856-feat-synthetic-widgets-getter-for-SubgraphNode-proxy-widget-v2-3076d73d365081c7b517f5ec7cb514f3) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -6,7 +6,10 @@ import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations
|
||||
import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage'
|
||||
import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview'
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { addWidgetPromotionOptions } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import {
|
||||
addWidgetPromotionOptions,
|
||||
isPreviewPseudoWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { applyDynamicInputs } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import { st, t } from '@/i18n'
|
||||
import {
|
||||
@@ -37,6 +40,8 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { resolveSubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
|
||||
import type { SubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import type {
|
||||
ComfyNodeDef as ComfyNodeDefV2,
|
||||
@@ -51,6 +56,7 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -127,6 +133,30 @@ export const useLitegraphService = () => {
|
||||
const widgetStore = useWidgetStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const subgraphPseudoWidgetCache = new WeakMap<
|
||||
SubgraphNode,
|
||||
SubgraphPseudoWidgetCache<LGraphNode, IBaseWidget>
|
||||
>()
|
||||
|
||||
function invalidateSubgraphPseudoWidgetCache(node: SubgraphNode) {
|
||||
subgraphPseudoWidgetCache.delete(node)
|
||||
}
|
||||
|
||||
function getPseudoWidgetPreviewTargets(node: SubgraphNode): LGraphNode[] {
|
||||
const promotionStore = usePromotionStore()
|
||||
const promotions = promotionStore.getPromotionsRef(
|
||||
node.rootGraph.id,
|
||||
node.id
|
||||
)
|
||||
const resolved = resolveSubgraphPseudoWidgetCache({
|
||||
cache: subgraphPseudoWidgetCache.get(node) ?? null,
|
||||
promotions,
|
||||
getNodeById: (nodeId) => node.subgraph.getNodeById(nodeId) ?? undefined,
|
||||
isPreviewPseudoWidget
|
||||
})
|
||||
subgraphPseudoWidgetCache.set(node, resolved.cache)
|
||||
return resolved.nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
@@ -317,6 +347,7 @@ export const useLitegraphService = () => {
|
||||
|
||||
// Set up event listener for promoted widget registration
|
||||
subgraph.events.addEventListener('widget-promoted', (event) => {
|
||||
invalidateSubgraphPseudoWidgetCache(this)
|
||||
const { widget } = event.detail
|
||||
// Only handle DOM widgets
|
||||
if (!isDOMWidget(widget) && !isComponentWidget(widget)) return
|
||||
@@ -336,6 +367,7 @@ export const useLitegraphService = () => {
|
||||
|
||||
// Set up event listener for promoted widget removal
|
||||
subgraph.events.addEventListener('widget-demoted', (event) => {
|
||||
invalidateSubgraphPseudoWidgetCache(this)
|
||||
const { widget } = event.detail
|
||||
// Only handle DOM widgets
|
||||
if (!isDOMWidget(widget) && !isComponentWidget(widget)) return
|
||||
@@ -794,6 +826,15 @@ export const useLitegraphService = () => {
|
||||
}
|
||||
node.prototype.onDrawBackground = function () {
|
||||
updatePreviews(this)
|
||||
|
||||
if (this instanceof SubgraphNode) {
|
||||
const parentGraph = this.graph
|
||||
for (const interiorNode of getPseudoWidgetPreviewTargets(this)) {
|
||||
updatePreviews(interiorNode, () => {
|
||||
parentGraph?.setDirtyCanvas(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
167
src/services/subgraphPseudoWidgetCache.test.ts
Normal file
167
src/services/subgraphPseudoWidgetCache.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { resolveSubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
|
||||
import type {
|
||||
SubgraphPseudoWidget,
|
||||
SubgraphPseudoWidgetCache,
|
||||
SubgraphPseudoWidgetNode,
|
||||
SubgraphPromotionEntry
|
||||
} from '@/services/subgraphPseudoWidgetCache'
|
||||
|
||||
interface TestWidget extends SubgraphPseudoWidget {
|
||||
isPseudo?: boolean
|
||||
}
|
||||
|
||||
interface TestNode extends SubgraphPseudoWidgetNode<TestWidget> {
|
||||
widgets?: TestWidget[]
|
||||
}
|
||||
|
||||
function widget(name: string, isPseudo = false): TestWidget {
|
||||
return { name, isPseudo }
|
||||
}
|
||||
|
||||
function node(id: string, widgets: TestWidget[] = []): TestNode {
|
||||
return { id, widgets }
|
||||
}
|
||||
|
||||
describe('resolveSubgraphPseudoWidgetCache', () => {
|
||||
it('builds update targets from pseudo widget promotions', () => {
|
||||
const interiorNode = node('n1', [widget('preview', true)])
|
||||
const getNodeById = vi.fn((id: string) =>
|
||||
id === 'n1' ? interiorNode : undefined
|
||||
)
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
]
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.nodes).toEqual([interiorNode])
|
||||
expect(result.cache.entries).toHaveLength(1)
|
||||
expect(getNodeById).toHaveBeenCalledWith('n1')
|
||||
})
|
||||
|
||||
it('keeps $$ fallback behavior when the backing widget is missing', () => {
|
||||
const interiorNode = node('n1', [widget('other')])
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: '$$canvas-image-preview' }
|
||||
]
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.nodes).toEqual([interiorNode])
|
||||
})
|
||||
|
||||
it('reuses cache when promotions and node identities are unchanged', () => {
|
||||
const interiorNode = node('n1', [widget('preview', true)])
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
]
|
||||
const base = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
const getNodeById = vi.fn((id: string) =>
|
||||
id === 'n1' ? interiorNode : undefined
|
||||
)
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: base.cache,
|
||||
promotions,
|
||||
getNodeById,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.cache).toBe(base.cache)
|
||||
expect(result.nodes).toBe(base.nodes)
|
||||
expect(getNodeById).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rebuilds cache when promotions reference changes', () => {
|
||||
const interiorNode = node('n1', [widget('preview', true)])
|
||||
const promotionsA: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
]
|
||||
const base = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions: promotionsA,
|
||||
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
const promotionsB: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
]
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: base.cache,
|
||||
promotions: promotionsB,
|
||||
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.cache).not.toBe(base.cache)
|
||||
})
|
||||
|
||||
it('falls back to rebuild when a cached node reference goes stale', () => {
|
||||
const oldNode = node('n1', [widget('preview', true)])
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'n1', widgetName: 'preview' }
|
||||
]
|
||||
const initial = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: null,
|
||||
promotions,
|
||||
getNodeById: () => oldNode,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
const newNode = node('n1', [widget('preview', true)])
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache: initial.cache,
|
||||
promotions,
|
||||
getNodeById: () => newNode,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.cache).not.toBe(initial.cache)
|
||||
expect(result.nodes).toEqual([newNode])
|
||||
})
|
||||
|
||||
it('drops cached entries when node no longer resolves', () => {
|
||||
const promotions: readonly SubgraphPromotionEntry[] = [
|
||||
{ interiorNodeId: 'missing', widgetName: '$$canvas-image-preview' }
|
||||
]
|
||||
const cache: SubgraphPseudoWidgetCache<TestNode, TestWidget> = {
|
||||
promotions,
|
||||
entries: [
|
||||
{
|
||||
interiorNodeId: 'missing',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
node: node('missing')
|
||||
}
|
||||
],
|
||||
nodes: [node('missing')]
|
||||
}
|
||||
|
||||
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
|
||||
cache,
|
||||
promotions,
|
||||
getNodeById: () => undefined,
|
||||
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
|
||||
})
|
||||
|
||||
expect(result.nodes).toEqual([])
|
||||
expect(result.cache.entries).toEqual([])
|
||||
})
|
||||
})
|
||||
120
src/services/subgraphPseudoWidgetCache.ts
Normal file
120
src/services/subgraphPseudoWidgetCache.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
export interface SubgraphPromotionEntry {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
export interface SubgraphPseudoWidget {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SubgraphPseudoWidgetNode<
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
id: string | number
|
||||
widgets?: TWidget[]
|
||||
}
|
||||
|
||||
interface SubgraphPseudoWidgetCacheEntry<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
node: TNode
|
||||
}
|
||||
|
||||
export interface SubgraphPseudoWidgetCache<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
promotions: readonly SubgraphPromotionEntry[]
|
||||
entries: SubgraphPseudoWidgetCacheEntry<TNode, TWidget>[]
|
||||
nodes: TNode[]
|
||||
}
|
||||
|
||||
interface ResolveSubgraphPseudoWidgetCacheArgs<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
cache: SubgraphPseudoWidgetCache<TNode, TWidget> | null
|
||||
promotions: readonly SubgraphPromotionEntry[]
|
||||
getNodeById: (nodeId: string) => TNode | undefined
|
||||
isPreviewPseudoWidget: (widget: TWidget) => boolean
|
||||
}
|
||||
|
||||
interface ResolveSubgraphPseudoWidgetCacheResult<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
> {
|
||||
cache: SubgraphPseudoWidgetCache<TNode, TWidget>
|
||||
nodes: TNode[]
|
||||
}
|
||||
|
||||
function isPseudoPromotion<TWidget extends SubgraphPseudoWidget>(
|
||||
widgetName: string,
|
||||
widgets: TWidget[] | undefined,
|
||||
isPreviewPseudoWidget: (widget: TWidget) => boolean
|
||||
): boolean {
|
||||
const widget = widgets?.find((candidate) => candidate.name === widgetName)
|
||||
if (widget) return isPreviewPseudoWidget(widget)
|
||||
return widgetName.startsWith('$$')
|
||||
}
|
||||
|
||||
function isCacheStillValid<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
>(
|
||||
cache: SubgraphPseudoWidgetCache<TNode, TWidget>,
|
||||
getNodeById: (nodeId: string) => TNode | undefined,
|
||||
isPreviewPseudoWidget: (widget: TWidget) => boolean
|
||||
): boolean {
|
||||
return cache.entries.every((entry) => {
|
||||
const currentNode = getNodeById(entry.interiorNodeId)
|
||||
if (!currentNode || currentNode !== entry.node) return false
|
||||
return isPseudoPromotion(
|
||||
entry.widgetName,
|
||||
currentNode.widgets,
|
||||
isPreviewPseudoWidget
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveSubgraphPseudoWidgetCache<
|
||||
TNode extends SubgraphPseudoWidgetNode<TWidget>,
|
||||
TWidget extends SubgraphPseudoWidget
|
||||
>(
|
||||
args: ResolveSubgraphPseudoWidgetCacheArgs<TNode, TWidget>
|
||||
): ResolveSubgraphPseudoWidgetCacheResult<TNode, TWidget> {
|
||||
const { cache, promotions, getNodeById, isPreviewPseudoWidget } = args
|
||||
|
||||
if (
|
||||
cache &&
|
||||
cache.promotions === promotions &&
|
||||
isCacheStillValid(cache, getNodeById, isPreviewPseudoWidget)
|
||||
)
|
||||
return { cache, nodes: cache.nodes }
|
||||
|
||||
const entries = promotions.flatMap((promotion) => {
|
||||
const node = getNodeById(promotion.interiorNodeId)
|
||||
if (!node) return []
|
||||
if (
|
||||
!isPseudoPromotion(
|
||||
promotion.widgetName,
|
||||
node.widgets,
|
||||
isPreviewPseudoWidget
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ ...promotion, node }]
|
||||
})
|
||||
|
||||
const nextCache: SubgraphPseudoWidgetCache<TNode, TWidget> = {
|
||||
promotions,
|
||||
entries,
|
||||
nodes: entries.map((entry) => entry.node)
|
||||
}
|
||||
return {
|
||||
cache: nextCache,
|
||||
nodes: nextCache.nodes
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user