fix: resolve UI state reset and backlog sync hang
- Fix client startup order: fetch/apply server UI state BEFORE loadGuilds() to prevent overwriting persisted state with default guild - Remove auto-post of first guild in loadGuilds() — let server state drive selection - Refactor collectWatchableChannels() to collect text channels fast first, then discover threads in parallel with 5s per-channel timeout and 30s overall timeout to prevent blocking message sync - Ignore /favicon.ico 404 in error logging to reduce noise Fixes: - UI state now persists across restart (was being overwritten by client startup race) - Backlog sync no longer hangs on thread discovery (was blocking before message sync) - Cleaner logs without favicon 404 errors Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -85,7 +85,7 @@
|
|||||||
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
|
||||||
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
|
||||||
|
|
||||||
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } else if (guilds[0]?.id) { await postUIState({ selectedGuild: guilds[0].id }); } }
|
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } }
|
||||||
async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
|
async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
|
||||||
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
|
||||||
async function connectVoice() { const guildId = el.guildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
async function connectVoice() { const guildId = el.guildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
|
||||||
|
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
loadGuilds().then(refreshStatus).then(() => apiRequest('/api/ui-state')).then(applyServerState).catch((error) => showError(error.message));
|
apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).catch((error) => showError(error.message));
|
||||||
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
|
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -17,24 +17,51 @@ function isWatchableChannel(channel: { type?: string; messages?: unknown }): boo
|
|||||||
|
|
||||||
async function collectWatchableChannels(guild: any): Promise<any[]> {
|
async function collectWatchableChannels(guild: any): Promise<any[]> {
|
||||||
const channels: any[] = [];
|
const channels: any[] = [];
|
||||||
|
|
||||||
|
// Fast pass: collect text channels from cache only
|
||||||
for (const channel of guild.channels.cache.values()) {
|
for (const channel of guild.channels.cache.values()) {
|
||||||
if (isWatchableChannel(channel)) {
|
if (isWatchableChannel(channel)) {
|
||||||
channels.push(channel);
|
channels.push(channel);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (channel.threads?.fetch) {
|
// Slow pass: discover threads with timeout per channel (non-blocking to message sync)
|
||||||
|
const threadPromises: Promise<void>[] = [];
|
||||||
|
for (const channel of guild.channels.cache.values()) {
|
||||||
|
if (!channel.threads?.fetch) continue;
|
||||||
|
|
||||||
|
threadPromises.push(
|
||||||
|
(async () => {
|
||||||
for (const archived of [false, true]) {
|
for (const archived of [false, true]) {
|
||||||
const fetched = await channel.threads
|
try {
|
||||||
.fetch({ archived, limit: 100 })
|
const controller = new AbortController();
|
||||||
.catch(() => null);
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
const fetched = await Promise.race([
|
||||||
|
channel.threads.fetch({ archived, limit: 100 }),
|
||||||
|
new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('timeout')))),
|
||||||
|
]).catch(() => null);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!fetched?.threads) continue;
|
if (!fetched?.threads) continue;
|
||||||
for (const thread of fetched.threads.values()) {
|
for (const thread of fetched.threads.values()) {
|
||||||
if (isWatchableChannel(thread)) channels.push(thread);
|
if (isWatchableChannel(thread)) channels.push(thread);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip this channel's threads on timeout/error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for all thread discoveries with overall timeout
|
||||||
|
await Promise.race([
|
||||||
|
Promise.all(threadPromises),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 30000)),
|
||||||
|
]).catch(() => {
|
||||||
|
logger.warn("Thread discovery timeout, proceeding with cached channels");
|
||||||
|
});
|
||||||
|
|
||||||
return Array.from(new Map(channels.map((channel) => [channel.id, channel])).values());
|
return Array.from(new Map(channels.map((channel) => [channel.id, channel])).values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export function startWebserver(
|
|||||||
}
|
}
|
||||||
res.on("finish", () => {
|
res.on("finish", () => {
|
||||||
if (req.originalUrl.startsWith("/.well-known/appspecific/")) return;
|
if (req.originalUrl.startsWith("/.well-known/appspecific/")) return;
|
||||||
|
if (req.originalUrl === "/favicon.ico") return;
|
||||||
if (res.statusCode >= 400) {
|
if (res.statusCode >= 400) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ method: req.method, url: req.originalUrl, statusCode: res.statusCode },
|
{ method: req.method, url: req.originalUrl, statusCode: res.statusCode },
|
||||||
|
|||||||
Reference in New Issue
Block a user