mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-18 23:06:40 +00:00
Add more info for Person (#4848)
* Add more info for Person * Update src/dialogs/more-info/controls/more-info-vacuum.ts Co-Authored-By: Paulus Schoutsen <balloob@gmail.com> Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
parent
e261fafdb3
commit
c93e1b0123
@ -44,6 +44,7 @@ export const DOMAINS_WITH_MORE_INFO = [
|
|||||||
"light",
|
"light",
|
||||||
"lock",
|
"lock",
|
||||||
"media_player",
|
"media_player",
|
||||||
|
"person",
|
||||||
"script",
|
"script",
|
||||||
"sun",
|
"sun",
|
||||||
"timer",
|
"timer",
|
||||||
|
311
src/components/map/ha-map.ts
Normal file
311
src/components/map/ha-map.ts
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
import "@polymer/paper-icon-button/paper-icon-button";
|
||||||
|
import { Circle, Layer, Map, Marker } from "leaflet";
|
||||||
|
import {
|
||||||
|
css,
|
||||||
|
CSSResult,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
LitElement,
|
||||||
|
property,
|
||||||
|
PropertyValues,
|
||||||
|
TemplateResult,
|
||||||
|
} from "lit-element";
|
||||||
|
import {
|
||||||
|
LeafletModuleType,
|
||||||
|
setupLeafletMap,
|
||||||
|
} from "../../common/dom/setup-leaflet-map";
|
||||||
|
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||||
|
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||||
|
import { debounce } from "../../common/util/debounce";
|
||||||
|
import { HomeAssistant } from "../../types";
|
||||||
|
import "../../panels/map/ha-entity-marker";
|
||||||
|
|
||||||
|
@customElement("ha-map")
|
||||||
|
class HaMap extends LitElement {
|
||||||
|
@property() public hass?: HomeAssistant;
|
||||||
|
|
||||||
|
@property() public entities?: string[];
|
||||||
|
@property() public darkMode = false;
|
||||||
|
@property() public zoom?: number;
|
||||||
|
// tslint:disable-next-line
|
||||||
|
private Leaflet?: LeafletModuleType;
|
||||||
|
private _leafletMap?: Map;
|
||||||
|
// @ts-ignore
|
||||||
|
private _resizeObserver?: ResizeObserver;
|
||||||
|
private _debouncedResizeListener = debounce(
|
||||||
|
() => {
|
||||||
|
if (!this._leafletMap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._leafletMap.invalidateSize();
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
private _mapItems: Array<Marker | Circle> = [];
|
||||||
|
private _mapZones: Array<Marker | Circle> = [];
|
||||||
|
private _connected = false;
|
||||||
|
|
||||||
|
public connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this._connected = true;
|
||||||
|
if (this.hasUpdated) {
|
||||||
|
this.loadMap();
|
||||||
|
this._attachObserver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this._connected = false;
|
||||||
|
|
||||||
|
if (this._leafletMap) {
|
||||||
|
this._leafletMap.remove();
|
||||||
|
this._leafletMap = undefined;
|
||||||
|
this.Leaflet = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._resizeObserver) {
|
||||||
|
this._resizeObserver.unobserve(this._mapEl);
|
||||||
|
} else {
|
||||||
|
window.removeEventListener("resize", this._debouncedResizeListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
if (!this.entities) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<div id="map"></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected firstUpdated(changedProps: PropertyValues): void {
|
||||||
|
super.firstUpdated(changedProps);
|
||||||
|
this.loadMap();
|
||||||
|
|
||||||
|
if (this._connected) {
|
||||||
|
this._attachObserver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updated(changedProps: PropertyValues): void {
|
||||||
|
if (changedProps.has("hass")) {
|
||||||
|
this._drawEntities();
|
||||||
|
this._fitMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _mapEl(): HTMLDivElement {
|
||||||
|
return this.shadowRoot!.getElementById("map") as HTMLDivElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadMap(): Promise<void> {
|
||||||
|
[this._leafletMap, this.Leaflet] = await setupLeafletMap(
|
||||||
|
this._mapEl,
|
||||||
|
this.darkMode
|
||||||
|
);
|
||||||
|
this._drawEntities();
|
||||||
|
this._leafletMap.invalidateSize();
|
||||||
|
this._fitMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _fitMap(): void {
|
||||||
|
if (!this._leafletMap || !this.Leaflet || !this.hass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._mapItems.length === 0) {
|
||||||
|
this._leafletMap.setView(
|
||||||
|
new this.Leaflet.LatLng(
|
||||||
|
this.hass.config.latitude,
|
||||||
|
this.hass.config.longitude
|
||||||
|
),
|
||||||
|
this.zoom || 14
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = this.Leaflet.latLngBounds(
|
||||||
|
this._mapItems ? this._mapItems.map((item) => item.getLatLng()) : []
|
||||||
|
);
|
||||||
|
this._leafletMap.fitBounds(bounds.pad(0.5));
|
||||||
|
|
||||||
|
if (this.zoom && this._leafletMap.getZoom() > this.zoom) {
|
||||||
|
this._leafletMap.setZoom(this.zoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _drawEntities(): void {
|
||||||
|
const hass = this.hass;
|
||||||
|
const map = this._leafletMap;
|
||||||
|
const Leaflet = this.Leaflet;
|
||||||
|
if (!hass || !map || !Leaflet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._mapItems) {
|
||||||
|
this._mapItems.forEach((marker) => marker.remove());
|
||||||
|
}
|
||||||
|
const mapItems: Layer[] = (this._mapItems = []);
|
||||||
|
|
||||||
|
if (this._mapZones) {
|
||||||
|
this._mapZones.forEach((marker) => marker.remove());
|
||||||
|
}
|
||||||
|
const mapZones: Layer[] = (this._mapZones = []);
|
||||||
|
|
||||||
|
const allEntities = this.entities!.concat();
|
||||||
|
|
||||||
|
for (const entity of allEntities) {
|
||||||
|
const entityId = entity;
|
||||||
|
const stateObj = hass.states[entityId];
|
||||||
|
if (!stateObj) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const title = computeStateName(stateObj);
|
||||||
|
const {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
passive,
|
||||||
|
icon,
|
||||||
|
radius,
|
||||||
|
entity_picture: entityPicture,
|
||||||
|
gps_accuracy: gpsAccuracy,
|
||||||
|
} = stateObj.attributes;
|
||||||
|
|
||||||
|
if (!(latitude && longitude)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (computeStateDomain(stateObj) === "zone") {
|
||||||
|
// DRAW ZONE
|
||||||
|
if (passive) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create icon
|
||||||
|
let iconHTML = "";
|
||||||
|
if (icon) {
|
||||||
|
const el = document.createElement("ha-icon");
|
||||||
|
el.setAttribute("icon", icon);
|
||||||
|
iconHTML = el.outerHTML;
|
||||||
|
} else {
|
||||||
|
const el = document.createElement("span");
|
||||||
|
el.innerHTML = title;
|
||||||
|
iconHTML = el.outerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create marker with the icon
|
||||||
|
mapZones.push(
|
||||||
|
Leaflet.marker([latitude, longitude], {
|
||||||
|
icon: Leaflet.divIcon({
|
||||||
|
html: iconHTML,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
className: this.darkMode ? "dark" : "light",
|
||||||
|
}),
|
||||||
|
interactive: false,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// create circle around it
|
||||||
|
mapZones.push(
|
||||||
|
Leaflet.circle([latitude, longitude], {
|
||||||
|
interactive: false,
|
||||||
|
color: "#FF9800",
|
||||||
|
radius,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DRAW ENTITY
|
||||||
|
// create icon
|
||||||
|
const entityName = title
|
||||||
|
.split(" ")
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")
|
||||||
|
.substr(0, 3);
|
||||||
|
|
||||||
|
// create market with the icon
|
||||||
|
mapItems.push(
|
||||||
|
Leaflet.marker([latitude, longitude], {
|
||||||
|
icon: Leaflet.divIcon({
|
||||||
|
// Leaflet clones this element before adding it to the map. This messes up
|
||||||
|
// our Polymer object and we can't pass data through. Thus we hack like this.
|
||||||
|
html: `
|
||||||
|
<ha-entity-marker
|
||||||
|
entity-id="${entityId}"
|
||||||
|
entity-name="${entityName}"
|
||||||
|
entity-picture="${entityPicture || ""}"
|
||||||
|
></ha-entity-marker>
|
||||||
|
`,
|
||||||
|
iconSize: [48, 48],
|
||||||
|
className: "",
|
||||||
|
}),
|
||||||
|
title: computeStateName(stateObj),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// create circle around if entity has accuracy
|
||||||
|
if (gpsAccuracy) {
|
||||||
|
mapItems.push(
|
||||||
|
Leaflet.circle([latitude, longitude], {
|
||||||
|
interactive: false,
|
||||||
|
color: "#0288D1",
|
||||||
|
radius: gpsAccuracy,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._mapItems.forEach((marker) => map.addLayer(marker));
|
||||||
|
this._mapZones.forEach((marker) => map.addLayer(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _attachObserver(): void {
|
||||||
|
// Observe changes to map size and invalidate to prevent broken rendering
|
||||||
|
// Uses ResizeObserver in Chrome, otherwise window resize event
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (typeof ResizeObserver === "function") {
|
||||||
|
// @ts-ignore
|
||||||
|
this._resizeObserver = new ResizeObserver(() =>
|
||||||
|
this._debouncedResizeListener()
|
||||||
|
);
|
||||||
|
this._resizeObserver.observe(this._mapEl);
|
||||||
|
} else {
|
||||||
|
window.addEventListener("resize", this._debouncedResizeListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResult {
|
||||||
|
return css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
#map {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#map.dark {
|
||||||
|
background: #090909;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-map": HaMap;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import { HomeAssistant } from "../types";
|
import { HomeAssistant } from "../types";
|
||||||
|
import { navigate } from "../common/navigate";
|
||||||
|
|
||||||
export const defaultRadiusColor = "#FF9800";
|
export const defaultRadiusColor = "#FF9800";
|
||||||
export const homeRadiusColor: string = "#03a9f4";
|
export const homeRadiusColor: string = "#03a9f4";
|
||||||
@ -48,3 +49,19 @@ export const deleteZone = (hass: HomeAssistant, zoneId: string) =>
|
|||||||
type: "zone/delete",
|
type: "zone/delete",
|
||||||
zone_id: zoneId,
|
zone_id: zoneId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let inititialZoneEditorData: Partial<ZoneMutableParams> | undefined;
|
||||||
|
|
||||||
|
export const showZoneEditor = (
|
||||||
|
el: HTMLElement,
|
||||||
|
data?: Partial<ZoneMutableParams>
|
||||||
|
) => {
|
||||||
|
inititialZoneEditorData = data;
|
||||||
|
navigate(el, "/config/zone/new");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getZoneEditorInitData = () => {
|
||||||
|
const data = inititialZoneEditorData;
|
||||||
|
inititialZoneEditorData = undefined;
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
@ -16,6 +16,7 @@ import "./more-info-input_datetime";
|
|||||||
import "./more-info-light";
|
import "./more-info-light";
|
||||||
import "./more-info-lock";
|
import "./more-info-lock";
|
||||||
import "./more-info-media_player";
|
import "./more-info-media_player";
|
||||||
|
import "./more-info-person";
|
||||||
import "./more-info-script";
|
import "./more-info-script";
|
||||||
import "./more-info-sun";
|
import "./more-info-sun";
|
||||||
import "./more-info-timer";
|
import "./more-info-timer";
|
||||||
|
85
src/dialogs/more-info/controls/more-info-person.ts
Normal file
85
src/dialogs/more-info/controls/more-info-person.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
LitElement,
|
||||||
|
html,
|
||||||
|
TemplateResult,
|
||||||
|
CSSResult,
|
||||||
|
css,
|
||||||
|
property,
|
||||||
|
customElement,
|
||||||
|
} from "lit-element";
|
||||||
|
import { HassEntity } from "home-assistant-js-websocket";
|
||||||
|
import "@material/mwc-button";
|
||||||
|
|
||||||
|
import "../../../components/map/ha-map";
|
||||||
|
|
||||||
|
import { HomeAssistant } from "../../../types";
|
||||||
|
import { showZoneEditor } from "../../../data/zone";
|
||||||
|
import { fireEvent } from "../../../common/dom/fire_event";
|
||||||
|
|
||||||
|
@customElement("more-info-person")
|
||||||
|
class MoreInfoPerson extends LitElement {
|
||||||
|
@property() public hass!: HomeAssistant;
|
||||||
|
@property() public stateObj?: HassEntity;
|
||||||
|
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
if (!this.hass || !this.stateObj) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-attributes
|
||||||
|
.stateObj=${this.stateObj}
|
||||||
|
extraFilters="id,user_id,editable"
|
||||||
|
></ha-attributes>
|
||||||
|
${this.stateObj.attributes.latitude && this.stateObj.attributes.longitude
|
||||||
|
? html`
|
||||||
|
<ha-map
|
||||||
|
.hass=${this.hass}
|
||||||
|
.entities=${[this.stateObj.entity_id]}
|
||||||
|
></ha-map>
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
${this.hass.user?.is_admin &&
|
||||||
|
this.stateObj.state === "not_home" &&
|
||||||
|
this.stateObj.attributes.latitude &&
|
||||||
|
this.stateObj.attributes.longitude
|
||||||
|
? html`
|
||||||
|
<div class="actions">
|
||||||
|
<mwc-button @click=${this._handleAction}>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.dialogs.more_info_control.person.create_zone"
|
||||||
|
)}
|
||||||
|
</mwc-button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleAction() {
|
||||||
|
showZoneEditor(this, {
|
||||||
|
latitude: this.stateObj!.attributes.latitude,
|
||||||
|
longitude: this.stateObj!.attributes.longitude,
|
||||||
|
});
|
||||||
|
fireEvent(this, "hass-more-info", { entityId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
static get styles(): CSSResult {
|
||||||
|
return css`
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin: 36px 0 8px 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"more-info-person": MoreInfoPerson;
|
||||||
|
}
|
||||||
|
}
|
@ -123,12 +123,15 @@ class MoreInfoVacuum extends LitElement {
|
|||||||
: ""}
|
: ""}
|
||||||
${supportsFeature(stateObj, VACUUM_SUPPORT_BATTERY)
|
${supportsFeature(stateObj, VACUUM_SUPPORT_BATTERY)
|
||||||
? html`
|
? html`
|
||||||
<div">
|
<div>
|
||||||
<span>
|
<span>
|
||||||
<iron-icon .icon=${stateObj.attributes.battery_icon}></iron-icon>
|
<iron-icon
|
||||||
${stateObj.attributes.battery_level} %
|
.icon=${stateObj.attributes.battery_icon}
|
||||||
|
></iron-icon>
|
||||||
|
${stateObj.attributes.battery_level}%
|
||||||
</span>
|
</span>
|
||||||
</div>`
|
</div>
|
||||||
|
`
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
ZoneMutableParams,
|
ZoneMutableParams,
|
||||||
passiveRadiusColor,
|
passiveRadiusColor,
|
||||||
defaultRadiusColor,
|
defaultRadiusColor,
|
||||||
|
getZoneEditorInitData,
|
||||||
} from "../../../data/zone";
|
} from "../../../data/zone";
|
||||||
import { addDistanceToCoord } from "../../../common/location/add_distance_to_coord";
|
import { addDistanceToCoord } from "../../../common/location/add_distance_to_coord";
|
||||||
|
|
||||||
@ -47,15 +48,20 @@ class DialogZoneDetail extends LitElement {
|
|||||||
this._passive = this._params.entry.passive || false;
|
this._passive = this._params.entry.passive || false;
|
||||||
this._radius = this._params.entry.radius || 100;
|
this._radius = this._params.entry.radius || 100;
|
||||||
} else {
|
} else {
|
||||||
const movedHomeLocation = addDistanceToCoord(
|
const initConfig = getZoneEditorInitData();
|
||||||
|
let movedHomeLocation;
|
||||||
|
if (!initConfig?.latitude || !initConfig?.longitude) {
|
||||||
|
movedHomeLocation = addDistanceToCoord(
|
||||||
[this.hass.config.latitude, this.hass.config.longitude],
|
[this.hass.config.latitude, this.hass.config.longitude],
|
||||||
500,
|
Math.random() * 500 * (Math.random() < 0.5 ? -1 : 1),
|
||||||
500
|
Math.random() * 500 * (Math.random() < 0.5 ? -1 : 1)
|
||||||
);
|
);
|
||||||
this._name = "";
|
}
|
||||||
this._icon = "mdi:map-marker";
|
this._latitude = initConfig?.latitude || movedHomeLocation[0];
|
||||||
this._latitude = movedHomeLocation[0];
|
this._longitude = initConfig?.longitude || movedHomeLocation[1];
|
||||||
this._longitude = movedHomeLocation[1];
|
this._name = initConfig?.name || "";
|
||||||
|
this._icon = initConfig?.icon || "mdi:map-marker";
|
||||||
|
|
||||||
this._passive = false;
|
this._passive = false;
|
||||||
this._radius = 100;
|
this._radius = 100;
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,7 @@ import memoizeOne from "memoize-one";
|
|||||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||||
import { subscribeEntityRegistry } from "../../../data/entity_registry";
|
import { subscribeEntityRegistry } from "../../../data/entity_registry";
|
||||||
import { configSections } from "../ha-panel-config";
|
import { configSections } from "../ha-panel-config";
|
||||||
|
import { navigate } from "../../../common/navigate";
|
||||||
|
|
||||||
@customElement("ha-config-zone")
|
@customElement("ha-config-zone")
|
||||||
export class HaConfigZone extends SubscribeMixin(LitElement) {
|
export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||||
@ -234,6 +235,10 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
|||||||
protected firstUpdated(changedProps: PropertyValues) {
|
protected firstUpdated(changedProps: PropertyValues) {
|
||||||
super.firstUpdated(changedProps);
|
super.firstUpdated(changedProps);
|
||||||
this._fetchData();
|
this._fetchData();
|
||||||
|
if (this.route.path === "/new") {
|
||||||
|
navigate(this, "/config/zone", true);
|
||||||
|
this._createZone();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected updated(changedProps: PropertyValues) {
|
protected updated(changedProps: PropertyValues) {
|
||||||
|
@ -630,6 +630,9 @@
|
|||||||
"locate": "Locate",
|
"locate": "Locate",
|
||||||
"return_home": "Return home",
|
"return_home": "Return home",
|
||||||
"start_pause": "Start/Pause"
|
"start_pause": "Start/Pause"
|
||||||
|
},
|
||||||
|
"person": {
|
||||||
|
"create_zone": "Create zone from current location"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity_registry": {
|
"entity_registry": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user