feat: simplify paths
This commit is contained in:
@@ -1,36 +1,24 @@
|
||||
/**
|
||||
* Connection status: visual state of an edge (color/class).
|
||||
* Priority when multiple apply: error > paused > updating > default.
|
||||
*
|
||||
* State flow: nodes report lifecycle via useSyncConnectionStatus() (nodeLifecycle.ts) → FlowContext
|
||||
* holds the sets → edges pass those sets into getConnectionStatus() here. See lib/graph/state.ts.
|
||||
* Priority: error > updating > default.
|
||||
*/
|
||||
|
||||
export type ConnectionStatus = 'default' | 'updating' | 'paused' | 'error'
|
||||
export type ConnectionStatus = 'default' | 'updating' | 'error'
|
||||
|
||||
export type ConnectionStatusInputs = {
|
||||
source: string
|
||||
target: string
|
||||
pathNodeIds: Set<string>
|
||||
pausedSegmentNodeIds: Set<string>
|
||||
activeSegmentNodeIds: Set<string>
|
||||
errorTargetNodeIds: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the single connection status for an edge (priority: error > paused > updating > default).
|
||||
* Use in edge components; add new statuses by extending the type and adding a branch here.
|
||||
* Compute the single connection status for an edge (priority: error > updating > default).
|
||||
*/
|
||||
export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionStatus {
|
||||
const { target, pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds, errorTargetNodeIds, source } = inputs
|
||||
const { target, pathNodeIds, activeSegmentNodeIds, errorTargetNodeIds, source } = inputs
|
||||
if (errorTargetNodeIds.has(target)) return 'error'
|
||||
if (
|
||||
pathNodeIds.has(source) &&
|
||||
pathNodeIds.has(target) &&
|
||||
pausedSegmentNodeIds.has(source) &&
|
||||
pausedSegmentNodeIds.has(target)
|
||||
)
|
||||
return 'paused'
|
||||
if (
|
||||
pathNodeIds.has(source) &&
|
||||
pathNodeIds.has(target) &&
|
||||
@@ -45,6 +33,5 @@ export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionS
|
||||
export const CONNECTION_STATUS_CLASS: Record<ConnectionStatus, string> = {
|
||||
default: '',
|
||||
updating: 'animated-edge-path--updating',
|
||||
paused: 'animated-edge-path--paused',
|
||||
error: 'animated-edge-path--error',
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import type { AppNode, AppEdge } from './nodeTypes'
|
||||
|
||||
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
||||
|
||||
/** Role of a node in the current connection path update. */
|
||||
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path'
|
||||
/** Role of a node in the current connection path. */
|
||||
export type ConnectionPathRole = 'trigger' | 'on-path'
|
||||
|
||||
export type FlowActions = {
|
||||
pasteAtViewportCenter: () => void
|
||||
@@ -108,11 +108,9 @@ export function useConnectionPathRole(nodeId: string | undefined): ConnectionPat
|
||||
return useMemo(() => {
|
||||
if (!nodeId) return null
|
||||
const triggers = ctx?.connectionPathTriggerNodeIds
|
||||
const updating = ctx?.connectionPathUpdatingNodeIds
|
||||
const path = ctx?.connectionPathNodeIds
|
||||
if (!path?.has(nodeId)) return null
|
||||
if (triggers?.includes(nodeId)) return 'trigger'
|
||||
if (updating?.includes(nodeId)) return 'updating'
|
||||
return 'on-path'
|
||||
}, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathUpdatingNodeIds, ctx?.connectionPathNodeIds])
|
||||
}, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathNodeIds])
|
||||
}
|
||||
|
||||
@@ -1,123 +1,14 @@
|
||||
/**
|
||||
* Graph path utilities: compute which nodes/edges are "on the path" of an update.
|
||||
* Used to show connection ant trail only along the full chain (upstream → updating → downstream).
|
||||
* Works with any node types; any node can signal it is updating via startConnectionPathUpdate(id).
|
||||
* @deprecated Use graphologyPath.ts instead. Path derivation now uses graphology
|
||||
* (DirectedGraph + BFS) for traversal. This file re-exports from graphologyPath
|
||||
* for backward compatibility and will be removed in a future version.
|
||||
*
|
||||
* @see graphologyPath.ts
|
||||
*/
|
||||
|
||||
export type GraphEdge = { source: string; target: string }
|
||||
|
||||
/** Nodes reachable from seedIds by following edges forward (source → target). */
|
||||
export function getDownstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
|
||||
const out = new Set<string>(seedIds)
|
||||
let added = true
|
||||
while (added) {
|
||||
added = false
|
||||
for (const e of edges) {
|
||||
if (out.has(e.source) && !out.has(e.target)) {
|
||||
out.add(e.target)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Nodes that can reach any seed by following edges backward (target → source). */
|
||||
export function getUpstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
|
||||
const out = new Set<string>(seedIds)
|
||||
let added = true
|
||||
while (added) {
|
||||
added = false
|
||||
for (const e of edges) {
|
||||
if (out.has(e.target) && !out.has(e.source)) {
|
||||
out.add(e.source)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* All node ids that lie on the path of an update.
|
||||
* - If updatingNodeIds non-empty: path = downstream(trigger) ∩ (upstream(updating) ∪ downstream(updating))
|
||||
* so we include config→agent→rendering when agent is running.
|
||||
* - Else if triggerNodeIds and pausedNodeIds non-empty: path = downstream(trigger) ∩ upstream(paused)
|
||||
* so we show yellow (config→agent) when config changed and agent is on hold.
|
||||
* An edge should show color iff both its source and target are in this set.
|
||||
*/
|
||||
export function getPathNodeIds(
|
||||
edges: GraphEdge[],
|
||||
updatingNodeIds: string[],
|
||||
triggerNodeIds?: string[],
|
||||
pausedNodeIds?: string[]
|
||||
): Set<string> {
|
||||
const hasUpdating = updatingNodeIds.length > 0
|
||||
const hasPausedPath =
|
||||
pausedNodeIds != null &&
|
||||
pausedNodeIds.length > 0 &&
|
||||
triggerNodeIds != null &&
|
||||
triggerNodeIds.length > 0
|
||||
|
||||
if (hasUpdating && triggerNodeIds != null && triggerNodeIds.length > 0) {
|
||||
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds)
|
||||
const upstreamOfUpdating = getUpstreamNodeIds(edges, updatingNodeIds)
|
||||
const downstreamOfUpdating = getDownstreamNodeIds(edges, updatingNodeIds)
|
||||
const path = new Set<string>()
|
||||
downstreamOfTrigger.forEach((id) => {
|
||||
if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id)
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
if (hasPausedPath && !hasUpdating) {
|
||||
const upstream = getUpstreamNodeIds(edges, pausedNodeIds!)
|
||||
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds!)
|
||||
const path = new Set<string>()
|
||||
upstream.forEach((id) => {
|
||||
if (downstreamOfTrigger.has(id)) path.add(id)
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
if (hasUpdating) {
|
||||
const upstream = getUpstreamNodeIds(edges, updatingNodeIds)
|
||||
const downstream = getDownstreamNodeIds(edges, updatingNodeIds)
|
||||
const path = new Set<string>(upstream)
|
||||
downstream.forEach((id) => path.add(id))
|
||||
return path
|
||||
}
|
||||
|
||||
return new Set()
|
||||
}
|
||||
|
||||
/**
|
||||
* Path nodes from triggers up to and including the first paused node (e.g. Renderer waiting for Run).
|
||||
* Used to color those edges yellow; rest of path stays blue.
|
||||
*/
|
||||
export function getPausedSegmentNodeIds(
|
||||
edges: GraphEdge[],
|
||||
pathNodeIds: Set<string>,
|
||||
triggerNodeIds: string[],
|
||||
pausedNodeIds: string[]
|
||||
): Set<string> {
|
||||
if (pausedNodeIds.length === 0 || triggerNodeIds.length === 0) return new Set()
|
||||
const pausedSet = new Set(pausedNodeIds)
|
||||
const seeds = triggerNodeIds.filter((id) => pathNodeIds.has(id))
|
||||
if (seeds.length === 0) return new Set()
|
||||
const out = new Set<string>(seeds)
|
||||
const frontier: string[] = [...seeds]
|
||||
const visited = new Set<string>(seeds)
|
||||
while (frontier.length > 0) {
|
||||
const n = frontier.shift()!
|
||||
if (pausedSet.has(n)) continue
|
||||
for (const e of edges) {
|
||||
if (e.source !== n || !pathNodeIds.has(e.target) || visited.has(e.target)) continue
|
||||
visited.add(e.target)
|
||||
out.add(e.target)
|
||||
if (pausedSet.has(e.target)) continue
|
||||
frontier.push(e.target)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
export type { GraphEdge } from './graphologyPath'
|
||||
export {
|
||||
getPathNodeIds,
|
||||
getPathToUpdatingSegmentNodeIds,
|
||||
clearGraphologyPathCache,
|
||||
} from './graphologyPath'
|
||||
|
||||
201
frontend/src/lib/graph/graphologyPath.ts
Normal file
201
frontend/src/lib/graph/graphologyPath.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Path derivation for connection status using graphology.
|
||||
* Builds a DirectedGraph from canvas edges (with optional node attributes),
|
||||
* uses BFS (outbound/inbound) to compute path sets for propagation animation.
|
||||
*
|
||||
* @see https://graphology.github.io/
|
||||
* @see https://graphology.github.io/standard-library/traversal.html
|
||||
*/
|
||||
|
||||
import DirectedGraph from 'graphology'
|
||||
import { bfsFromNode } from 'graphology-traversal'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GraphEdge = { source: string; target: string; id?: string }
|
||||
|
||||
/** Optional attributes stored on graphology nodes (e.g. for sink detection). */
|
||||
export type GraphologyNodeAttributes = {
|
||||
nodeType?: string
|
||||
updateMode?: 'auto' | 'manual'
|
||||
}
|
||||
|
||||
/** Optional: pass when building so graph has node type/updateMode for future use. */
|
||||
export type NodeAttributesMap = Record<string, GraphologyNodeAttributes>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph build (with cache)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedEdgesRef: GraphEdge[] | null = null
|
||||
let cachedGraph: DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }> | null =
|
||||
null
|
||||
|
||||
function buildGraph(
|
||||
edges: GraphEdge[],
|
||||
nodeAttributes?: NodeAttributesMap
|
||||
): DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }> {
|
||||
if (edges === cachedEdgesRef && cachedGraph !== null) return cachedGraph
|
||||
|
||||
const graph = new DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }>()
|
||||
const nodeIds = new Set<string>()
|
||||
for (const e of edges) {
|
||||
nodeIds.add(e.source)
|
||||
nodeIds.add(e.target)
|
||||
}
|
||||
for (const id of nodeIds) {
|
||||
const attrs: GraphologyNodeAttributes = { ...nodeAttributes?.[id] }
|
||||
graph.mergeNode(id, attrs)
|
||||
}
|
||||
for (const e of edges) {
|
||||
const key = e.id ?? `${e.source}->${e.target}`
|
||||
if (!graph.hasEdge(e.source, e.target)) {
|
||||
graph.addEdgeWithKey(key, e.source, e.target, {
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
edgeId: e.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
cachedEdgesRef = edges
|
||||
cachedGraph = graph
|
||||
return graph
|
||||
}
|
||||
|
||||
/** Call when graph structure changes from outside (e.g. store reset) to clear cache. */
|
||||
export function clearGraphologyPathCache(): void {
|
||||
cachedEdgesRef = null
|
||||
cachedGraph = null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Traversal helpers (BFS via graphology-traversal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Outbound BFS: collect all node ids reachable from seeds following edges forward (source → target). */
|
||||
function getDownstreamNodeIds(
|
||||
graph: DirectedGraph,
|
||||
seedIds: string[]
|
||||
): Set<string> {
|
||||
const out = new Set<string>()
|
||||
for (const id of seedIds) {
|
||||
if (!graph.hasNode(id)) continue
|
||||
bfsFromNode(
|
||||
graph,
|
||||
id,
|
||||
(node) => {
|
||||
out.add(node)
|
||||
return false
|
||||
},
|
||||
{ mode: 'outbound' }
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Inbound BFS: collect all node ids that can reach any seed (following edges backward). */
|
||||
function getUpstreamNodeIds(
|
||||
graph: DirectedGraph,
|
||||
seedIds: string[]
|
||||
): Set<string> {
|
||||
const out = new Set<string>()
|
||||
for (const id of seedIds) {
|
||||
if (!graph.hasNode(id)) continue
|
||||
bfsFromNode(
|
||||
graph,
|
||||
id,
|
||||
(node) => {
|
||||
out.add(node)
|
||||
return false
|
||||
},
|
||||
{ mode: 'inbound' }
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path-to-updating / path-to-paused (same semantics as graphPath.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getPathToUpdatingNodeIds(
|
||||
graph: DirectedGraph,
|
||||
triggerNodeIds: string[],
|
||||
updatingNodeIds: string[]
|
||||
): Set<string> {
|
||||
const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds)
|
||||
const upstreamOfUpdating = getUpstreamNodeIds(graph, updatingNodeIds)
|
||||
const downstreamOfUpdating = getDownstreamNodeIds(graph, updatingNodeIds)
|
||||
const path = new Set<string>()
|
||||
downstreamOfTrigger.forEach((id) => {
|
||||
if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id)
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
function getPathToPausedNodeIds(
|
||||
graph: DirectedGraph,
|
||||
triggerNodeIds: string[],
|
||||
pausedNodeIds: string[]
|
||||
): Set<string> {
|
||||
const upstreamOfPaused = getUpstreamNodeIds(graph, pausedNodeIds)
|
||||
const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds)
|
||||
const path = new Set<string>()
|
||||
upstreamOfPaused.forEach((id) => {
|
||||
if (downstreamOfTrigger.has(id)) path.add(id)
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API (same signatures as graphPath.ts for drop-in replacement)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All node ids on the path of an update (downstream of trigger, or path to updating nodes).
|
||||
*/
|
||||
export function getPathNodeIds(
|
||||
edges: GraphEdge[],
|
||||
updatingNodeIds: string[],
|
||||
triggerNodeIds?: string[],
|
||||
_pausedNodeIds?: string[],
|
||||
nodeAttributes?: NodeAttributesMap
|
||||
): Set<string> {
|
||||
const hasUpdating = updatingNodeIds.length > 0
|
||||
const hasTrigger = triggerNodeIds != null && triggerNodeIds.length > 0
|
||||
const graph = buildGraph(edges, nodeAttributes)
|
||||
|
||||
if (hasTrigger && hasUpdating) {
|
||||
return getPathToUpdatingNodeIds(graph, triggerNodeIds!, updatingNodeIds)
|
||||
}
|
||||
if (hasUpdating) {
|
||||
const upstream = getUpstreamNodeIds(graph, updatingNodeIds)
|
||||
const downstream = getDownstreamNodeIds(graph, updatingNodeIds)
|
||||
const path = new Set<string>(upstream)
|
||||
downstream.forEach((id) => path.add(id))
|
||||
return path
|
||||
}
|
||||
|
||||
if (hasTrigger) {
|
||||
return getDownstreamNodeIds(graph, triggerNodeIds!)
|
||||
}
|
||||
|
||||
return new Set()
|
||||
}
|
||||
|
||||
/**
|
||||
* Path nodes that show "updating" during the time-bound pulse.
|
||||
* When pulseActive is true, returns downstream(trigger); otherwise empty.
|
||||
*/
|
||||
export function getPathToUpdatingSegmentNodeIds(
|
||||
edges: GraphEdge[],
|
||||
triggerNodeIds: string[],
|
||||
pulseActive: boolean,
|
||||
nodeAttributes?: NodeAttributesMap
|
||||
): Set<string> {
|
||||
if (!pulseActive || triggerNodeIds.length === 0) return new Set()
|
||||
const graph = buildGraph(edges, nodeAttributes)
|
||||
return getDownstreamNodeIds(graph, triggerNodeIds)
|
||||
}
|
||||
@@ -1,85 +1,47 @@
|
||||
/**
|
||||
* Node lifecycle: contract that nodes implement so the graph can show the right
|
||||
* connection status (edge colors) and path animation.
|
||||
* Node lifecycle: nodes report error state so the graph can show the right
|
||||
* connection status (edge colors). "Updating" is time-bound from triggers, not per-node.
|
||||
*
|
||||
* ## State flow
|
||||
*
|
||||
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state).
|
||||
* This hook dispatches to the canvas store; edges read path state via selectors.
|
||||
*
|
||||
* ## Lifecycle phases (conceptual)
|
||||
*
|
||||
* - **Idle** – Node is not on an active path; edges use default style.
|
||||
* - **Trigger** – Node's output changed; path is computed from triggers + updating nodes.
|
||||
* - **Updating** – Node is doing async work. Report `updating: true` → false. Path edges show blue.
|
||||
* - **Paused** – Node is on hold (e.g. manual mode waiting for Run). Report `paused: true` → false. Segment shows yellow.
|
||||
* - **Error** – Node has an error. Report `error: true` → false. Incoming edges show red.
|
||||
*
|
||||
* Priority for edge status: error > paused > updating > default.
|
||||
* Priority for edge status: error > updating (pulse) > default.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
|
||||
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
|
||||
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'on-path' | 'error'
|
||||
|
||||
/**
|
||||
* State that drives connection status for this node.
|
||||
* Pass the current values from your node; the hook syncs them to the canvas store.
|
||||
* Only error is synced to the path; updating is shown via time-bound pulse from triggers.
|
||||
*/
|
||||
export type NodeConnectionStatusState = {
|
||||
/** Node is doing async work (e.g. loading, running). Incoming/outgoing path edges show blue. */
|
||||
/** Ignored for path; kept for API compatibility (e.g. loading spinner). */
|
||||
updating?: boolean
|
||||
/** Node has an error. Incoming edges to this node show red. */
|
||||
error?: boolean
|
||||
/** Node is on hold (e.g. agent waiting for Run). Path edges in paused segment show yellow. */
|
||||
paused?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs this node's lifecycle state to the canvas store so connection status (edge colors)
|
||||
* and path animation are correct. Call once per node with the current updating/error/paused
|
||||
* state; the hook will dispatch path commands to add/remove this node from the appropriate sets.
|
||||
*
|
||||
* Use in any node that can be updating, in error, or paused:
|
||||
*
|
||||
* const [loading, setLoading] = useState(false)
|
||||
* const [error, setError] = useState(null)
|
||||
* const hasPendingInputs = ...
|
||||
* useSyncConnectionStatus(id, { updating: loading, error: !!error, paused: hasPendingInputs })
|
||||
* Syncs this node's error state to the canvas store so connection status (edge colors)
|
||||
* is correct. Call once per node: useSyncConnectionStatus(id, { error: !!error }).
|
||||
*/
|
||||
export function useSyncConnectionStatus(
|
||||
nodeId: string,
|
||||
state: NodeConnectionStatusState
|
||||
): void {
|
||||
const { updating, error, paused } = state
|
||||
const prevRef = useRef({ updating: false, error: false, paused: false })
|
||||
const { error } = state
|
||||
const prevRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevRef.current
|
||||
const nowUpdating = Boolean(updating)
|
||||
const nowError = Boolean(error)
|
||||
const nowPaused = Boolean(paused)
|
||||
|
||||
if (prev.updating !== nowUpdating) {
|
||||
if (nowUpdating) dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
|
||||
else dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||
prev.updating = nowUpdating
|
||||
}
|
||||
if (prev.error !== nowError) {
|
||||
if (prevRef.current !== nowError) {
|
||||
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: nowError } })
|
||||
prev.error = nowError
|
||||
prevRef.current = nowError
|
||||
}
|
||||
if (prev.paused !== nowPaused) {
|
||||
dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: nowPaused } })
|
||||
prev.paused = nowPaused
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (prevRef.current.updating) dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||
if (prevRef.current.error) dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } })
|
||||
if (prevRef.current.paused) dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: false } })
|
||||
prevRef.current = { updating: false, error: false, paused: false }
|
||||
if (prevRef.current) {
|
||||
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } })
|
||||
prevRef.current = false
|
||||
}
|
||||
}
|
||||
}, [nodeId, updating, error, paused])
|
||||
}, [nodeId, error])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user