feat(discord): implement channel caching and enhance UI for channel selection

- Introduced caching for Discord channel lookups, reducing API calls and improving performance.
- Updated the DiscordSettings component to use a SearchableSelect for better user experience when selecting channels.
- Added new language strings for channel search and no channels found messages in English, Spanish, and Portuguese.
- Updated tests to ensure channel list caching is isolated between runs.
This commit is contained in:
Paulo Castellano 2026-06-16 15:53:20 -03:00
parent 47c3b4a9e9
commit 3547b471ea
9 changed files with 128 additions and 25 deletions

View file

@ -8,6 +8,7 @@
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
/**
* Read-side Discord API calls made with the global bot token (channel listing,
@ -22,6 +23,8 @@ class DiscordClient
*/
private const POSTABLE_CHANNEL_TYPES = [0, 5, 15];
private const CHANNELS_TTL = 300;
public function baseUrl(): string
{
return (string) config('trypost.platforms.discord.api');
@ -38,26 +41,32 @@ public function baseUrl(): string
*/
public function channels(string $guildId): array
{
$response = $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}/channels");
// Cached per guild for 5 minutes — channels change rarely and this runs
// both in the composer picker and on every publish (the channel guard),
// all against the shared, rate-limited bot token. A transient failure
// throws and is NOT cached, so the next call retries.
return Cache::remember("discord:channels:{$guildId}", self::CHANNELS_TTL, function () use ($guildId) {
$response = $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}/channels");
if ($response->failed()) {
throw new PlatformUnavailableException("Discord channel lookup failed ({$response->status()}).", $response->status());
}
if ($response->failed()) {
throw new PlatformUnavailableException("Discord channel lookup failed ({$response->status()}).", $response->status());
}
$channels = $response->json();
$channels = $response->json();
if (! is_array($channels)) {
return [];
}
if (! is_array($channels)) {
return [];
}
return collect($channels)
->filter(fn ($channel) => in_array((int) data_get($channel, 'type'), self::POSTABLE_CHANNEL_TYPES, true))
->map(fn ($channel) => [
'id' => (string) data_get($channel, 'id'),
'name' => (string) data_get($channel, 'name'),
])
->values()
->all();
return collect($channels)
->filter(fn ($channel) => in_array((int) data_get($channel, 'type'), self::POSTABLE_CHANNEL_TYPES, true))
->map(fn ($channel) => [
'id' => (string) data_get($channel, 'id'),
'name' => (string) data_get($channel, 'name'),
])
->values()
->all();
});
}
/**

View file

@ -167,6 +167,8 @@
'channel' => 'Channel',
'select_channel' => 'Select a channel',
'loading_channels' => 'Loading channels…',
'search_channel' => 'Search channels…',
'no_channels' => 'No channels found.',
'channel_required' => 'Select a Discord channel to publish this post.',
'mentions' => 'Mentions',
'search_mention' => 'Mention a role or member…',

View file

@ -167,6 +167,8 @@
'channel' => 'Canal',
'select_channel' => 'Selecciona un canal',
'loading_channels' => 'Cargando canales…',
'search_channel' => 'Buscar canales…',
'no_channels' => 'No se encontraron canales.',
'channel_required' => 'Selecciona un canal de Discord para publicar este post.',
'mentions' => 'Menciones',
'search_mention' => 'Menciona un rol o miembro…',

View file

@ -167,6 +167,8 @@
'channel' => 'Canal',
'select_channel' => 'Selecione um canal',
'loading_channels' => 'Carregando canais…',
'search_channel' => 'Buscar canais…',
'no_channels' => 'Nenhum canal encontrado.',
'channel_required' => 'Selecione um canal do Discord para publicar este post.',
'mentions' => 'Menções',
'search_mention' => 'Mencione um cargo ou membro…',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,83 @@
<script setup lang="ts">
import { IconCheck, IconChevronDown } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
interface Option {
value: string;
label: string;
}
const props = withDefaults(
defineProps<{
options: Option[];
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
invalid?: boolean;
}>(),
{
placeholder: 'Select…',
searchPlaceholder: 'Search…',
emptyText: 'No results.',
disabled: false,
invalid: false,
},
);
const value = defineModel<string>({ default: '' });
const open = ref(false);
const selected = computed(() => props.options.find((option) => option.value === value.value));
const select = (option: Option) => {
value.value = option.value;
open.value = false;
};
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
role="combobox"
:aria-expanded="open"
:disabled="disabled"
class="w-full justify-between font-normal"
:class="invalid ? 'border-rose-500' : ''"
>
<span :class="selected ? 'text-foreground' : 'text-foreground/50'">
{{ selected ? selected.label : placeholder }}
</span>
<IconChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-(--reka-popover-trigger-width) p-0" align="start">
<Command>
<CommandInput :placeholder="searchPlaceholder" />
<CommandList>
<CommandEmpty>{{ emptyText }}</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="option in options"
:key="option.value"
:value="option.label"
@select="select(option)"
>
{{ option.label }}
<IconCheck :class="cn('ml-auto size-4', value === option.value ? 'opacity-100' : 'opacity-0')" />
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>

View file

@ -5,6 +5,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { channels as channelsRoute, mentions as mentionsRoute } from '@/actions/App/Http/Controllers/App/DiscordController';
import InputError from '@/components/InputError.vue';
import SearchableSelect from '@/components/SearchableSelect.vue';
import { Avatar } from '@/components/ui/avatar';
import { Input } from '@/components/ui/input';
import { usePageErrors } from '@/composables/usePageErrors';
@ -97,6 +98,8 @@ const channelOptions = computed<DiscordChannel[]>(() => {
return channels.value;
});
const channelSelectOptions = computed(() => channelOptions.value.map((channel) => ({ value: channel.id, label: `#${channel.name}` })));
const errors = usePageErrors();
const channelError = computed<string | undefined>(() => {
if (props.meta?.channel_id) {
@ -203,17 +206,15 @@ const updateEmbed = (index: number, patch: Partial<EmbedDraft>) =>
<!-- Channel -->
<div class="space-y-2">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.channel') }}</p>
<select
<SearchableSelect
v-model="channelId"
:options="channelSelectOptions"
:placeholder="channelsLoading ? $t('posts.form.discord.loading_channels') : $t('posts.form.discord.select_channel')"
:search-placeholder="$t('posts.form.discord.search_channel')"
:empty-text="$t('posts.form.discord.no_channels')"
:disabled="disabled || channelsLoading"
class="w-full rounded-lg border-2 bg-card px-3 py-2 text-sm font-medium text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="channelError ? 'border-rose-500' : 'border-foreground/30 hover:border-foreground'"
>
<option value="">
{{ channelsLoading ? $t('posts.form.discord.loading_channels') : $t('posts.form.discord.select_channel') }}
</option>
<option v-for="channel in channelOptions" :key="channel.id" :value="channel.id">#{{ channel.name }}</option>
</select>
:invalid="!!channelError"
/>
<InputError :message="channelError" />
</div>

View file

@ -6,9 +6,11 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
Cache::flush(); // channel list is cached per guild — isolate between tests
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
$this->user = User::factory()->create();

View file

@ -13,9 +13,11 @@
use App\Models\Workspace;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Discord\DiscordPublisher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
Cache::flush(); // channel list is cached per guild — isolate between tests
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
$this->user = User::factory()->create();