feat(dashboard): working ignored channels page
This commit is contained in:
parent
1c43beb82f
commit
bccf7caf34
13 changed files with 304 additions and 8 deletions
112
Catalogger.Backend/Api/GuildsController.Ignores.cs
Normal file
112
Catalogger.Backend/Api/GuildsController.Ignores.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// Copyright (C) 2021-present sam (starshines.gay)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
using Catalogger.Backend.Database.Queries;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Remora.Discord.API;
|
||||||
|
using Remora.Discord.API.Abstractions.Objects;
|
||||||
|
|
||||||
|
namespace Catalogger.Backend.Api;
|
||||||
|
|
||||||
|
public partial class GuildsController
|
||||||
|
{
|
||||||
|
[HttpPut("ignored-channels/{channelId}")]
|
||||||
|
public async Task<IActionResult> AddIgnoredChannelAsync(string id, ulong channelId)
|
||||||
|
{
|
||||||
|
var (guildId, _) = await ParseGuildAsync(id);
|
||||||
|
var guildConfig = await db.GetGuildAsync(guildId);
|
||||||
|
|
||||||
|
if (guildConfig.Channels.IgnoredChannels.Contains(channelId))
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
|
var channel = channelCache
|
||||||
|
.GuildChannels(guildId)
|
||||||
|
.FirstOrDefault(c =>
|
||||||
|
c.ID.Value == channelId
|
||||||
|
&& c.Type
|
||||||
|
is ChannelType.GuildText
|
||||||
|
or ChannelType.GuildCategory
|
||||||
|
or ChannelType.GuildAnnouncement
|
||||||
|
or ChannelType.GuildForum
|
||||||
|
or ChannelType.GuildMedia
|
||||||
|
or ChannelType.GuildVoice
|
||||||
|
);
|
||||||
|
if (channel == null)
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
|
guildConfig.Channels.IgnoredChannels.Add(channelId);
|
||||||
|
db.Update(guildConfig);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("ignored-channels/{channelId}")]
|
||||||
|
public async Task<IActionResult> RemoveIgnoredChannelAsync(string id, ulong channelId)
|
||||||
|
{
|
||||||
|
var (guildId, _) = await ParseGuildAsync(id);
|
||||||
|
var guildConfig = await db.GetGuildAsync(guildId);
|
||||||
|
|
||||||
|
guildConfig.Channels.IgnoredChannels.Remove(channelId);
|
||||||
|
db.Update(guildConfig);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("users")]
|
||||||
|
public async Task<IActionResult> ListUsersAsync(string id, [FromQuery] string query)
|
||||||
|
{
|
||||||
|
var (guildId, _) = await ParseGuildAsync(id);
|
||||||
|
var members = await memberCache.GetMemberNamesAsync(guildId, query);
|
||||||
|
|
||||||
|
return Ok(members.OrderBy(m => m.Name).Select(m => new UserQueryResponse(m.Name, m.Id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private record UserQueryResponse(string Name, string Id);
|
||||||
|
|
||||||
|
[HttpPut("ignored-users/{userId}")]
|
||||||
|
public async Task<IActionResult> AddIgnoredUserAsync(string id, ulong userId)
|
||||||
|
{
|
||||||
|
var (guildId, _) = await ParseGuildAsync(id);
|
||||||
|
var guildConfig = await db.GetGuildAsync(guildId);
|
||||||
|
|
||||||
|
if (guildConfig.Channels.IgnoredUsers.Contains(userId))
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
|
var user = await memberCache.TryGetAsync(guildId, DiscordSnowflake.New(userId));
|
||||||
|
if (user == null)
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
|
guildConfig.Channels.IgnoredUsers.Add(userId);
|
||||||
|
db.Update(guildConfig);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("ignored-users/{userId}")]
|
||||||
|
public async Task<IActionResult> RemoveIgnoredUserAsync(string id, ulong userId)
|
||||||
|
{
|
||||||
|
var (guildId, _) = await ParseGuildAsync(id);
|
||||||
|
var guildConfig = await db.GetGuildAsync(guildId);
|
||||||
|
|
||||||
|
guildConfig.Channels.IgnoredUsers.Remove(userId);
|
||||||
|
db.Update(guildConfig);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using Catalogger.Backend.Api.Middleware;
|
using Catalogger.Backend.Api.Middleware;
|
||||||
|
using Catalogger.Backend.Cache;
|
||||||
using Catalogger.Backend.Cache.InMemoryCache;
|
using Catalogger.Backend.Cache.InMemoryCache;
|
||||||
using Catalogger.Backend.Database;
|
using Catalogger.Backend.Database;
|
||||||
using Catalogger.Backend.Database.Queries;
|
using Catalogger.Backend.Database.Queries;
|
||||||
|
|
@ -32,6 +33,7 @@ public partial class GuildsController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
ChannelCache channelCache,
|
ChannelCache channelCache,
|
||||||
RedisService redisService,
|
RedisService redisService,
|
||||||
|
IMemberCache memberCache,
|
||||||
DiscordRequestService discordRequestService
|
DiscordRequestService discordRequestService
|
||||||
) : ApiControllerBase
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ public class GuildMembersChunkResponder(ILogger logger, IMemberCache memberCache
|
||||||
);
|
);
|
||||||
|
|
||||||
await memberCache.SetManyAsync(evt.GuildID, evt.Members);
|
await memberCache.SetManyAsync(evt.GuildID, evt.Members);
|
||||||
|
await memberCache.SetMemberNamesAsync(evt.GuildID, evt.Members);
|
||||||
|
|
||||||
if (evt.ChunkIndex == evt.ChunkCount - 1)
|
if (evt.ChunkIndex == evt.ChunkCount - 1)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ public class GuildMemberAddResponder(
|
||||||
public async Task<Result> RespondAsync(IGuildMemberAdd member, CancellationToken ct = default)
|
public async Task<Result> RespondAsync(IGuildMemberAdd member, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await memberCache.SetAsync(member.GuildID, member);
|
await memberCache.SetAsync(member.GuildID, member);
|
||||||
|
await memberCache.SetMemberNamesAsync(member.GuildID, [member]);
|
||||||
|
|
||||||
var user = member.User.GetOrThrow();
|
var user = member.User.GetOrThrow();
|
||||||
userCache.UpdateUser(user);
|
userCache.UpdateUser(user);
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ public class GuildMemberRemoveResponder(
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await memberCache.RemoveAsync(evt.GuildID, evt.User.ID);
|
await memberCache.RemoveAsync(evt.GuildID, evt.User.ID);
|
||||||
|
await memberCache.TryRemoveMemberNameAsync(evt.GuildID, evt.User.Username);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ public class GuildMemberUpdateResponder(
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await memberCache.UpdateAsync(newMember);
|
await memberCache.UpdateAsync(newMember);
|
||||||
|
|
||||||
userCache.UpdateUser(newMember.User);
|
userCache.UpdateUser(newMember.User);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,6 +195,13 @@ public class GuildMemberUpdateResponder(
|
||||||
**After:** {newMember.User.Tag()}
|
**After:** {newMember.User.Tag()}
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await memberCache.UpdateMemberNameAsync(
|
||||||
|
newMember.GuildID,
|
||||||
|
newMember.User.ID,
|
||||||
|
oldUser.Tag(),
|
||||||
|
newMember.User.Tag()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var guildConfig = await db.GetGuildAsync(newMember.GuildID, ct);
|
var guildConfig = await db.GetGuildAsync(newMember.GuildID, ct);
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,21 @@ public interface IMemberCache
|
||||||
public Task MarkAsCachedAsync(Snowflake guildId);
|
public Task MarkAsCachedAsync(Snowflake guildId);
|
||||||
public Task MarkAsUncachedAsync(Snowflake guildId);
|
public Task MarkAsUncachedAsync(Snowflake guildId);
|
||||||
public Task UpdateAsync(IGuildMemberUpdate newMember);
|
public Task UpdateAsync(IGuildMemberUpdate newMember);
|
||||||
|
|
||||||
|
// These methods can be stubbed out for any implementation that isn't intended for use with the dashboard.
|
||||||
|
public Task SetMemberNamesAsync(Snowflake guildId, IEnumerable<IGuildMember> members);
|
||||||
|
|
||||||
|
public Task UpdateMemberNameAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
Snowflake userId,
|
||||||
|
string prevName,
|
||||||
|
string newName
|
||||||
|
);
|
||||||
|
|
||||||
|
public Task<IEnumerable<(string Name, string Id)>> GetMemberNamesAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
string prefix
|
||||||
|
);
|
||||||
|
|
||||||
|
public Task TryRemoveMemberNameAsync(Snowflake guildId, string username);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,4 +128,21 @@ public class InMemoryMemberCache(IDiscordRestGuildAPI guildApi, ILogger logger)
|
||||||
|
|
||||||
_members[(newMember.GuildID, newMember.User.ID)] = member;
|
_members[(newMember.GuildID, newMember.User.ID)] = member;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task SetMemberNamesAsync(Snowflake guildId, IEnumerable<IGuildMember> members) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task UpdateMemberNameAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
Snowflake userId,
|
||||||
|
string prevName,
|
||||||
|
string newName
|
||||||
|
) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<IEnumerable<(string Name, string Id)>> GetMemberNamesAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
string prefix
|
||||||
|
) => Task.FromResult<IEnumerable<(string, string)>>([]);
|
||||||
|
|
||||||
|
public Task TryRemoveMemberNameAsync(Snowflake guildId, string username) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,14 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
using Catalogger.Backend.Database.Redis;
|
using Catalogger.Backend.Database.Redis;
|
||||||
|
using Catalogger.Backend.Extensions;
|
||||||
using Remora.Discord.API;
|
using Remora.Discord.API;
|
||||||
using Remora.Discord.API.Abstractions.Gateway.Events;
|
using Remora.Discord.API.Abstractions.Gateway.Events;
|
||||||
using Remora.Discord.API.Abstractions.Objects;
|
using Remora.Discord.API.Abstractions.Objects;
|
||||||
using Remora.Discord.API.Abstractions.Rest;
|
using Remora.Discord.API.Abstractions.Rest;
|
||||||
using Remora.Discord.API.Objects;
|
using Remora.Discord.API.Objects;
|
||||||
using Remora.Rest.Core;
|
using Remora.Rest.Core;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
namespace Catalogger.Backend.Cache.RedisCache;
|
namespace Catalogger.Backend.Cache.RedisCache;
|
||||||
|
|
||||||
|
|
@ -146,9 +148,55 @@ public class RedisMemberCache(
|
||||||
await SetInnerAsync(newMember.GuildID, member);
|
await SetInnerAsync(newMember.GuildID, member);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SetMemberNamesAsync(Snowflake guildId, IEnumerable<IGuildMember> members)
|
||||||
|
{
|
||||||
|
await redisService
|
||||||
|
.GetDatabase()
|
||||||
|
.HashSetAsync(
|
||||||
|
MemberNamesKey(guildId),
|
||||||
|
members
|
||||||
|
.Select(m => new HashEntry(m.User.Value.Tag(), m.User.Value.ID.ToString()))
|
||||||
|
.ToArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateMemberNameAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
Snowflake userId,
|
||||||
|
string prevName,
|
||||||
|
string newName
|
||||||
|
) =>
|
||||||
|
await Task.WhenAll(
|
||||||
|
redisService.GetDatabase().HashDeleteAsync(MemberNamesKey(guildId), prevName),
|
||||||
|
redisService
|
||||||
|
.GetDatabase()
|
||||||
|
.HashSetAsync(MemberNamesKey(guildId), newName, userId.ToString())
|
||||||
|
);
|
||||||
|
|
||||||
|
public async Task<IEnumerable<(string Name, string Id)>> GetMemberNamesAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
string prefix
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var entries = redisService
|
||||||
|
.GetDatabase()
|
||||||
|
.HashScanAsync(MemberNamesKey(guildId), $"{prefix}*", 50);
|
||||||
|
|
||||||
|
var names = new List<(string Name, string Id)>();
|
||||||
|
await foreach (var entry in entries)
|
||||||
|
names.Add((entry.Name, entry.Value)!);
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TryRemoveMemberNameAsync(Snowflake guildId, string username) =>
|
||||||
|
await redisService.GetDatabase().HashDeleteAsync(MemberNamesKey(guildId), username);
|
||||||
|
|
||||||
private const string GuildCacheKey = "cached-guilds";
|
private const string GuildCacheKey = "cached-guilds";
|
||||||
|
|
||||||
private static string GuildMembersKey(Snowflake guildId) => $"guild-members:{guildId}";
|
private static string GuildMembersKey(Snowflake guildId) => $"guild-members:{guildId}";
|
||||||
|
|
||||||
|
private static string MemberNamesKey(Snowflake guildId) => $"guild-member-names:{guildId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
internal record RedisMember(
|
internal record RedisMember(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export const TOKEN_KEY = "catalogger-token";
|
export const TOKEN_KEY = "catalogger-token";
|
||||||
|
|
||||||
export type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE";
|
export type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||||
|
|
||||||
export async function fastFetch(
|
export async function fastFetch(
|
||||||
method: HttpMethod,
|
method: HttpMethod,
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<slot />
|
<slot />
|
||||||
<div class="position-absolute top-0 start-50 translate-middle-x px-2">
|
<div class="position-absolute top-0 start-50 translate-middle-x">
|
||||||
{#each $toastStore as toast}
|
{#each $toastStore as toast}
|
||||||
<Toast>
|
<Toast class="mt-2">
|
||||||
{#if toast.header}<ToastHeader>{toast.header}</ToastHeader>{/if}
|
{#if toast.header}<ToastHeader>{toast.header}</ToastHeader>{/if}
|
||||||
<ToastBody>{toast.body}</ToastBody>
|
<ToastBody>{toast.body}</ToastBody>
|
||||||
</Toast>
|
</Toast>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ListGroup, ListGroupItem } from "@sveltestrap/sveltestrap";
|
import { ListGroup, ListGroupItem } from "@sveltestrap/sveltestrap";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import GuildCard from "./GuildCard.svelte";
|
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,82 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label } from "@sveltestrap/sveltestrap";
|
import {
|
||||||
|
Label,
|
||||||
|
ListGroup,
|
||||||
|
ListGroupItem,
|
||||||
|
Button,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import { makeFullOptions } from "$lib/util";
|
import { makeFullOptions } from "$lib/util";
|
||||||
import ChannelSelect from "../ChannelSelect.svelte";
|
import ChannelSelect from "../ChannelSelect.svelte";
|
||||||
|
import { fastFetch, type ApiError } from "$lib/api";
|
||||||
|
import { addToast } from "$lib/toast";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
$: ignored = data.guild.config.ignored_channels;
|
$: ignored = data.guild.config.ignored_channels;
|
||||||
$: options = makeFullOptions(data.guild, ignored);
|
$: options = makeFullOptions(data.guild, ignored);
|
||||||
|
|
||||||
|
$: allChannels = [
|
||||||
|
...data.guild.channels_without_category.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: `#${c.name}`,
|
||||||
|
})),
|
||||||
|
...data.guild.categories.map((cat) => ({ id: cat.id, name: cat.name })),
|
||||||
|
...data.guild.categories.flatMap((cat) =>
|
||||||
|
cat.channels.map((c) => ({ id: c.id, name: `#${c.name}` })),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const channelName = (id: string) =>
|
||||||
|
allChannels.find((c) => c.id === id)?.name || `(unknown channel ${id})`;
|
||||||
|
|
||||||
|
let toIgnore: string | null = null;
|
||||||
|
|
||||||
|
const addIgnore = async () => {
|
||||||
|
if (!toIgnore) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastFetch(
|
||||||
|
"PUT",
|
||||||
|
`/api/guilds/${data.guild.id}/ignored-channels/${toIgnore}`,
|
||||||
|
);
|
||||||
|
ignored.push(toIgnore);
|
||||||
|
ignored = ignored;
|
||||||
|
addToast({
|
||||||
|
header: "Ignored channel",
|
||||||
|
body: `Added ${channelName(toIgnore)} to the list of ignored channels.`,
|
||||||
|
});
|
||||||
|
toIgnore = null;
|
||||||
|
} catch (e) {
|
||||||
|
addToast({
|
||||||
|
header: "Error ignoring channel",
|
||||||
|
body:
|
||||||
|
(e as ApiError).message || "Unknown error. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeIgnore = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await fastFetch(
|
||||||
|
"DELETE",
|
||||||
|
`/api/guilds/${data.guild.id}/ignored-channels/${id}`,
|
||||||
|
);
|
||||||
|
const idx = ignored.indexOf(id);
|
||||||
|
if (idx > -1) ignored.splice(idx, 1);
|
||||||
|
ignored = ignored;
|
||||||
|
addToast({
|
||||||
|
header: "Stopped ignoring channel",
|
||||||
|
body: `Removed ${channelName(id)} from the list of ignored channels.`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
addToast({
|
||||||
|
header: "Error removing channel",
|
||||||
|
body:
|
||||||
|
(e as ApiError).message || "Unknown error. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h3>Ignored channels</h3>
|
<h3>Ignored channels</h3>
|
||||||
|
|
@ -17,7 +86,28 @@
|
||||||
ignore channel update events, any changes to the channel will still be logged.
|
ignore channel update events, any changes to the channel will still be logged.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="p-2">
|
<div>
|
||||||
<Label><strong>Ignored channels</strong></Label>
|
<Label><strong>Ignore a new channel</strong></Label>
|
||||||
<ChannelSelect bind:value={ignored} {options} multiple={true} />
|
<ChannelSelect bind:value={toIgnore} {options} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="my-2 d-grid d-md-block">
|
||||||
|
<Button color="primary" on:click={() => addIgnore()} disabled={!toIgnore}>
|
||||||
|
Ignore channel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4>Currently ignored channels</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ListGroup>
|
||||||
|
{#each ignored as id}
|
||||||
|
<ListGroupItem class="d-flex justify-content-between align-items-center">
|
||||||
|
{channelName(id)}
|
||||||
|
<Button color="link" on:click={() => removeIgnore(id)}>
|
||||||
|
Stop ignoring
|
||||||
|
</Button>
|
||||||
|
</ListGroupItem>
|
||||||
|
{/each}
|
||||||
|
</ListGroup>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue