UI & Control Panel Architecture

Last Updated: 2026-05-26 • For Frontend & Interaction Designers

Overview

This guide documents the UI component architecture, embed caching, control panel builders, and message updater flow for QuranBot's interactive Discord interface.

  • Standardized embed factory
  • Component builders (buttons, menus)
  • Message updater flow
  • Embed caching strategy

Embed Architecture

QuranBot uses a factory pattern for consistent embed creation across all commands and interactions.

Embed Factory (src/ui/embedFactory.js)

function createStandardEmbed() {
    return new EmbedBuilder().setColor(0x1e1f22);
}

function createLoadingEmbed(desc) {
    return createStandardEmbed().setTitle('جاري التحميل').setDescription(desc);
}

function createPrayerTimesDisplay(city, country, data, flag = '') {
    return createStandardEmbed()
        .setTitle(`${flag} مواقيت الصلاة`)
        .setDescription(`**${city} - ${country}**`)
        .addFields(
            { name: 'الفجر', value: data.fajr, inline: true },
            { name: 'الشروق', value: data.sunrise, inline: true },
            // ... other prayer fields
        )
        .setFooter({ text: prayer_times_footer });
}

Component Builders

All interactive UI elements are generated via standardized component builders in src/ui/components.js:

Builder Output Usage
createReciterRow ActionRow with StringSelectMenu Reciter selection dropdown
createSelectRow ActionRow with StringSelectMenu Surah selection dropdown
createButtonRow ActionRow with 5 Buttons Playback controls (prev, pause, resume, next, toggle)
createNavigationRow 2-3 ActionRows Page navigation + mode toggle + system buttons
createRadioRow ActionRow with StringSelectMenu Radio station selection dropdown

Control Panel Builder

The control panel is dynamically assembled based on guild state:

// src/ui/controlPanelBuilder.js
async function rebuildAndSendControlPanel(interaction, guildState, guildId) {
    const embed = createControlEmbed(guildState, guildId);
    const rows = [];
    
    if (['surah', 'juz'].includes(guildState.playbackMode)) {
        rows.push(createReciterRow(guildState), createSelectRow(guildState));
    } else {
        rows.push(createRadioRow(guildState));
    }
    
    rows.push(createButtonRow(guildState));
    
    for (const nav of createNavigationRow(guildState, guildId)) {
        if (rows.length >= 5) break;
        if (nav.components?.length <= 5) rows.push(nav);
    }
    
    try {
        await updateControlMessage(interaction, embed, rows.slice(0, 5));
        await saveControlId(guildId, interaction.channelId, interaction.message.id);
        return true;
    } catch (err) {
        logger.error('Control panel update failed: ' + err.message);
        return false;
    }
}

Message Updater Flow

Handles Discord's interaction expiration and message not found scenarios:

// src/interactions/flow/messageUpdater.js
async function updateControlMessage(interaction, embed, components) {
    try {
        if (interaction.replied || interaction.deferred) {
            return await interaction.editReply({ embeds: [embed], components });
        } else {
            return await interaction.update({ embeds: [embed], components });
        }
    } catch (error) {
        logger.error('Error Updating Control Message', error);
        
        if (error.code === 10062 || error.message?.includes('Unknown interaction')) {
            logger.debug('Interaction Expired Cannot Update');
            return null;
        }
        
        if (error.code === 10008 || error.message?.includes('Unknown Message')) {
            logger.debug('Message Not Found Cannot Update');
            return null;
        }
        
        // Fallback: send as new message and track ID
        // ... fallback logic
        return null;
    }
}

Pagination System

Reusable pagination utilities in src/ui/pagination.js handle page calculation and button creation:

Function Parameters Return
calculatePagination total, page, limit { totalPages, currentPage, startIndex, endIndex }
createPaginationRow page, totalPages, config ActionRowBuilder with prev/next buttons

Embed Caching

Control embeds are cached to reduce Discord API rate limits and improve response time:

// Cache key generation
const key =
    'control_embed_' +
    guildId +
    '_' + st.currentSurah +
    '_' + st.currentReciter +
    '_' + st.currentPage +
    '_' + st.playbackMode +
    '_' + st.controlMode;

if (cache.has(key)) {
    const c = cache.get(key);
    c.active = true;
    c.timestamp = Date.now();
    return c.embed;
}

// Auto-cleanup interval runs every 10s
setInterval(() => {
    const now = Date.now();
    for (const [k, { timestamp, active }] of cache.entries()) {
        const ttl = active ? 30000 : 60000;
        if (now - timestamp > ttl) cache.delete(k);
    }
    
    if (cache.size > 100) {
        // Remove half if exceeding limit
    }
}, 10000);

Troubleshooting UI Issues

Control panel shows "Unknown interaction"

Discord interactions expire after 15 minutes. Use rebuildAndSendControlPanel to generate a fresh panel. The message updater automatically falls back to sending a new message if the original expires.

Embed cache not updating after state changes

Clear the cache manually: clearEmbedCache(guildId) or wait for TTL expiry (30s for active, 60s for inactive). The cache key includes all state variables, so any state change will bypass the cache.