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:
parent
47c3b4a9e9
commit
3547b471ea
9 changed files with 128 additions and 25 deletions
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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 |
83
resources/js/components/SearchableSelect.vue
Normal file
83
resources/js/components/SearchableSelect.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue