mirror of
https://github.com/ihabunek/toot.git
synced 2025-02-02 15:07:51 -05:00
Overhaul output to use click
This commit is contained in:
parent
e89cc6d590
commit
bbb5658781
@ -117,7 +117,7 @@ def test_empty_timeline(app, run_as):
|
||||
user = register_account(app)
|
||||
result = run_as(user, cli.timeline)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "─" * 100
|
||||
assert result.stdout.strip() == "─" * 80
|
||||
|
||||
|
||||
def test_timeline_cant_combine_timelines(run):
|
||||
|
@ -1,26 +0,0 @@
|
||||
from toot.output import colorize, strip_tags, STYLES
|
||||
|
||||
reset = STYLES["reset"]
|
||||
red = STYLES["red"]
|
||||
green = STYLES["green"]
|
||||
bold = STYLES["bold"]
|
||||
|
||||
|
||||
def test_colorize():
|
||||
assert colorize("foo") == "foo"
|
||||
assert colorize("<red>foo</red>") == f"{red}foo{reset}{reset}"
|
||||
assert colorize("foo <red>bar</red> baz") == f"foo {red}bar{reset} baz{reset}"
|
||||
assert colorize("foo <red bold>bar</red bold> baz") == f"foo {red}{bold}bar{reset} baz{reset}"
|
||||
assert colorize("foo <red bold>bar</red> baz") == f"foo {red}{bold}bar{reset}{bold} baz{reset}"
|
||||
assert colorize("foo <red bold>bar</> baz") == f"foo {red}{bold}bar{reset} baz{reset}"
|
||||
assert colorize("<red>foo<bold>bar</bold>baz</red>") == f"{red}foo{bold}bar{reset}{red}baz{reset}{reset}"
|
||||
|
||||
|
||||
def test_strip_tags():
|
||||
assert strip_tags("foo") == "foo"
|
||||
assert strip_tags("<red>foo</red>") == "foo"
|
||||
assert strip_tags("foo <red>bar</red> baz") == "foo bar baz"
|
||||
assert strip_tags("foo <red bold>bar</red bold> baz") == "foo bar baz"
|
||||
assert strip_tags("foo <red bold>bar</red> baz") == "foo bar baz"
|
||||
assert strip_tags("foo <red bold>bar</> baz") == "foo bar baz"
|
||||
assert strip_tags("<red>foo<bold>bar</bold>baz</red>") == "foobarbaz"
|
407
toot/output.py
407
toot/output.py
@ -1,227 +1,103 @@
|
||||
import os
|
||||
import click
|
||||
import re
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
from functools import lru_cache
|
||||
from toot import settings
|
||||
from toot.utils import get_text, html_to_paragraphs
|
||||
from toot.entities import Account, Instance, Notification, Poll, Status
|
||||
from toot.utils import get_text, html_to_paragraphs
|
||||
from toot.wcstring import wc_wrap
|
||||
from typing import Iterable, List
|
||||
from typing import Any, Generator, Iterable, List
|
||||
from wcwidth import wcswidth
|
||||
|
||||
|
||||
STYLES = {
|
||||
'reset': '\033[0m',
|
||||
'bold': '\033[1m',
|
||||
'dim': '\033[2m',
|
||||
'italic': '\033[3m',
|
||||
'underline': '\033[4m',
|
||||
'red': '\033[91m',
|
||||
'green': '\033[92m',
|
||||
'yellow': '\033[93m',
|
||||
'blue': '\033[94m',
|
||||
'magenta': '\033[95m',
|
||||
'cyan': '\033[96m',
|
||||
}
|
||||
|
||||
STYLE_TAG_PATTERN = re.compile(r"""
|
||||
(?<!\\) # not preceeded by a backslash - allows escaping
|
||||
< # literal
|
||||
(/)? # optional closing - first group
|
||||
(.*?) # style names - ungreedy - second group
|
||||
> # literal
|
||||
""", re.X)
|
||||
def print_instance(instance: Instance, width: int = 80):
|
||||
click.echo(instance_to_text(instance, width))
|
||||
|
||||
|
||||
def colorize(message):
|
||||
"""
|
||||
Replaces style tags in `message` with ANSI escape codes.
|
||||
|
||||
Markup is inspired by HTML, but you can use multiple words pre tag, e.g.:
|
||||
|
||||
<red bold>alert!</red bold> a thing happened
|
||||
|
||||
Empty closing tag will reset all styes:
|
||||
|
||||
<red bold>alert!</> a thing happened
|
||||
|
||||
Styles can be nested:
|
||||
|
||||
<red>red <underline>red and underline</underline> red</red>
|
||||
"""
|
||||
|
||||
def _codes(styles):
|
||||
for style in styles:
|
||||
yield STYLES.get(style, "")
|
||||
|
||||
def _generator(message):
|
||||
# A list is used instead of a set because we want to keep style order
|
||||
# This allows nesting colors, e.g. "<blue>foo<red>bar</red>baz</blue>"
|
||||
position = 0
|
||||
active_styles = []
|
||||
|
||||
for match in re.finditer(STYLE_TAG_PATTERN, message):
|
||||
is_closing = bool(match.group(1))
|
||||
styles = match.group(2).strip().split()
|
||||
|
||||
start, end = match.span()
|
||||
# Replace backslash for escaped <
|
||||
yield message[position:start].replace("\\<", "<")
|
||||
|
||||
if is_closing:
|
||||
yield STYLES["reset"]
|
||||
|
||||
# Empty closing tag resets all styles
|
||||
if styles == []:
|
||||
active_styles = []
|
||||
else:
|
||||
active_styles = [s for s in active_styles if s not in styles]
|
||||
yield from _codes(active_styles)
|
||||
else:
|
||||
active_styles = active_styles + styles
|
||||
yield from _codes(styles)
|
||||
|
||||
position = end
|
||||
|
||||
if position == 0:
|
||||
# Nothing matched, yield the original string
|
||||
yield message
|
||||
else:
|
||||
# Yield the remaining fragment
|
||||
yield message[position:]
|
||||
# Reset styles at the end to prevent leaking
|
||||
yield STYLES["reset"]
|
||||
|
||||
return "".join(_generator(message))
|
||||
def instance_to_text(instance: Instance, width: int) -> str:
|
||||
return "\n".join(instance_lines(instance, width))
|
||||
|
||||
|
||||
def strip_tags(message):
|
||||
return re.sub(STYLE_TAG_PATTERN, "", message)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def use_ansi_color():
|
||||
"""Returns True if ANSI color codes should be used."""
|
||||
|
||||
# Windows doesn't support color unless ansicon is installed
|
||||
# See: http://adoxa.altervista.org/ansicon/
|
||||
if sys.platform == 'win32' and 'ANSICON' not in os.environ:
|
||||
return False
|
||||
|
||||
# Don't show color if stdout is not a tty, e.g. if output is piped on
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
|
||||
# Don't show color if explicitly specified in options
|
||||
if "--no-color" in sys.argv:
|
||||
return False
|
||||
|
||||
# Check in settings
|
||||
color = settings.get_setting("common.color", bool)
|
||||
if color is not None:
|
||||
return color
|
||||
|
||||
# Use color by default
|
||||
return True
|
||||
|
||||
|
||||
def print_out(*args, **kwargs):
|
||||
if not settings.get_quiet():
|
||||
args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
|
||||
print(*args, **kwargs)
|
||||
|
||||
|
||||
def print_err(*args, **kwargs):
|
||||
args = [f"<red>{a}</red>" for a in args]
|
||||
args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
|
||||
print(*args, file=sys.stderr, **kwargs)
|
||||
|
||||
|
||||
def print_instance(instance: Instance):
|
||||
print_out(f"<green>{instance.title}</green>")
|
||||
print_out(f"<blue>{instance.uri}</blue>")
|
||||
print_out(f"running Mastodon {instance.version}")
|
||||
print_out()
|
||||
def instance_lines(instance: Instance, width: int) -> Generator[str, None, None]:
|
||||
yield f"{green(instance.title)}"
|
||||
yield f"{blue(instance.uri)}"
|
||||
yield f"running Mastodon {instance.version}"
|
||||
yield ""
|
||||
|
||||
if instance.description:
|
||||
for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
|
||||
paragraph = get_text(paragraph)
|
||||
print_out(textwrap.fill(paragraph, width=80))
|
||||
print_out()
|
||||
yield textwrap.fill(paragraph, width=width)
|
||||
yield ""
|
||||
|
||||
if instance.rules:
|
||||
print_out("Rules:")
|
||||
yield "Rules:"
|
||||
for ordinal, rule in enumerate(instance.rules):
|
||||
ordinal = f"{ordinal + 1}."
|
||||
lines = textwrap.wrap(rule.text, 80 - len(ordinal))
|
||||
lines = textwrap.wrap(rule.text, width - len(ordinal))
|
||||
first = True
|
||||
for line in lines:
|
||||
if first:
|
||||
print_out(f"{ordinal} {line}")
|
||||
yield f"{ordinal} {line}"
|
||||
first = False
|
||||
else:
|
||||
print_out(f"{' ' * len(ordinal)} {line}")
|
||||
print_out()
|
||||
yield f"{' ' * len(ordinal)} {line}"
|
||||
yield ""
|
||||
|
||||
contact = instance.contact_account
|
||||
if contact:
|
||||
print_out(f"Contact: {contact.display_name} @{contact.acct}")
|
||||
yield f"Contact: {contact.display_name} @{contact.acct}"
|
||||
|
||||
|
||||
def print_account(account: Account):
|
||||
print_out(f"<green>@{account.acct}</green> {account.display_name}")
|
||||
def print_account(account: Account, width: int = 80) -> None:
|
||||
click.echo(account_to_text(account, width))
|
||||
|
||||
|
||||
def account_to_text(account: Account, width: int) -> str:
|
||||
return "\n".join(account_lines(account, width))
|
||||
|
||||
|
||||
def account_lines(account: Account, width: int) -> Generator[str, None, None]:
|
||||
acct = f"@{account.acct}"
|
||||
since = account.created_at.strftime("%Y-%m-%d")
|
||||
|
||||
yield f"{green(acct)} {account.display_name}"
|
||||
|
||||
if account.note:
|
||||
print_out("")
|
||||
print_html(account.note)
|
||||
yield ""
|
||||
yield from html_lines(account.note, width)
|
||||
|
||||
since = account.created_at.strftime('%Y-%m-%d')
|
||||
|
||||
print_out("")
|
||||
print_out(f"ID: <green>{account.id}</green>")
|
||||
print_out(f"Since: <green>{since}</green>")
|
||||
print_out("")
|
||||
print_out(f"Followers: <yellow>{account.followers_count}</yellow>")
|
||||
print_out(f"Following: <yellow>{account.following_count}</yellow>")
|
||||
print_out(f"Statuses: <yellow>{account.statuses_count}</yellow>")
|
||||
yield ""
|
||||
yield f"ID: {green(account.id)}"
|
||||
yield f"Since: {green(since)}"
|
||||
yield ""
|
||||
yield f"Followers: {yellow(account.followers_count)}"
|
||||
yield f"Following: {yellow(account.following_count)}"
|
||||
yield f"Statuses: {yellow(account.statuses_count)}"
|
||||
|
||||
if account.fields:
|
||||
for field in account.fields:
|
||||
name = field.name.title()
|
||||
print_out(f'\n<yellow>{name}</yellow>:')
|
||||
print_html(field.value)
|
||||
yield f'\n{yellow(name)}:'
|
||||
yield from html_lines(field.value, width)
|
||||
if field.verified_at:
|
||||
print_out("<green>✓ Verified</green>")
|
||||
yield green("✓ Verified")
|
||||
|
||||
print_out("")
|
||||
print_out(account.url)
|
||||
|
||||
|
||||
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
|
||||
|
||||
|
||||
def highlight_hashtags(line):
|
||||
return re.sub(HASHTAG_PATTERN, '<cyan>\\1</cyan>', line)
|
||||
yield ""
|
||||
yield account.url
|
||||
|
||||
|
||||
def print_acct_list(accounts):
|
||||
for account in accounts:
|
||||
print_out(f"* <green>@{account['acct']}</green> {account['display_name']}")
|
||||
|
||||
|
||||
def print_user_list(users):
|
||||
for user in users:
|
||||
print_out(f"* {user}")
|
||||
acct = green(f"@{account['acct']}")
|
||||
click.echo(f"* {acct} {account['display_name']}")
|
||||
|
||||
|
||||
def print_tag_list(tags):
|
||||
if tags:
|
||||
for tag in tags:
|
||||
print_out(f"* <green>#{tag['name']}\t</green>{tag['url']}")
|
||||
click.echo(f"* {format_tag_name(tag)}\t{tag['url']}")
|
||||
else:
|
||||
print_out("You're not following any hashtags.")
|
||||
click.echo("You're not following any hashtags.")
|
||||
|
||||
|
||||
def print_lists(lists):
|
||||
@ -234,20 +110,17 @@ def print_table(headers: List[str], data: List[List[str]]):
|
||||
widths = [[len(cell) for cell in row] for row in data + [headers]]
|
||||
widths = [max(width) for width in zip(*widths)]
|
||||
|
||||
def style(string, tag):
|
||||
return f"<{tag}>{string}</{tag}>" if tag else string
|
||||
|
||||
def print_row(row, tag=None):
|
||||
def print_row(row):
|
||||
for idx, cell in enumerate(row):
|
||||
width = widths[idx]
|
||||
print_out(style(cell.ljust(width), tag), end="")
|
||||
print_out(" ", end="")
|
||||
print_out()
|
||||
click.echo(cell.ljust(width), nl=False)
|
||||
click.echo(" ", nl=False)
|
||||
click.echo()
|
||||
|
||||
underlines = ["-" * width for width in widths]
|
||||
|
||||
print_row(headers, "bold")
|
||||
print_row(underlines, "dim")
|
||||
print_row(headers)
|
||||
print_row(underlines)
|
||||
|
||||
for row in data:
|
||||
print_row(row)
|
||||
@ -255,33 +128,40 @@ def print_table(headers: List[str], data: List[List[str]]):
|
||||
|
||||
def print_list_accounts(accounts):
|
||||
if accounts:
|
||||
print_out("Accounts in list</green>:\n")
|
||||
click.echo("Accounts in list:\n")
|
||||
print_acct_list(accounts)
|
||||
else:
|
||||
print_out("This list has no accounts.")
|
||||
click.echo("This list has no accounts.")
|
||||
|
||||
|
||||
def print_search_results(results):
|
||||
accounts = results['accounts']
|
||||
hashtags = results['hashtags']
|
||||
accounts = results["accounts"]
|
||||
hashtags = results["hashtags"]
|
||||
|
||||
if accounts:
|
||||
print_out("\nAccounts:")
|
||||
click.echo("\nAccounts:")
|
||||
print_acct_list(accounts)
|
||||
|
||||
if hashtags:
|
||||
print_out("\nHashtags:")
|
||||
print_out(", ".join([f"<green>#{t['name']}</green>" for t in hashtags]))
|
||||
click.echo("\nHashtags:")
|
||||
click.echo(", ".join([format_tag_name(tag) for tag in hashtags]))
|
||||
|
||||
if not accounts and not hashtags:
|
||||
print_out("<yellow>Nothing found</yellow>")
|
||||
click.echo("Nothing found")
|
||||
|
||||
|
||||
def print_status(status: Status, width: int = 80):
|
||||
def print_status(status: Status, width: int = 80) -> None:
|
||||
click.echo(status_to_text(status, width))
|
||||
|
||||
|
||||
def status_to_text(status: Status, width: int) -> str:
|
||||
return "\n".join(status_lines(status, width))
|
||||
|
||||
|
||||
def status_lines(status: Status, width: int = 80) -> Generator[str, None, None]:
|
||||
status_id = status.id
|
||||
in_reply_to_id = status.in_reply_to_id
|
||||
reblogged_by = status.account if status.reblog else None
|
||||
|
||||
status = status.original
|
||||
|
||||
time = status.created_at.strftime('%Y-%m-%d %H:%M %Z')
|
||||
@ -289,61 +169,60 @@ def print_status(status: Status, width: int = 80):
|
||||
spacing = width - wcswidth(username) - wcswidth(time) - 2
|
||||
|
||||
display_name = status.account.display_name
|
||||
|
||||
if display_name:
|
||||
author = f"{green(display_name)} {blue(username)}"
|
||||
spacing -= wcswidth(display_name) + 1
|
||||
else:
|
||||
author = blue(username)
|
||||
|
||||
print_out(
|
||||
f"<green>{display_name}</green>" if display_name else "",
|
||||
f"<blue>{username}</blue>",
|
||||
" " * spacing,
|
||||
f"<yellow>{time}</yellow>",
|
||||
)
|
||||
spaces = " " * spacing
|
||||
yield f"{author} {spaces} {yellow(time)}"
|
||||
|
||||
print_out("")
|
||||
print_html(status.content, width)
|
||||
yield ""
|
||||
yield from html_lines(status.content, width)
|
||||
|
||||
if status.media_attachments:
|
||||
print_out("\nMedia:")
|
||||
yield ""
|
||||
yield "Media:"
|
||||
for attachment in status.media_attachments:
|
||||
url = attachment.url
|
||||
for line in wc_wrap(url, width):
|
||||
print_out(line)
|
||||
yield line
|
||||
|
||||
if status.poll:
|
||||
print_poll(status.poll)
|
||||
yield from poll_lines(status.poll)
|
||||
|
||||
print_out()
|
||||
reblogged_by_acct = f"@{reblogged_by.acct}" if reblogged_by else None
|
||||
yield ""
|
||||
|
||||
print_out(
|
||||
f"ID <yellow>{status_id}</yellow> ",
|
||||
f"↲ In reply to <yellow>{in_reply_to_id}</yellow> " if in_reply_to_id else "",
|
||||
f"↻ <blue>@{reblogged_by.acct}</blue> boosted " if reblogged_by else "",
|
||||
)
|
||||
reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
|
||||
boost = f"↻ {blue(reblogged_by_acct)} boosted " if reblogged_by else ""
|
||||
yield f"ID {yellow(status_id)} {reply} {boost}"
|
||||
|
||||
|
||||
def print_html(text, width=80):
|
||||
def html_lines(html: str, width: int) -> Generator[str, None, None]:
|
||||
first = True
|
||||
for paragraph in html_to_paragraphs(text):
|
||||
for paragraph in html_to_paragraphs(html):
|
||||
if not first:
|
||||
print_out("")
|
||||
yield ""
|
||||
for line in paragraph:
|
||||
for subline in wc_wrap(line, width):
|
||||
print_out(highlight_hashtags(subline))
|
||||
yield subline
|
||||
first = False
|
||||
|
||||
|
||||
def print_poll(poll: Poll):
|
||||
print_out()
|
||||
def poll_lines(poll: Poll) -> Generator[str, None, None]:
|
||||
for idx, option in enumerate(poll.options):
|
||||
perc = (round(100 * option.votes_count / poll.votes_count)
|
||||
if poll.votes_count and option.votes_count is not None else 0)
|
||||
|
||||
if poll.voted and poll.own_votes and idx in poll.own_votes:
|
||||
voted_for = " <yellow>✓</yellow>"
|
||||
voted_for = yellow(" ✓")
|
||||
else:
|
||||
voted_for = ""
|
||||
|
||||
print_out(f'{option.title} - {perc}% {voted_for}')
|
||||
yield f"{option.title} - {perc}% {voted_for}"
|
||||
|
||||
poll_footer = f'Poll · {poll.votes_count} votes'
|
||||
|
||||
@ -354,15 +233,15 @@ def print_poll(poll: Poll):
|
||||
expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
|
||||
poll_footer += f" · Closes on {expires_at}"
|
||||
|
||||
print_out()
|
||||
print_out(poll_footer)
|
||||
yield ""
|
||||
yield poll_footer
|
||||
|
||||
|
||||
def print_timeline(items: Iterable[Status], width=100):
|
||||
print_out("─" * width)
|
||||
def print_timeline(items: Iterable[Status], width=80):
|
||||
click.echo("─" * width)
|
||||
for item in items:
|
||||
print_status(item, width)
|
||||
print_out("─" * width)
|
||||
click.echo("─" * width)
|
||||
|
||||
|
||||
notification_msgs = {
|
||||
@ -373,19 +252,79 @@ notification_msgs = {
|
||||
}
|
||||
|
||||
|
||||
def print_notification(notification: Notification, width=100):
|
||||
account = f"{notification.account.display_name} @{notification.account.acct}"
|
||||
msg = notification_msgs.get(notification.type)
|
||||
if msg is None:
|
||||
return
|
||||
|
||||
print_out("─" * width)
|
||||
print_out(msg.format(account=account))
|
||||
def print_notification(notification: Notification, width=80):
|
||||
print_notification_header(notification)
|
||||
if notification.status:
|
||||
click.echo("-" * width)
|
||||
print_status(notification.status, width)
|
||||
|
||||
|
||||
def print_notifications(notifications: List[Notification], width=100):
|
||||
def print_notifications(notifications: List[Notification], width=80):
|
||||
for notification in notifications:
|
||||
click.echo("─" * width)
|
||||
print_notification(notification)
|
||||
print_out("─" * width)
|
||||
click.echo("─" * width)
|
||||
|
||||
|
||||
def print_notification_header(notification: Notification):
|
||||
account_name = format_account_name(notification.account)
|
||||
|
||||
if (notification.type == "follow"):
|
||||
click.echo(f"{account_name} now follows you")
|
||||
elif (notification.type == "mention"):
|
||||
click.echo(f"{account_name} mentioned you")
|
||||
elif (notification.type == "reblog"):
|
||||
click.echo(f"{account_name} reblogged your status")
|
||||
elif (notification.type == "favourite"):
|
||||
click.echo(f"{account_name} favourited your status")
|
||||
elif (notification.type == "update"):
|
||||
click.echo(f"{account_name} edited a post")
|
||||
else:
|
||||
click.secho(f"Unknown notification type: '{notification.type}'", err=True, fg="yellow")
|
||||
click.secho("Please report an issue to toot.", err=True, fg="yellow")
|
||||
|
||||
|
||||
notification_msgs = {
|
||||
"follow": "{account} now follows you",
|
||||
"mention": "{account} mentioned you in",
|
||||
"reblog": "{account} reblogged your status",
|
||||
"favourite": "{account} favourited your status",
|
||||
}
|
||||
|
||||
|
||||
def format_tag_name(tag):
|
||||
return green(f"#{tag['name']}")
|
||||
|
||||
|
||||
def format_account_name(account: Account) -> str:
|
||||
acct = blue(f"@{account.acct}")
|
||||
if account.display_name:
|
||||
return f"{green(account.display_name)} {acct}"
|
||||
else:
|
||||
return acct
|
||||
|
||||
|
||||
# Shorthand functions for coloring output
|
||||
|
||||
def blue(text: Any) -> str:
|
||||
return click.style(text, fg="blue")
|
||||
|
||||
|
||||
def bold(text: Any) -> str:
|
||||
return click.style(text, bold=True)
|
||||
|
||||
|
||||
def cyan(text: Any) -> str:
|
||||
return click.style(text, fg="cyan")
|
||||
|
||||
|
||||
def dim(text: Any) -> str:
|
||||
return click.style(text, dim=True)
|
||||
|
||||
|
||||
def green(text: Any) -> str:
|
||||
return click.style(text, fg="green")
|
||||
|
||||
|
||||
def yellow(text: Any) -> str:
|
||||
return click.style(text, fg="yellow")
|
||||
|
@ -7,7 +7,7 @@ import unicodedata
|
||||
import warnings
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import click
|
||||
|
||||
@ -40,7 +40,7 @@ def get_text(html):
|
||||
return unicodedata.normalize("NFKC", text)
|
||||
|
||||
|
||||
def html_to_paragraphs(html):
|
||||
def html_to_paragraphs(html: str) -> List[List[str]]:
|
||||
"""Attempt to convert html to plain text while keeping line breaks.
|
||||
Returns a list of paragraphs, each being a list of lines.
|
||||
"""
|
||||
|
Loading…
x
Reference in New Issue
Block a user