mirror of
https://github.com/notion-enhancer/notion-enhancer.git
synced 2025-04-10 15:39:01 +00:00
fix(panel): reliable/not-weird panel animations
This commit is contained in:
parent
a2efca4ca6
commit
c722c7f854
@ -123,7 +123,8 @@ const insertPanel = async (api, db) => {
|
|||||||
const notionFrame = ".notion-frame",
|
const notionFrame = ".notion-frame",
|
||||||
notionTopbarBtn = ".notion-topbar-more-button",
|
notionTopbarBtn = ".notion-topbar-more-button",
|
||||||
togglePanelHotkey = await db.get("togglePanelHotkey"),
|
togglePanelHotkey = await db.get("togglePanelHotkey"),
|
||||||
{ html, setState, addPanelView } = api;
|
{ addPanelView, addMutationListener, addKeyListener } = api,
|
||||||
|
{ html, setState, useState } = api;
|
||||||
|
|
||||||
const $panel = html`<${Panel}
|
const $panel = html`<${Panel}
|
||||||
...${Object.assign(
|
...${Object.assign(
|
||||||
@ -178,17 +179,18 @@ const insertPanel = async (api, db) => {
|
|||||||
$notionTopbarBtn?.before($panelTopbarBtn);
|
$notionTopbarBtn?.before($panelTopbarBtn);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
api.addMutationListener(`${notionFrame}, ${notionTopbarBtn}`, appendToDom);
|
addMutationListener(`${notionFrame}, ${notionTopbarBtn}`, appendToDom);
|
||||||
api.useState(["panelOpen"], ([panelOpen]) => {
|
appendToDom();
|
||||||
|
|
||||||
|
useState(["panelOpen"], ([panelOpen]) => {
|
||||||
if (panelOpen) $panelTopbarBtn.setAttribute("data-active", true);
|
if (panelOpen) $panelTopbarBtn.setAttribute("data-active", true);
|
||||||
else $panelTopbarBtn.removeAttribute("data-active");
|
else $panelTopbarBtn.removeAttribute("data-active");
|
||||||
});
|
});
|
||||||
api.useState(["panelViews"], ([panelViews = []]) => {
|
useState(["panelViews"], ([panelViews = []]) => {
|
||||||
$panelTopbarBtn.style.display = panelViews.length ? "" : "none";
|
$panelTopbarBtn.style.display = panelViews.length ? "" : "none";
|
||||||
});
|
});
|
||||||
appendToDom();
|
|
||||||
|
|
||||||
api.addKeyListener(togglePanelHotkey, (event) => {
|
addKeyListener(togglePanelHotkey, (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
$panel.toggle();
|
$panel.toggle();
|
||||||
|
@ -8,11 +8,9 @@
|
|||||||
import { Tooltip } from "./Tooltip.mjs";
|
import { Tooltip } from "./Tooltip.mjs";
|
||||||
import { Select } from "../menu/islands/Select.mjs";
|
import { Select } from "../menu/islands/Select.mjs";
|
||||||
|
|
||||||
// note: these islands do not accept extensible
|
// note: these islands are not reusable.
|
||||||
// properties, i.e. they are not reusable.
|
// panel views can be added via addPanelView,
|
||||||
// please register your own interfaces via
|
// do not instantiate additional panels
|
||||||
// globalThis.__enhancerApi.addPanelView and
|
|
||||||
// not by re-instantiating additional panels
|
|
||||||
|
|
||||||
let panelViews = [],
|
let panelViews = [],
|
||||||
// "$icon" may either be an actual dom element,
|
// "$icon" may either be an actual dom element,
|
||||||
@ -90,9 +88,9 @@ function Panel({
|
|||||||
}) {
|
}) {
|
||||||
const { html, setState, useState } = globalThis.__enhancerApi,
|
const { html, setState, useState } = globalThis.__enhancerApi,
|
||||||
{ addMutationListener, removeMutationListener } = globalThis.__enhancerApi,
|
{ addMutationListener, removeMutationListener } = globalThis.__enhancerApi,
|
||||||
$panel = html`<aside
|
$panel = html`<div
|
||||||
class="notion-enhancer--panel group/panel
|
class="notion-enhancer--panel group/panel order-2
|
||||||
order-2 shrink-0 [open]:w-[var(--panel--width,0)]"
|
shrink-0 &[data-pinned]:w-[var(--panel--width,0)]"
|
||||||
>
|
>
|
||||||
<style>
|
<style>
|
||||||
.notion-frame {
|
.notion-frame {
|
||||||
@ -106,12 +104,11 @@ function Panel({
|
|||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<div
|
<aside
|
||||||
class="absolute right-0 bottom-0 bg-[color:var(--theme--bg-primary)]
|
class="border-(l-1 [color:var(--theme--fg-border)]) w-0
|
||||||
z-20 border-(l-1 [color:var(--theme--fg-border)]) w-[var(--panel--width,0)]
|
group-&[data-pinned]/panel:(w-[var(--panel--width,0)]) h-[calc(100vh-45px)] bottom-0)
|
||||||
transition-[width,bottom,top,border-radius] duration-[${transitionDuration}ms]
|
absolute right-0 z-20 bg-[color:var(--theme--bg-primary)] group-&[data-peeked]/panel:(
|
||||||
hover:transition-[height,width,bottom,top,border-radius] h-[calc(100vh-45px)]
|
w-[var(--panel--width,0)] h-[calc(100vh-120px)] bottom-[60px] rounded-l-[8px] border-(t-1 b-1)
|
||||||
group-not-[open]/panel:(bottom-[60px] h-[calc(100vh-120px)] rounded-l-[8px] border-(t-1 b-1)
|
|
||||||
shadow-[rgba(15,15,15,0.1)_0px_0px_0px_1px,rgba(15,15,15,0.2)_0px_3px_6px,rgba(15,15,15,0.4)_0px_9px_24px])"
|
shadow-[rgba(15,15,15,0.1)_0px_0px_0px_1px,rgba(15,15,15,0.2)_0px_3px_6px,rgba(15,15,15,0.4)_0px_9px_24px])"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -130,27 +127,79 @@ function Panel({
|
|||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
class="i-chevrons-left w-[20px] h-[20px]
|
class="i-chevrons-left w-[20px] h-[20px]
|
||||||
group-[open]/panel:rotate-180 duration-[${transitionDuration}ms]
|
text-[color:var(--theme--fg-secondary)] transition-transform
|
||||||
transition-transform text-[color:var(--theme--fg-secondary)]"
|
group-&[data-pinned]/panel:rotate-180 duration-[${transitionDuration}ms]"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<${View} ...${{ _get: _getView }} />
|
<${View} ...${{ _get: _getView }} />
|
||||||
</div>
|
</aside>
|
||||||
</aside>`;
|
</div>`;
|
||||||
|
|
||||||
let preDragWidth,
|
let preDragWidth, dragStartX;
|
||||||
dragStartX = 0;
|
const getWidth = async (width) => {
|
||||||
|
if (width && !isNaN(width)) {
|
||||||
|
width = Math.max(width, minWidth);
|
||||||
|
width = Math.min(width, maxWidth);
|
||||||
|
} else width = await _getWidth?.();
|
||||||
|
if (isNaN(width)) width = minWidth;
|
||||||
|
return width;
|
||||||
|
},
|
||||||
|
setInteractive = (interactive) => {
|
||||||
|
$panel
|
||||||
|
.querySelectorAll("[tabindex]")
|
||||||
|
.forEach(($el) => ($el.tabIndex = interactive ? 1 : -1));
|
||||||
|
},
|
||||||
|
isDragging = () => !isNaN(preDragWidth) && !isNaN(dragStartX),
|
||||||
|
isPinned = () => $panel.hasAttribute("data-pinned"),
|
||||||
|
isPeeked = () => $panel.hasAttribute("data-peeked"),
|
||||||
|
isClosed = () => !isPinned() && !isPeeked();
|
||||||
|
|
||||||
|
const closedWidth = { width: "0px" },
|
||||||
|
openWidth = { width: "var(--panel--width, 0px)" },
|
||||||
|
peekAnimation = {
|
||||||
|
height: "calc(100vh - 120px)",
|
||||||
|
bottom: "60px",
|
||||||
|
borderTopWidth: "1px",
|
||||||
|
borderBottomWidth: "1px",
|
||||||
|
borderTopLeftRadius: "8px",
|
||||||
|
borderBottomLeftRadius: "8px",
|
||||||
|
boxShadow:
|
||||||
|
"rgba(15, 15, 15, 0.1) 0px 0px 0px 1px, rgba(15, 15, 15, 0.2) 0px 3px 6px, rgba(15, 15, 15, 0.4) 0px 9px 24px",
|
||||||
|
},
|
||||||
|
pinAnimation = {
|
||||||
|
height: "calc(100vh - 45px)",
|
||||||
|
bottom: "0px",
|
||||||
|
borderTopWidth: "0px",
|
||||||
|
borderBottomWidth: "0px",
|
||||||
|
borderTopLeftRadius: "0px",
|
||||||
|
borderBottomLeftRadius: "0px",
|
||||||
|
boxShadow: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
const animationState = {},
|
||||||
|
easing = "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
|
animate = ($target, keyframes) => {
|
||||||
|
const opts = { fill: "forwards", duration: transitionDuration, easing };
|
||||||
|
$target.animate(keyframes, opts);
|
||||||
|
console.log($target, keyframes);
|
||||||
|
},
|
||||||
|
animatePanel = (to) => {
|
||||||
|
animate($panel.lastElementChild, [animationState, to]);
|
||||||
|
Object.assign(animationState, to);
|
||||||
|
};
|
||||||
|
|
||||||
|
// dragging the resize handle horizontally will
|
||||||
|
// adjust the width of the panel correspondingly
|
||||||
const $resizeHandle = html`<div
|
const $resizeHandle = html`<div
|
||||||
class="absolute h-full w-[3px] left-[-2px]
|
class="absolute opacity-0 h-full w-[3px] left-[-2px]
|
||||||
z-20 active:cursor-text transition duration-300
|
active:cursor-text bg-[color:var(--theme--fg-border)] z-20
|
||||||
bg-[color:var(--theme--fg-border)] opacity-0
|
transition duration-300 hover:(cursor-col-resize opacity-100)
|
||||||
group-not-[open]/panel:(w-[8px] left-[-1px] rounded-l-[7px])
|
group-&[data-peeked]/panel:(w-[8px] left-[-1px] rounded-l-[7px])"
|
||||||
hover:(cursor-col-resize opacity-100)"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ml-[2px] bg-[color:var(--theme--bg-primary)]
|
class="ml-[2px] bg-[color:var(--theme--bg-primary)]
|
||||||
group-not-[open]/panel:(my-px h-[calc(100%-2px)] rounded-l-[6px])"
|
group-&[data-peeked]/panel:(my-px h-[calc(100%-2px)] rounded-l-[6px])"
|
||||||
></div>
|
></div>
|
||||||
</div>`,
|
</div>`,
|
||||||
$onResizeClick = html`<span>close</span>`,
|
$onResizeClick = html`<span>close</span>`,
|
||||||
@ -160,120 +209,132 @@ function Panel({
|
|||||||
<//>`,
|
<//>`,
|
||||||
showTooltip = (event) => {
|
showTooltip = (event) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const handleHovered = $resizeHandle.matches(":hover");
|
if (!$resizeHandle.matches(":hover")) return;
|
||||||
if (!handleHovered) return;
|
const open = $panel.hasAttribute("open"),
|
||||||
const panelOpen = $panel.hasAttribute("open"),
|
|
||||||
{ x } = $resizeHandle.getBoundingClientRect();
|
{ x } = $resizeHandle.getBoundingClientRect();
|
||||||
$onResizeClick.innerText = panelOpen ? "close" : "lock open";
|
$onResizeClick.innerText = open ? "close" : "lock open";
|
||||||
$resizeTooltip.show(x, event.clientY);
|
$resizeTooltip.show(x, event.clientY);
|
||||||
}, 200);
|
}, 200);
|
||||||
},
|
},
|
||||||
startDrag = async (event) => {
|
startDrag = async (event) => {
|
||||||
dragStartX = event.clientX;
|
dragStartX = event.clientX;
|
||||||
preDragWidth = await _getWidth?.();
|
preDragWidth = await getWidth();
|
||||||
if (isNaN(preDragWidth)) preDragWidth = minWidth;
|
|
||||||
document.addEventListener("mousemove", onDrag);
|
document.addEventListener("mousemove", onDrag);
|
||||||
document.addEventListener("mouseup", endDrag);
|
document.addEventListener("mouseup", endDrag);
|
||||||
$panel.style.transitionDuration = "0ms";
|
|
||||||
},
|
},
|
||||||
onDrag = (event) => {
|
onDrag = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!isDragging()) return;
|
||||||
$panel.resize(preDragWidth + (dragStartX - event.clientX));
|
$panel.resize(preDragWidth + (dragStartX - event.clientX));
|
||||||
},
|
},
|
||||||
endDrag = (event) => {
|
endDrag = (event) => {
|
||||||
document.removeEventListener("mousemove", onDrag);
|
document.removeEventListener("mousemove", onDrag);
|
||||||
document.removeEventListener("mouseup", endDrag);
|
document.removeEventListener("mouseup", endDrag);
|
||||||
$panel.style.transitionDuration = "";
|
if (!isDragging()) return;
|
||||||
$panel.resize(preDragWidth + (dragStartX - event.clientX));
|
$panel.resize(preDragWidth + (dragStartX - event.clientX));
|
||||||
// toggle panel if not resized
|
// toggle panel if not resized
|
||||||
if (dragStartX - event.clientX === 0) $panel.toggle();
|
if (dragStartX - event.clientX === 0) $panel.toggle();
|
||||||
|
preDragWidth = dragStartX = undefined;
|
||||||
};
|
};
|
||||||
$resizeHandle.addEventListener("mouseout", $resizeTooltip.hide);
|
$resizeHandle.addEventListener("mouseout", $resizeTooltip.hide);
|
||||||
$resizeHandle.addEventListener("mousedown", startDrag);
|
$resizeHandle.addEventListener("mousedown", startDrag);
|
||||||
$resizeHandle.addEventListener("mouseover", showTooltip);
|
$resizeHandle.addEventListener("mouseover", showTooltip);
|
||||||
$panel.prepend($resizeHandle);
|
$panel.lastElementChild.prepend($resizeHandle);
|
||||||
|
|
||||||
// pop out panel preview when hovering near the right edge
|
// hovering over the peek trigger will temporarily
|
||||||
// of the screen, otherwise collapse panel when closed
|
// pop out an interactive preview of the panel
|
||||||
const $hoverTrigger = html`<div
|
const $peekTrigger = html`<div
|
||||||
class="z-10 absolute right-0 bottom-[60px] h-[calc(100vh-120px)]
|
class="absolute z-10 right-0 h-[calc(100vh-120px)] bottom-[60px] w-[64px]
|
||||||
w-[64px] transition-[width] duration-[${transitionDuration}ms]"
|
group-&[data-peeked]/panel:(w-[calc(var(--panel--width,0)+8px)])
|
||||||
|
group-&[data-pinned]/panel:(w-[calc(var(--panel--width,0)+8px)])"
|
||||||
></div>`;
|
></div>`;
|
||||||
$hoverTrigger.addEventListener("mouseover", () => $panel.resize(0, true));
|
$panel.prepend($peekTrigger);
|
||||||
$panel.addEventListener("mouseenter", () => $panel.resize());
|
$panel.addEventListener("mouseout", () => {
|
||||||
$panel.addEventListener("mouseout", () => $panel.resize());
|
if (isDragging() || isPinned()) return;
|
||||||
$panel.append($hoverTrigger);
|
if (!$panel.matches(":hover")) $panel.close();
|
||||||
|
});
|
||||||
|
$panel.addEventListener("mouseover", () => {
|
||||||
|
if (isClosed() && $panel.matches(":hover")) $panel.peek();
|
||||||
|
});
|
||||||
|
|
||||||
|
// moves help button out of the way of open panel.
|
||||||
// normally would place outside of an island, but in
|
// normally would place outside of an island, but in
|
||||||
// this case is necessary for syncing up animations
|
// this case is necessary for syncing up animations
|
||||||
const notionHelp = ".notion-help-button",
|
const notionHelp = ".notion-help-button",
|
||||||
repositionHelp = async (width) => {
|
repositionHelp = async (width) => {
|
||||||
const $notionHelp = document.querySelector(notionHelp);
|
const $notionHelp = document.querySelector(notionHelp);
|
||||||
if (!$notionHelp) return;
|
if (!$notionHelp) return;
|
||||||
width ??= await _getWidth?.();
|
width ??= await getWidth();
|
||||||
if (isNaN(width)) width = minWidth;
|
if (isNaN(width)) width = minWidth;
|
||||||
if (!$panel.hasAttribute("open")) width = 0;
|
if (!$panel.hasAttribute("open")) width = 0;
|
||||||
const position = $notionHelp.style.getPropertyValue("right"),
|
const to = `${26 + width}px`,
|
||||||
destination = `${26 + width}px`,
|
from = $notionHelp.style.getPropertyValue("right"),
|
||||||
keyframes = [{ right: position }, { right: destination }],
|
opts = { duration: transitionDuration, easing };
|
||||||
options = {
|
if (from === to) return;
|
||||||
duration: transitionDuration,
|
$notionHelp.style.setProperty("right", to);
|
||||||
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
|
animate($notionHelp, [({ right: from }, { right: to })]);
|
||||||
};
|
|
||||||
$notionHelp.style.setProperty("right", destination);
|
|
||||||
$notionHelp.animate(keyframes, options);
|
|
||||||
removeMutationListener(repositionHelp);
|
removeMutationListener(repositionHelp);
|
||||||
};
|
};
|
||||||
addMutationListener(notionHelp, repositionHelp);
|
addMutationListener(notionHelp, repositionHelp);
|
||||||
|
|
||||||
$panel.resize = async (width, peek = false) => {
|
$panel.pin = () => {
|
||||||
$resizeTooltip.hide();
|
if (isPinned() || !panelViews.length) return;
|
||||||
if (width && !isNaN(width)) {
|
if (isClosed()) Object.assign(animationState, pinAnimation);
|
||||||
width = Math.max(width, minWidth);
|
animatePanel({ ...openWidth, ...pinAnimation });
|
||||||
width = Math.min(width, maxWidth);
|
animate($panel, [closedWidth, openWidth]);
|
||||||
_setWidth?.(width);
|
$panel.removeAttribute("data-peeked");
|
||||||
} else width = await _getWidth?.();
|
$panel.dataset.pinned = true;
|
||||||
if (isNaN(width)) width = minWidth;
|
setInteractive(true);
|
||||||
const panelOpen = $panel.hasAttribute("open"),
|
|
||||||
panelHovered = $panel.matches(":hover");
|
|
||||||
if (panelOpen) {
|
|
||||||
} else {
|
|
||||||
if (!panelHovered && !peek) width = 0;
|
|
||||||
}
|
|
||||||
const $cssVarTarget = $panel.parentElement || $panel;
|
|
||||||
$cssVarTarget.style.setProperty("--panel--width", `${width}px`);
|
|
||||||
if ($cssVarTarget !== $panel) $panel.style.removeProperty("--panel--width");
|
|
||||||
repositionHelp(width);
|
|
||||||
};
|
|
||||||
$panel.open = () => {
|
|
||||||
if (!panelViews.length) return;
|
|
||||||
$panel.setAttribute("open", true);
|
|
||||||
$panel.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = 1));
|
|
||||||
setState({ panelOpen: true });
|
|
||||||
$panel.onopen?.();
|
|
||||||
_setOpen(true);
|
_setOpen(true);
|
||||||
|
setState({ panelOpen: true });
|
||||||
$panel.resize();
|
$panel.resize();
|
||||||
};
|
};
|
||||||
$panel.close = () => {
|
$panel.peek = () => {
|
||||||
$resizeTooltip.hide();
|
if (isPeeked() || !panelViews.length) return;
|
||||||
$panel.onbeforeclose?.();
|
if (isClosed()) Object.assign(animationState, peekAnimation);
|
||||||
$panel.removeAttribute("open");
|
animatePanel({ ...openWidth, ...peekAnimation });
|
||||||
$panel.style.pointerEvents = "auto";
|
$panel.removeAttribute("data-pinned");
|
||||||
$panel.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = -1));
|
$panel.dataset.peeked = true;
|
||||||
|
setInteractive(true);
|
||||||
$panel.resize();
|
$panel.resize();
|
||||||
|
};
|
||||||
|
$panel.close = async () => {
|
||||||
|
if (isClosed()) return;
|
||||||
setState({ panelOpen: false });
|
setState({ panelOpen: false });
|
||||||
if (panelViews.length) _setOpen(false);
|
if (panelViews.length) _setOpen(false);
|
||||||
setTimeout(() => {
|
const width = (animationState.width = `${await getWidth()}px`);
|
||||||
$panel.style.pointerEvents = "";
|
// only animate container close if it is actually taking up space,
|
||||||
$panel.onclose?.();
|
// otherwise will unnaturally grow + retrigger peek on peek mouseout
|
||||||
}, transitionDuration);
|
if (isPinned()) animate($panel, [{ width }, closedWidth]);
|
||||||
|
if (!$panel.matches(":hover")) {
|
||||||
|
$panel.removeAttribute("data-pinned");
|
||||||
|
$panel.removeAttribute("data-peeked");
|
||||||
|
animatePanel(closedWidth);
|
||||||
|
setInteractive(false);
|
||||||
|
$panel.resize();
|
||||||
|
} else $panel.peek();
|
||||||
};
|
};
|
||||||
$panel.toggle = () => {
|
$panel.toggle = () => {
|
||||||
if ($panel.hasAttribute("open")) $panel.close();
|
if (isPinned()) $panel.close();
|
||||||
else $panel.open();
|
else $panel.pin();
|
||||||
};
|
};
|
||||||
|
// resizing handles visual resizes (inc. setting width to 0
|
||||||
|
// if closed) and actual resizes on drag (inc. saving to db)
|
||||||
|
$panel.resize = async (width) => {
|
||||||
|
$resizeTooltip.hide();
|
||||||
|
width = await getWidth(width);
|
||||||
|
_setWidth?.(width);
|
||||||
|
// works in conjunction with animations, acts as fallback
|
||||||
|
// plus updates dependent styles e.g. page skeleton padding
|
||||||
|
if (isClosed()) width = 0;
|
||||||
|
const $parent = $panel.parentElement || $panel;
|
||||||
|
$parent.style.setProperty("--panel--width", `${width}px`);
|
||||||
|
if ($parent !== $panel) $panel.style.removeProperty("--panel--width");
|
||||||
|
repositionHelp(width);
|
||||||
|
};
|
||||||
|
|
||||||
useState(["panelViews"], async ([panelViews = []]) => {
|
useState(["panelViews"], async ([panelViews = []]) => {
|
||||||
if (panelViews.length && (await _getOpen())) $panel.open();
|
if (panelViews.length && (await _getOpen())) $panel.pin();
|
||||||
else $panel.close();
|
else $panel.close();
|
||||||
});
|
});
|
||||||
return $panel;
|
return $panel;
|
||||||
|
@ -93,7 +93,6 @@ twind.install({
|
|||||||
["children", "&>*"],
|
["children", "&>*"],
|
||||||
["siblings", "&~*"],
|
["siblings", "&~*"],
|
||||||
["sibling", "&+*"],
|
["sibling", "&+*"],
|
||||||
["\\[.+]", (match) => "&" + match.input],
|
|
||||||
[/^&/, (match) => match.input],
|
[/^&/, (match) => match.input],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user