UI & Control Panel Architecture
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.