(timer): UI improvements.
Add `pomodoro_channel` guild setting. Add customisable per-timer text channel. Improve `reaction_message` flow. Change algorithm for updating vc name. Add `stage` and `pattern` vc name substitutions.
This commit is contained in:
@@ -32,7 +32,8 @@ guild_config = RowTable(
|
|||||||
'accountability_reward', 'accountability_price',
|
'accountability_reward', 'accountability_price',
|
||||||
'video_studyban', 'video_grace_period',
|
'video_studyban', 'video_grace_period',
|
||||||
'greeting_channel', 'greeting_message', 'returning_message',
|
'greeting_channel', 'greeting_message', 'returning_message',
|
||||||
'starting_funds', 'persist_roles'),
|
'starting_funds', 'persist_roles',
|
||||||
|
'pomodoro_channel'),
|
||||||
'guildid',
|
'guildid',
|
||||||
cache=TTLCache(2500, ttl=60*5)
|
cache=TTLCache(2500, ttl=60*5)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import math
|
||||||
import asyncio
|
import asyncio
|
||||||
import discord
|
import discord
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
@@ -110,12 +111,15 @@ class Timer:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def text_channel(self):
|
def text_channel(self):
|
||||||
return GuildSettings(self.data.guildid).alert_channel.value
|
if (channelid := self.data.text_channelid) and (channel := self.guild.get_channel(channelid)):
|
||||||
|
return channel
|
||||||
|
else:
|
||||||
|
return GuildSettings(self.data.guildid).pomodoro_channel.value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def members(self):
|
def members(self):
|
||||||
if (channel := self.channel):
|
if (channel := self.channel):
|
||||||
return channel.members
|
return [member for member in channel.members if not member.bot]
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -127,17 +131,26 @@ class Timer:
|
|||||||
stage = self.current_stage
|
stage = self.current_stage
|
||||||
name_format = self.data.channel_name or "{remaining} -- {name}"
|
name_format = self.data.channel_name or "{remaining} -- {name}"
|
||||||
return name_format.replace(
|
return name_format.replace(
|
||||||
'{remaining}', "{:02}:{:02}".format(
|
'{remaining}', "{}m left".format(
|
||||||
int((stage.end - utc_now()).total_seconds() // 60),
|
int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)),
|
||||||
int((stage.end - utc_now()).total_seconds() % 60),
|
|
||||||
)
|
)
|
||||||
|
).replace(
|
||||||
|
'{stage}', stage.name
|
||||||
).replace(
|
).replace(
|
||||||
'{members}', str(len(self.channel.members))
|
'{members}', str(len(self.channel.members))
|
||||||
).replace(
|
).replace(
|
||||||
'{name}', self.data.pretty_name or "WORK ROOM"
|
'{name}', self.data.pretty_name or "WORK ROOM"
|
||||||
|
).replace(
|
||||||
|
'{pattern}',
|
||||||
|
"{}/{}".format(
|
||||||
|
int(self.focus_length // 60), int(self.break_length // 60)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def notify_change_stage(self, old_stage, new_stage):
|
async def notify_change_stage(self, old_stage, new_stage):
|
||||||
|
# Update channel name
|
||||||
|
asyncio.create_task(self._update_channel_name())
|
||||||
|
|
||||||
# Kick people if they need kicking
|
# Kick people if they need kicking
|
||||||
to_warn = []
|
to_warn = []
|
||||||
to_kick = []
|
to_kick = []
|
||||||
@@ -174,7 +187,13 @@ class Timer:
|
|||||||
)
|
)
|
||||||
content.append(warn_string)
|
content.append(warn_string)
|
||||||
|
|
||||||
|
# Send a new status/reaction message
|
||||||
if self.text_channel and self.members:
|
if self.text_channel and self.members:
|
||||||
|
if self.reaction_message:
|
||||||
|
try:
|
||||||
|
await self.reaction_message.delete()
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
# Send status image, add reaction
|
# Send status image, add reaction
|
||||||
self.reaction_message = await self.text_channel.send(
|
self.reaction_message = await self.text_channel.send(
|
||||||
content='\n'.join(content),
|
content='\n'.join(content),
|
||||||
@@ -192,6 +211,8 @@ class Timer:
|
|||||||
*(self.text_channel.send(block, delete_after=0.5) for block in blocks),
|
*(self.text_channel.send(block, delete_after=0.5) for block in blocks),
|
||||||
return_exceptions=True
|
return_exceptions=True
|
||||||
)
|
)
|
||||||
|
elif not self.members:
|
||||||
|
await self.update_last_status()
|
||||||
# TODO: DM task if anyone has notifications on
|
# TODO: DM task if anyone has notifications on
|
||||||
|
|
||||||
# Mute or unmute everyone in the channel as needed
|
# Mute or unmute everyone in the channel as needed
|
||||||
@@ -206,9 +227,6 @@ class Timer:
|
|||||||
# except discord.HTTPException:
|
# except discord.HTTPException:
|
||||||
# pass
|
# pass
|
||||||
|
|
||||||
# Update channel name
|
|
||||||
asyncio.create_task(self._update_channel_name())
|
|
||||||
|
|
||||||
# Run the notify hook
|
# Run the notify hook
|
||||||
await self.notify_hook(old_stage, new_stage)
|
await self.notify_hook(old_stage, new_stage)
|
||||||
|
|
||||||
@@ -294,7 +312,7 @@ class Timer:
|
|||||||
else:
|
else:
|
||||||
repost = False
|
repost = False
|
||||||
|
|
||||||
if repost:
|
if repost and self.text_channel:
|
||||||
try:
|
try:
|
||||||
self.reaction_message = await self.text_channel.send(**args)
|
self.reaction_message = await self.text_channel.send(**args)
|
||||||
await self.reaction_message.add_reaction('✅')
|
await self.reaction_message.add_reaction('✅')
|
||||||
@@ -338,7 +356,8 @@ class Timer:
|
|||||||
stage = self._state = self.current_stage
|
stage = self._state = self.current_stage
|
||||||
to_next_stage = (stage.end - utc_now()).total_seconds()
|
to_next_stage = (stage.end - utc_now()).total_seconds()
|
||||||
|
|
||||||
if to_next_stage > 10 * 60:
|
# Allow updating with 10 seconds of drift to stage change
|
||||||
|
if to_next_stage > 10 * 60 - 10:
|
||||||
time_to_sleep = 5 * 60
|
time_to_sleep = 5 * 60
|
||||||
else:
|
else:
|
||||||
time_to_sleep = to_next_stage
|
time_to_sleep = to_next_stage
|
||||||
@@ -353,6 +372,7 @@ class Timer:
|
|||||||
asyncio.create_task(self.notify_change_stage(self._state, self.current_stage))
|
asyncio.create_task(self.notify_change_stage(self._state, self.current_stage))
|
||||||
else:
|
else:
|
||||||
asyncio.create_task(self._update_channel_name())
|
asyncio.create_task(self._update_channel_name())
|
||||||
|
asyncio.create_task(self.update_last_status())
|
||||||
|
|
||||||
def runloop(self):
|
def runloop(self):
|
||||||
self._runloop_task = asyncio.create_task(self.run())
|
self._runloop_task = asyncio.create_task(self.run())
|
||||||
@@ -392,7 +412,7 @@ async def load_timers(client):
|
|||||||
async def reaction_tracker(client, payload):
|
async def reaction_tracker(client, payload):
|
||||||
if payload.guild_id and payload.member and not payload.member.bot and payload.member.voice:
|
if payload.guild_id and payload.member and not payload.member.bot and payload.member.voice:
|
||||||
if (channel := payload.member.voice.channel) and (timer := Timer.fetch_timer(channel.id)):
|
if (channel := payload.member.voice.channel) and (timer := Timer.fetch_timer(channel.id)):
|
||||||
if payload.message_id == timer.reaction_message.id:
|
if timer.reaction_message and payload.message_id == timer.reaction_message.id:
|
||||||
timer.last_seen[payload.member.id] = utc_now()
|
timer.last_seen[payload.member.id] = utc_now()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from .Timer import Timer
|
from .Timer import Timer
|
||||||
from . import commands
|
from . import commands
|
||||||
|
from . import settings
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import asyncio
|
||||||
import discord
|
import discord
|
||||||
from cmdClient import Context
|
from cmdClient import Context
|
||||||
from cmdClient.checks import in_guild
|
from cmdClient.checks import in_guild
|
||||||
from cmdClient.lib import SafeCancellation
|
from cmdClient.lib import SafeCancellation
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from wards import guild_admin
|
from wards import guild_admin
|
||||||
from utils.lib import utc_now, tick
|
from utils.lib import utc_now, tick
|
||||||
|
|
||||||
@@ -13,12 +12,13 @@ from ..module import module
|
|||||||
from .Timer import Timer
|
from .Timer import Timer
|
||||||
|
|
||||||
|
|
||||||
config_flags = ('name==', 'threshold=', 'channelname==')
|
config_flags = ('name==', 'threshold=', 'channelname==', 'text==')
|
||||||
|
|
||||||
|
|
||||||
@module.cmd(
|
@module.cmd(
|
||||||
"timer",
|
"timer",
|
||||||
group="Productivity",
|
group="Productivity",
|
||||||
desc="Display your study room pomodoro timer.",
|
desc="View your study room timer.",
|
||||||
flags=config_flags
|
flags=config_flags
|
||||||
)
|
)
|
||||||
@in_guild()
|
@in_guild()
|
||||||
@@ -32,7 +32,7 @@ async def cmd_timer(ctx: Context, flags):
|
|||||||
"""
|
"""
|
||||||
channel = ctx.author.voice.channel if ctx.author.voice else None
|
channel = ctx.author.voice.channel if ctx.author.voice else None
|
||||||
if ctx.args:
|
if ctx.args:
|
||||||
if len(splits := ctx.args.split()) > 1:
|
if len(ctx.args.split()) > 1:
|
||||||
# Multiple arguments provided
|
# Multiple arguments provided
|
||||||
# Assume configuration attempt
|
# Assume configuration attempt
|
||||||
return await _pomo_admin(ctx, flags)
|
return await _pomo_admin(ctx, flags)
|
||||||
@@ -48,6 +48,7 @@ async def cmd_timer(ctx: Context, flags):
|
|||||||
if channel is None:
|
if channel is None:
|
||||||
# Author is not in a voice channel, and they did not select a channel
|
# Author is not in a voice channel, and they did not select a channel
|
||||||
# Display the server timers they can see
|
# Display the server timers they can see
|
||||||
|
# TODO: Write UI
|
||||||
timers = Timer.fetch_guild_timers(ctx.guild.id)
|
timers = Timer.fetch_guild_timers(ctx.guild.id)
|
||||||
timers = [
|
timers = [
|
||||||
timer for timer in timers
|
timer for timer in timers
|
||||||
@@ -114,24 +115,38 @@ async def cmd_timer(ctx: Context, flags):
|
|||||||
@module.cmd(
|
@module.cmd(
|
||||||
"pomodoro",
|
"pomodoro",
|
||||||
group="Guild Admin",
|
group="Guild Admin",
|
||||||
desc="Create and modify the voice channel pomodoro timers.",
|
desc="Add and configure timers for your study rooms.",
|
||||||
flags=config_flags
|
flags=config_flags
|
||||||
)
|
)
|
||||||
async def ctx_pomodoro(ctx, flags):
|
async def cmd_pomodoro(ctx, flags):
|
||||||
"""
|
"""
|
||||||
Usage``:
|
Usage``:
|
||||||
{prefix}pomodoro [channelid] <work time>, <break time> [channel name] [options]
|
{prefix}pomodoro [channelid] <focus time>, <break time> [channel name]
|
||||||
{prefix}pomodoro [channelid] [options]
|
{prefix}pomodoro [channelid] [options]
|
||||||
{prefix}pomodoro [channelid] delete
|
{prefix}pomodoro [channelid] delete
|
||||||
Description:
|
Description:
|
||||||
...
|
Get started by joining a study voice channel and writing e.g. `{prefix}pomodoro 50, 10`.
|
||||||
|
The timer will start automatically and continue forever.
|
||||||
|
See the options and examples below for configuration.
|
||||||
Options::
|
Options::
|
||||||
--name: The name of the timer as shown in the timer status.
|
--name: The timer name (as shown in alerts and `{prefix}timer`).
|
||||||
--channelname: The voice channel name template.
|
--channelname: The name of the voice channel, see below for substitutions.
|
||||||
--threshold: How many work+break sessions before a user is removed.
|
--threshold: How many focus+break cycles before a member is kicked.
|
||||||
Examples``:
|
--text: Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`).
|
||||||
{prefix}pomodoro 50, 10
|
Channel name substitutions::
|
||||||
...
|
{{remaining}}: The time left in the current focus or break session, e.g. `10m left`.
|
||||||
|
{{stage}}: The name of the current stage (`FOCUS` or `BREAK`).
|
||||||
|
{{name}}: The configured timer name.
|
||||||
|
{{pattern}}: The timer pattern in the form `focus/break` (e.g. `50/10`).
|
||||||
|
Examples:
|
||||||
|
Add a timer to your study room with `50` minutes focus, `10` minutes break.
|
||||||
|
> `{prefix}pomodoro 50, 10`
|
||||||
|
Add a timer with a custom updating channel name
|
||||||
|
> `{prefix}pomodoro 50, 10 {{stage}} {{remaining}} -- {{pattern}} room`
|
||||||
|
Change the name on the `{prefix}timer` status
|
||||||
|
> `{prefix}pomodoro --name 50/10 study room`
|
||||||
|
Change the updating channel name
|
||||||
|
> `{prefix}pomodoro --channelname {{remaining}} -- {{name}}`
|
||||||
"""
|
"""
|
||||||
await _pomo_admin(ctx, flags)
|
await _pomo_admin(ctx, flags)
|
||||||
|
|
||||||
@@ -146,7 +161,10 @@ async def _pomo_admin(ctx, flags):
|
|||||||
args = ctx.args
|
args = ctx.args
|
||||||
if ctx.args:
|
if ctx.args:
|
||||||
splits = ctx.args.split(maxsplit=1)
|
splits = ctx.args.split(maxsplit=1)
|
||||||
if splits[0].strip('#<>').isdigit() or len(splits[0]) > 10:
|
assume_channel = not splits[0].endswith(',')
|
||||||
|
assume_channel = assume_channel and not (channel and len(splits[0]) < 5)
|
||||||
|
assume_channel = assume_channel and (splits[0].strip('#<>').isdigit() or len(splits[0]) > 10)
|
||||||
|
if assume_channel:
|
||||||
# Assume first argument is a channel specifier
|
# Assume first argument is a channel specifier
|
||||||
channel = await ctx.find_channel(
|
channel = await ctx.find_channel(
|
||||||
splits[0], interactive=True, chan_type=discord.ChannelType.voice
|
splits[0], interactive=True, chan_type=discord.ChannelType.voice
|
||||||
@@ -167,8 +185,8 @@ async def _pomo_admin(ctx, flags):
|
|||||||
if not channel:
|
if not channel:
|
||||||
return await ctx.error_reply(
|
return await ctx.error_reply(
|
||||||
f"No channel specified!\n"
|
f"No channel specified!\n"
|
||||||
"Please join a voice channel or pass the id as the first argument.\n"
|
"Please join a voice channel or pass the channel id as the first argument.\n"
|
||||||
f"See `{ctx.best_prefix}help pomodoro` for more usage information."
|
f"See `{ctx.best_prefix}help pomodoro` for usage and examples."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Now we have a channel and configuration arguments
|
# Now we have a channel and configuration arguments
|
||||||
@@ -196,6 +214,13 @@ async def _pomo_admin(ctx, flags):
|
|||||||
elif args or timer:
|
elif args or timer:
|
||||||
if args:
|
if args:
|
||||||
# Any provided arguments should be for setting up a new timer pattern
|
# Any provided arguments should be for setting up a new timer pattern
|
||||||
|
# Check the pomodoro channel exists
|
||||||
|
if not (timer and timer.text_channel) and not ctx.guild_settings.pomodoro_channel.value:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"Please set the pomodoro alerts channel first, "
|
||||||
|
f"with `{ctx.best_prefix}config pomodoro_channel <channel>`.\n"
|
||||||
|
f"For example: {ctx.best_prefix}config pomodoro_channel {ctx.ch.mention}"
|
||||||
|
)
|
||||||
# First validate input
|
# First validate input
|
||||||
try:
|
try:
|
||||||
# Ensure no trailing commas
|
# Ensure no trailing commas
|
||||||
@@ -294,7 +319,8 @@ async def _pomo_admin(ctx, flags):
|
|||||||
timer.runloop()
|
timer.runloop()
|
||||||
|
|
||||||
await ctx.embed_reply(
|
await ctx.embed_reply(
|
||||||
f"Restarted the pomodoro timer in {channel.mention} as `{focus_length}, {break_length}`."
|
f"Started a timer in {channel.mention} with **{focus_length}** "
|
||||||
|
f"minutes focus and **{break_length}** minutes break."
|
||||||
)
|
)
|
||||||
|
|
||||||
to_set = []
|
to_set = []
|
||||||
@@ -325,6 +351,50 @@ async def _pomo_admin(ctx, flags):
|
|||||||
flags['channelname'],
|
flags['channelname'],
|
||||||
f"The voice channel name template is now `{flags['channelname']}`."
|
f"The voice channel name template is now `{flags['channelname']}`."
|
||||||
))
|
))
|
||||||
|
if flags['text']:
|
||||||
|
# Handle text channel update
|
||||||
|
flag = flags['text']
|
||||||
|
if flag.lower() == 'none':
|
||||||
|
# Check if there is a default channel
|
||||||
|
channel = ctx.guild_settings.pomodoro_channel.value
|
||||||
|
if channel:
|
||||||
|
# Unset the channel to the default
|
||||||
|
msg = f"The custom text channel has been unset! (Alerts will be sent to {channel.mention})"
|
||||||
|
to_set.append((
|
||||||
|
'text_channelid',
|
||||||
|
None,
|
||||||
|
msg
|
||||||
|
))
|
||||||
|
# Remove the last reaction message and send a new one
|
||||||
|
timer.reaction_message = None
|
||||||
|
# Ensure this happens after the data update
|
||||||
|
asyncio.create_task(timer.update_last_status())
|
||||||
|
else:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"The text channel cannot be unset because there is no `pomodoro_channel` set up!\n"
|
||||||
|
f"See `{ctx.best_prefix}config pomodoro_channel` for setting a default pomodoro channel."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Attempt to parse the provided channel
|
||||||
|
channel = await ctx.find_channel(flag, interactive=True, chan_type=discord.ChannelType.text)
|
||||||
|
if channel:
|
||||||
|
if not channel.permissions_for(ctx.guild.me.send_messages):
|
||||||
|
return await ctx.error_reply(
|
||||||
|
f"Cannot send pomodoro alerts to {channel.mention}! "
|
||||||
|
"I don't have permission to send messages there."
|
||||||
|
)
|
||||||
|
to_set.append((
|
||||||
|
'text_channelid',
|
||||||
|
channel.id,
|
||||||
|
f"Timer alerts and updates will now be sent to {channel.mention}."
|
||||||
|
))
|
||||||
|
# Remove the last reaction message and send a new one
|
||||||
|
timer.reaction_message = None
|
||||||
|
# Ensure this happens after the data update
|
||||||
|
asyncio.create_task(timer.update_last_status())
|
||||||
|
else:
|
||||||
|
# Ack has already been sent, just ignore
|
||||||
|
return
|
||||||
|
|
||||||
if to_set:
|
if to_set:
|
||||||
to_update = {item[0]: item[1] for item in to_set}
|
to_update = {item[0]: item[1] for item in to_set}
|
||||||
@@ -343,4 +413,3 @@ async def _pomo_admin(ctx, flags):
|
|||||||
f"Create one with, for example, ```{ctx.best_prefix}pomodoro {channel.id} 50, 10```"
|
f"Create one with, for example, ```{ctx.best_prefix}pomodoro {channel.id} 50, 10```"
|
||||||
f"See `{ctx.best_prefix}help pomodoro` for more examples and usage."
|
f"See `{ctx.best_prefix}help pomodoro` for more examples and usage."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from data import RowTable
|
|||||||
timers = RowTable(
|
timers = RowTable(
|
||||||
'timers',
|
'timers',
|
||||||
('channelid', 'guildid',
|
('channelid', 'guildid',
|
||||||
|
'text_channelid',
|
||||||
'focus_length', 'break_length',
|
'focus_length', 'break_length',
|
||||||
'inactivity_threshold',
|
'inactivity_threshold',
|
||||||
'last_started',
|
'last_started',
|
||||||
|
|||||||
47
bot/modules/study/timers/settings.py
Normal file
47
bot/modules/study/timers/settings.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from settings import GuildSettings, GuildSetting
|
||||||
|
import settings
|
||||||
|
|
||||||
|
from . import Timer
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class pomodoro_channel(settings.TextChannel, GuildSetting):
|
||||||
|
category = "Study Tracking"
|
||||||
|
|
||||||
|
attr_name = "pomodoro_channel"
|
||||||
|
_data_column = "pomodoro_channel"
|
||||||
|
|
||||||
|
display_name = "pomodoro_channel"
|
||||||
|
desc = "Channel to send pomodoro timer status updates and alerts."
|
||||||
|
|
||||||
|
_default = None
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"Channel to send pomodoro status updates to.\n"
|
||||||
|
"Members studying in rooms with an attached timer will need to be able to see "
|
||||||
|
"this channel to get notifications and react to the status messages."
|
||||||
|
)
|
||||||
|
_accepts = "Any text channel I can write to, or `None` to unset."
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
timers = Timer.fetch_guild_timers(self.id)
|
||||||
|
if self.value:
|
||||||
|
for timer in timers:
|
||||||
|
if timer.reaction_message and timer.reaction_message.channel != self.value:
|
||||||
|
timer.reaction_message = None
|
||||||
|
asyncio.create_task(timer.update_last_status())
|
||||||
|
return f"The pomodoro alerts and updates will now be sent to {self.value.mention}"
|
||||||
|
else:
|
||||||
|
deleted = 0
|
||||||
|
for timer in timers:
|
||||||
|
if not timer.text_channel:
|
||||||
|
deleted += 1
|
||||||
|
asyncio.create_task(timer.destroy())
|
||||||
|
|
||||||
|
msg = "The pomodoro alert channel has been unset."
|
||||||
|
if deleted:
|
||||||
|
msg += f" `{deleted}` timers were subsequently deactivated."
|
||||||
|
return msg
|
||||||
@@ -78,7 +78,8 @@ CREATE TABLE guild_config(
|
|||||||
returning_message TEXT,
|
returning_message TEXT,
|
||||||
starting_funds INTEGER,
|
starting_funds INTEGER,
|
||||||
persist_roles BOOLEAN,
|
persist_roles BOOLEAN,
|
||||||
daily_study_cap INTEGER
|
daily_study_cap INTEGER,
|
||||||
|
pomodoro_channel BIGINT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE ignored_members(
|
CREATE TABLE ignored_members(
|
||||||
|
|||||||
Reference in New Issue
Block a user