Skip to content

Commit e0285e7

Browse files
committed
Fix screen-share reshare black-screen & invisible tile; add channel auto-clear messages timer mode (#5390)
Screen share: stopScreenShare now awaits per-peer renegotiation with Promise.allSettled (8s safety cap) instead of racing against a 3s timeout, dead-track detection in sameLiveTrack guard forces srcObject reassignment on reshare, and ontrack.onended skips tile teardown if a new screen-share is already registered. Auto-clear (#5390): new auto_delete_mode + auto_delete_interval_hours columns, set-channel-expiry accepts mode, cleanup interval branches between full delete and message wipe, badge shows hours+recur glyph, channel-messages-cleared event refreshes the viewer.
1 parent c2e520f commit e0285e7

8 files changed

Lines changed: 148 additions & 30 deletions

File tree

public/js/modules/app-channels.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,11 @@ _updateChannelFunctionsPanel(ch) {
500500
const hasExpiry = !!ch.expires_at;
501501
if (hasExpiry) {
502502
const hoursLeft = Math.max(1, Math.round((new Date(ch.expires_at) - Date.now()) / 3600000));
503-
this._setCfnBadge('self-destruct', true, `${hoursLeft}h`);
503+
// #5390 — distinguish 'clear messages' mode from full channel deletion
504+
// so admins can tell at a glance what the timer will do. The ↻ glyph
505+
// hints that the clear timer rearms itself.
506+
const isClear = ch.auto_delete_mode === 'clear';
507+
this._setCfnBadge('self-destruct', true, isClear ? `${hoursLeft}h ↻` : `${hoursLeft}h`);
504508
} else {
505509
this._setCfnBadge('self-destruct', false, 'OFF');
506510
}

public/js/modules/app-socket.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,18 @@ _setupSocketListeners() {
11461146
}
11471147
});
11481148

1149+
// #5390 — sister event of channel-deleted: messages were wiped via the
1150+
// auto-clear self-destruct mode but the channel itself still exists.
1151+
// If the user is viewing the affected channel, refetch its messages by
1152+
// resetting the view. Otherwise nothing visual needs to change.
1153+
this.socket.on('channel-messages-cleared', (data) => {
1154+
if (!data || !data.code) return;
1155+
if (this.currentChannel === data.code) {
1156+
try { this.switchChannel(data.code); } catch (e) { console.warn('[auto-clear] re-switch failed:', e); }
1157+
this._showToast('🧹 Channel messages were auto-cleared', 'info');
1158+
}
1159+
});
1160+
11491161
// ── Temporary voice channel events (#163) ──────────────
11501162
this.socket.on('temp-channel-created', (channel) => {
11511163
if (!this.channels.find(c => c.code === channel.code)) {

public/js/modules/app-ui.js

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -511,27 +511,59 @@ _setupUI() {
511511
if (row.querySelector('.cfn-input')) return;
512512
const badge = row.querySelector('.cfn-badge');
513513
if (!badge) return;
514+
// #5390 — self-destruct now has two modes: 'delete' (legacy: remove
515+
// the whole channel when the timer fires) and 'clear' (wipe messages
516+
// only, then rearm the timer at the same interval). We render the
517+
// hours input next to a mode select so admins can pick both at once.
518+
const wrap = document.createElement('span');
519+
wrap.className = 'cfn-input-wrap';
514520
const input = document.createElement('input');
515521
input.type = 'number'; input.min = '0'; input.max = '720';
516-
input.value = ''; input.placeholder = t('channel_functions.self_destruct_placeholder'); input.className = 'cfn-input';
522+
input.value = ''; input.placeholder = t('channel_functions.self_destruct_placeholder'); input.className = 'cfn-input cfn-input-hours';
517523
input.onclick = e2 => e2.stopPropagation();
518-
badge.replaceWith(input);
524+
const modeSelect = document.createElement('select');
525+
modeSelect.className = 'cfn-input cfn-mode-select';
526+
const optDelete = document.createElement('option');
527+
optDelete.value = 'delete';
528+
optDelete.textContent = t('channel_functions.self_destruct_mode_delete') || 'Delete channel';
529+
const optClear = document.createElement('option');
530+
optClear.value = 'clear';
531+
optClear.textContent = t('channel_functions.self_destruct_mode_clear') || 'Clear messages';
532+
modeSelect.appendChild(optDelete);
533+
modeSelect.appendChild(optClear);
534+
modeSelect.value = ch?.auto_delete_mode === 'clear' ? 'clear' : 'delete';
535+
modeSelect.onclick = e2 => e2.stopPropagation();
536+
wrap.appendChild(input);
537+
wrap.appendChild(modeSelect);
538+
badge.replaceWith(wrap);
519539
input.focus(); input.select();
540+
let committed = false;
520541
const commitExpiry = () => {
542+
if (committed) return;
521543
const hours = parseInt(input.value);
522544
if (isNaN(hours) || hours < 0) return;
545+
committed = true;
546+
const mode = modeSelect.value === 'clear' ? 'clear' : 'delete';
523547
if (hours === 0) {
524-
optimistic({ expires_at: null });
525-
this.socket.emit('set-channel-expiry', { code, hours: 0 });
548+
optimistic({ expires_at: null, auto_delete_mode: 'delete', auto_delete_interval_hours: null });
549+
this.socket.emit('set-channel-expiry', { code, hours: 0, mode });
526550
} else {
527551
const clamped = Math.max(1, Math.min(720, hours));
528552
const expiresAt = new Date(Date.now() + clamped * 3600000).toISOString();
529-
optimistic({ expires_at: expiresAt });
530-
this.socket.emit('set-channel-expiry', { code, hours: clamped });
553+
optimistic({ expires_at: expiresAt, auto_delete_mode: mode, auto_delete_interval_hours: clamped });
554+
this.socket.emit('set-channel-expiry', { code, hours: clamped, mode });
531555
}
532556
};
557+
// Delay blur-commit briefly so focus moving between input and select
558+
// inside the wrap doesn't fire a premature commit with stale values.
559+
const onBlur = () => {
560+
setTimeout(() => {
561+
if (!wrap.contains(document.activeElement)) commitExpiry();
562+
}, 50);
563+
};
533564
input.addEventListener('keydown', e2 => { if (e2.key === 'Enter') { commitExpiry(); input.blur(); } });
534-
input.addEventListener('blur', commitExpiry);
565+
input.addEventListener('blur', onBlur);
566+
modeSelect.addEventListener('blur', onBlur);
535567
} else if (fn === 'afk-sub') {
536568
// Show a select dropdown of sub-channels for this parent
537569
if (row.querySelector('.cfn-select')) return;

public/js/modules/app-voice.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -999,7 +999,14 @@ _handleScreenStream(userId, stream, { force = false } = {}) {
999999
const newVideoTrack = stream.getVideoTracks()[0] || null;
10001000
const curStream = videoEl.srcObject;
10011001
const curVideoTrack = curStream ? (curStream.getVideoTracks?.()[0] || null) : null;
1002-
const sameLiveTrack = newVideoTrack && curVideoTrack &&
1002+
// If the currently attached track is dead (readyState !== 'live'), we MUST
1003+
// reassign — even if the new track has the same id, the browser will keep
1004+
// rendering a black frame from the dead source. This happens on reshare
1005+
// when the sharer's stopScreenShare ends the track but the viewer's
1006+
// element still holds a reference to that dead MediaStreamTrack.
1007+
const curTrackIsDead = !!curVideoTrack && curVideoTrack.readyState !== 'live';
1008+
const sameLiveTrack = !curTrackIsDead &&
1009+
newVideoTrack && curVideoTrack &&
10031010
newVideoTrack.id === curVideoTrack.id &&
10041011
newVideoTrack.readyState === 'live' &&
10051012
videoEl.videoWidth > 0;

public/js/voice.js

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -986,11 +986,20 @@ class VoiceManager {
986986
renegotiations.push(this._renegotiate(userId, peer.connection).catch(() => {}));
987987
}
988988

989-
// Wait for all renegotiations to complete (with a timeout so we don't hang forever)
989+
// Wait for ALL renegotiations to actually finish before tearing the
990+
// tracks down. The previous Promise.race(..., 3s) here was the cause of
991+
// the months-long "black screen on reshare" / "streaming but no tile"
992+
// bugs: if any peer's _renegotiate was still in flight when the 3s
993+
// expired (perfectly possible since _renegotiate itself can wait up to
994+
// 5s for signaling state to settle), this function would return, kill
995+
// the tracks, and leave that peer's transceiver mid-direction-change.
996+
// On the next startScreenShare the new addTrack would reuse that broken
997+
// transceiver and ontrack would never fire on the viewer side — exactly
998+
// the symptom users reported. Use allSettled with a generous safety cap.
990999
try {
9911000
await Promise.race([
992-
Promise.all(renegotiations),
993-
new Promise(resolve => setTimeout(resolve, 3000))
1001+
Promise.allSettled(renegotiations),
1002+
new Promise(resolve => setTimeout(resolve, 8000))
9941003
]);
9951004
} catch { /* proceed anyway */ }
9961005

@@ -1348,7 +1357,19 @@ class VoiceManager {
13481357
};
13491358
track.onmute = () => {};
13501359
track.onended = () => {
1351-
if (this.onScreenStream) this.onScreenStream(userId, null);
1360+
// Don't tear down the tile if the sharer is in the middle of a
1361+
// stop+restart cycle. Their old track ends naturally as part of
1362+
// stopScreenShare, but the screenSharers set (driven by the
1363+
// server's screen-share-started/stopped events) is still true
1364+
// until we get screen-share-stopped. If we cleared the tile
1365+
// here on every onended, the viewer would see the tile vanish
1366+
// and the next track would have to recreate everything — which
1367+
// is fine in theory but masked the stuck-transceiver bug for
1368+
// months by making it look like "the new share never arrived".
1369+
// Only clear when the server has actually told us they stopped.
1370+
if (!this.screenSharers.has(userId)) {
1371+
if (this.onScreenStream) this.onScreenStream(userId, null);
1372+
}
13521373
};
13531374
// Check if any deferred audio belongs to this screen stream
13541375
for (let i = deferredAudio.length - 1; i >= 0; i--) {

src/database.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,14 @@ function initDatabase() {
665665
{ name: 'notification_type', sql: "ALTER TABLE channels ADD COLUMN notification_type TEXT DEFAULT 'default'" },
666666
{ name: 'voice_enabled', sql: "ALTER TABLE channels ADD COLUMN voice_enabled INTEGER DEFAULT 1" },
667667
{ name: 'text_enabled', sql: "ALTER TABLE channels ADD COLUMN text_enabled INTEGER DEFAULT 1" },
668+
// #5390 — extend the self-destruct timer with a "clear messages only"
669+
// mode. `auto_delete_mode` is 'delete' (existing behaviour: drop the
670+
// whole channel) or 'clear' (wipe messages but keep channel, perms,
671+
// roles, integrations). `auto_delete_interval_hours` stores the
672+
// original interval so a 'clear' timer can rearm itself after firing
673+
// (recurring sweep) instead of being a one-shot.
674+
{ name: 'auto_delete_mode', sql: "ALTER TABLE channels ADD COLUMN auto_delete_mode TEXT DEFAULT 'delete'" },
675+
{ name: 'auto_delete_interval_hours', sql: "ALTER TABLE channels ADD COLUMN auto_delete_interval_hours INTEGER DEFAULT NULL" },
668676
];
669677
for (const col of channelQolCols) {
670678
try { db.prepare(`SELECT ${col.name} FROM channels LIMIT 0`).get(); } catch { db.exec(col.sql); }

src/socketHandlers/channels.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -784,18 +784,28 @@ module.exports = function register(socket, ctx) {
784784
if (!code || !/^[a-f0-9]{8}$/i.test(code)) return;
785785
const channel = db.prepare('SELECT id FROM channels WHERE code = ? AND is_dm = 0').get(code);
786786
if (!channel) return socket.emit('error-msg', 'Channel not found');
787+
// #5390 — second mode: 'clear' wipes messages periodically instead of
788+
// deleting the whole channel. Anything other than 'clear' is treated
789+
// as the legacy 'delete' behaviour so unknown values fail closed
790+
// toward the original semantics.
791+
const mode = data.mode === 'clear' ? 'clear' : 'delete';
787792
let expiresAt = null;
793+
let intervalHours = null;
788794
if (data.hours && data.hours > 0) {
789795
const hours = Math.max(1, Math.min(720, parseInt(data.hours, 10)));
790796
if (isNaN(hours)) return socket.emit('error-msg', 'Invalid duration');
791797
expiresAt = new Date(Date.now() + hours * 3600000).toISOString();
798+
intervalHours = hours;
792799
}
793800
try {
794-
db.prepare('UPDATE channels SET expires_at = ? WHERE id = ?').run(expiresAt, channel.id);
801+
db.prepare(
802+
'UPDATE channels SET expires_at = ?, auto_delete_mode = ?, auto_delete_interval_hours = ? WHERE id = ?'
803+
).run(expiresAt, mode, intervalHours, channel.id);
795804
broadcastChannelLists();
796805
if (expiresAt) {
797806
const hours = Math.round((new Date(expiresAt) - Date.now()) / 3600000);
798-
socket.emit('toast', { message: `⏱️ Channel will self-destruct in ${hours}h`, type: 'success' });
807+
const verb = mode === 'clear' ? `clear messages every ${hours}h` : `self-destruct in ${hours}h`;
808+
socket.emit('toast', { message: `⏱️ Channel will ${verb}`, type: 'success' });
799809
} else {
800810
socket.emit('toast', { message: '⏱️ Self-destruct timer removed', type: 'success' });
801811
}

src/socketHandlers/index.js

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,15 @@ function setupSocketHandlers(io, db) {
214214
c.parent_channel_id, c.position, c.is_private, c.expires_at, c.is_temp_voice,
215215
c.streams_enabled, c.music_enabled, c.media_enabled, c.slow_mode_interval, c.category, c.sort_alphabetical,
216216
c.cleanup_exempt, c.channel_type, c.voice_user_limit, c.notification_type, c.voice_enabled, c.text_enabled, c.voice_bitrate,
217-
c.afk_sub_code, c.afk_timeout_minutes, c.read_only
217+
c.afk_sub_code, c.afk_timeout_minutes, c.read_only, c.auto_delete_mode, c.auto_delete_interval_hours
218218
FROM channels c WHERE c.is_dm = 0
219219
UNION
220220
SELECT c.id, c.name, c.code, c.created_by, c.topic, c.is_dm,
221221
c.code_visibility, c.code_mode, c.code_rotation_type, c.code_rotation_interval,
222222
c.parent_channel_id, c.position, c.is_private, c.expires_at, c.is_temp_voice,
223223
c.streams_enabled, c.music_enabled, c.media_enabled, c.slow_mode_interval, c.category, c.sort_alphabetical,
224224
c.cleanup_exempt, c.channel_type, c.voice_user_limit, c.notification_type, c.voice_enabled, c.text_enabled, c.voice_bitrate,
225-
c.afk_sub_code, c.afk_timeout_minutes, c.read_only
225+
c.afk_sub_code, c.afk_timeout_minutes, c.read_only, c.auto_delete_mode, c.auto_delete_interval_hours
226226
FROM channels c
227227
JOIN channel_members cm ON c.id = cm.channel_id
228228
WHERE cm.user_id = ? AND c.is_dm = 1
@@ -237,7 +237,7 @@ function setupSocketHandlers(io, db) {
237237
c.parent_channel_id, c.position, c.is_private, c.expires_at, c.is_temp_voice,
238238
c.streams_enabled, c.music_enabled, c.media_enabled, c.slow_mode_interval, c.category, c.sort_alphabetical,
239239
c.cleanup_exempt, c.channel_type, c.voice_user_limit, c.notification_type, c.voice_enabled, c.text_enabled, c.voice_bitrate,
240-
c.afk_sub_code, c.afk_timeout_minutes, c.read_only
240+
c.afk_sub_code, c.afk_timeout_minutes, c.read_only, c.auto_delete_mode, c.auto_delete_interval_hours
241241
FROM channels c
242242
JOIN channel_members cm ON c.id = cm.channel_id
243243
WHERE cm.user_id = ?
@@ -908,20 +908,44 @@ function setupSocketHandlers(io, db) {
908908
setInterval(() => {
909909
try {
910910
const expired = db.prepare(
911-
"SELECT id, code FROM channels WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')"
911+
"SELECT id, code, auto_delete_mode, auto_delete_interval_hours FROM channels WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')"
912912
).all();
913913
for (const ch of expired) {
914-
db.prepare('DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)').run(ch.id);
915-
db.prepare('DELETE FROM pinned_messages WHERE channel_id = ?').run(ch.id);
916-
db.prepare('DELETE FROM messages WHERE channel_id = ?').run(ch.id);
917-
db.prepare('DELETE FROM channel_members WHERE channel_id = ?').run(ch.id);
918-
db.prepare('DELETE FROM channels WHERE id = ?').run(ch.id);
919-
io.to(`channel:${ch.code}`).to(`voice:${ch.code}`).emit('channel-deleted', { code: ch.code, reason: 'expired' });
920-
channelUsers.delete(ch.code);
921-
voiceUsers.delete(ch.code);
922-
activeMusic.delete(ch.code);
923-
musicQueues.delete(ch.code);
924-
console.log(`[Temporary] Channel "${ch.code}" expired and was deleted`);
914+
if (ch.auto_delete_mode === 'clear') {
915+
// #5390 — clear-messages mode: wipe message-related rows but keep
916+
// the channel, its members, permissions, roles, and integrations
917+
// intact. Then rearm the timer using the original interval so the
918+
// sweep repeats (e.g. daily flood-channel reset) until an admin
919+
// disables it. If for some reason the interval wasn't stored,
920+
// fall back to disabling the timer to avoid getting stuck firing
921+
// a zero-second loop.
922+
db.prepare('DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)').run(ch.id);
923+
db.prepare('DELETE FROM pinned_messages WHERE channel_id = ?').run(ch.id);
924+
db.prepare('DELETE FROM messages WHERE channel_id = ?').run(ch.id);
925+
const interval = ch.auto_delete_interval_hours;
926+
if (interval && interval > 0) {
927+
const nextExpiry = new Date(Date.now() + interval * 3600000).toISOString();
928+
db.prepare('UPDATE channels SET expires_at = ? WHERE id = ?').run(nextExpiry, ch.id);
929+
} else {
930+
db.prepare('UPDATE channels SET expires_at = NULL WHERE id = ?').run(ch.id);
931+
}
932+
io.to(`channel:${ch.code}`).emit('channel-messages-cleared', { code: ch.code, reason: 'auto-clear' });
933+
// Refresh channel lists so the new expires_at propagates to clients.
934+
try { broadcastChannelLists(); } catch {}
935+
console.log(`[Temporary] Channel "${ch.code}" messages cleared (auto-clear mode)`);
936+
} else {
937+
db.prepare('DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)').run(ch.id);
938+
db.prepare('DELETE FROM pinned_messages WHERE channel_id = ?').run(ch.id);
939+
db.prepare('DELETE FROM messages WHERE channel_id = ?').run(ch.id);
940+
db.prepare('DELETE FROM channel_members WHERE channel_id = ?').run(ch.id);
941+
db.prepare('DELETE FROM channels WHERE id = ?').run(ch.id);
942+
io.to(`channel:${ch.code}`).to(`voice:${ch.code}`).emit('channel-deleted', { code: ch.code, reason: 'expired' });
943+
channelUsers.delete(ch.code);
944+
voiceUsers.delete(ch.code);
945+
activeMusic.delete(ch.code);
946+
musicQueues.delete(ch.code);
947+
console.log(`[Temporary] Channel "${ch.code}" expired and was deleted`);
948+
}
925949
}
926950
} catch (err) {
927951
console.error('Temporary channel cleanup error:', err);

0 commit comments

Comments
 (0)