feat(threads-forum): Phase 679 mixin, heal auto-archive, deep-link create

Add threads-forum.js partition heal for forum tag palettes, extend thread heal
with watermark and auto-archive reconciliation, guild-scoped threads panel,
create-thread deep link navigation payload, and thread.create autoArchiveMinutes
(Phase 679 / v0.8.654).

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-06-02 23:27:28 -04:00
co-authored by Cursor
parent 8e1054fd24
commit 2a1cd96b11
3 changed files with 88 additions and 7 deletions
+2
View File
@@ -2,6 +2,8 @@
Application facade: one `PearcordPlatform` class that wires identity, database, guild mesh, DMs, invites, voice, discovery, bots, and dozens of feature modules for the Pearcord desktop sidecar and companion.
**Phase 679 (v0.8.654):** Thread panel & forum parity — `threads-forum.js` mixin, `createThread` auto-archive, `_healThreadMetadataOnPartition` watermark + auto-archive heal, guild-scoped `buildThreadsPanel`, deep link `create-thread` query. Bundle: `npm run test:ci-phase679`.
**Phase 678 (v0.8.653):** Invites & discovery join — `invites-discovery.js` mixin (`listGuildInvites`, `_healInvitesOnPartition`, `_healDiscoveryListingsOnPartition`, `_shouldGossipInvite`); view `guildInvites`, `inviteCount`, `discoveryListingVersion`; `createInvite` ttl/maxUses validation. Bundle: `npm run test:ci-phase678`.
**Phase 677 (v0.8.652):** Moderation & audit log — `moderation.js` mixin (`unbanMember`, audit/ban gossip dedupe, `_healAuditLogOnPartition`, `_healBanListOnPartition`, `_assertCanModerateMember`, `moderation.action` spans). Bundle: `npm run test:ci-phase677`.
+41 -7
View File
@@ -1564,6 +1564,7 @@ class PearcordPlatform extends EventEmitter {
const moderationHeal = await this._healBanListOnPartition(gid).catch(() => ({ relisted: 0 }))
const invitesHeal = await this._healInvitesOnPartition(gid).catch(() => ({ relisted: 0 }))
const discoveryHeal = await this._healDiscoveryListingsOnPartition(gid).catch(() => ({ relisted: 0 }))
const forumHeal = await this._healForumTagPaletteOnPartition(gid).catch(() => ({ relisted: 0 }))
return {
voiceApplied,
emojiSlots,
@@ -1587,7 +1588,8 @@ class PearcordPlatform extends EventEmitter {
auditHeal,
moderationHeal,
invitesHeal,
discoveryHeal
discoveryHeal,
forumHeal
}
}
@@ -5395,7 +5397,7 @@ class PearcordPlatform extends EventEmitter {
this._voiceRosterDeafenedOnly = !!prefs.voiceRosterDeafenedOnly
}
if (prefs.threadsPanelFilter) {
const allowed = new Set(['active', 'archived', 'all', 'pinned', 'muted', 'pinned-muted'])
const allowed = new Set(['active', 'archived', 'all', 'pinned', 'muted', 'pinned-muted', 'locked'])
if (allowed.has(prefs.threadsPanelFilter)) {
this._threadsPanelFilter = prefs.threadsPanelFilter
if (this._threadsPanelFilter === 'archived' || this._threadsPanelFilter === 'all') {
@@ -13510,8 +13512,15 @@ class PearcordPlatform extends EventEmitter {
try {
const channels = await this.guild.listChannels().catch(() => [])
let relisted = 0
const watermark = Date.now()
this._threadHealWatermark = watermark
for (const ch of channels) {
if (!isThreadChannel(ch)) continue
if (ch.guildId && ch.guildId !== gid) continue
const meta = await this.guild?._getThreadMeta?.(gid, ch.id).catch(() => null)
if (meta?.autoArchiveAt && !ch.archived && Date.now() >= Number(meta.autoArchiveAt)) {
await this.archiveThread(ch.id, true).catch(() => {})
}
const payload = {
guildId: gid,
channelId: ch.id,
@@ -13524,7 +13533,11 @@ class PearcordPlatform extends EventEmitter {
relisted++
}
}
span.end({ relisted, rowCount: channels.filter((c) => isThreadChannel(c)).length })
span.end({
relisted,
watermark,
rowCount: channels.filter((c) => isThreadChannel(c) && (!c.guildId || c.guildId === gid)).length
})
return { relisted, rowCount: channels.filter((c) => isThreadChannel(c)).length }
} catch (err) {
this.log.error('thread.heal error', { guildId: gid, error: err?.message || String(err) })
@@ -16951,7 +16964,14 @@ class PearcordPlatform extends EventEmitter {
if (parsed.kind === 'guild-channel' && parsed.guildId && parsed.channelId) {
await this.loadGuild(parsed.guildId)
await this.selectChannel(parsed.channelId)
return { kind: 'guild-channel', found: true, guildId: parsed.guildId, channelId: parsed.channelId }
return {
kind: 'guild-channel',
found: true,
guildId: parsed.guildId,
channelId: parsed.channelId,
openCreateThread: !!parsed.createThreadMessageId,
messageId: parsed.createThreadMessageId || null
}
}
if (parsed.kind === 'guild' && parsed.guildId) {
@@ -22811,7 +22831,7 @@ class PearcordPlatform extends EventEmitter {
return t
}
async createThread ({ messageId, name }) {
async createThread ({ messageId, name, autoArchiveMinutes = 0 }) {
if (this.mode === 'dm') return this.createDmThread({ messageId, name })
const guildId = this.guild?.guild?.id || null
const parentId = this.activeChannelId || null
@@ -22843,7 +22863,8 @@ class PearcordPlatform extends EventEmitter {
const thread = await this.guild.createThread({
parentChannelId: parent.id,
rootMessageId: messageId,
name: threadName
name: threadName,
autoArchiveMinutes: Math.max(0, Math.min(10080, Number(autoArchiveMinutes) || 0))
})
this.guild.gossipChannel(thread)
await this._audit('thread.create', {
@@ -22861,6 +22882,7 @@ class PearcordPlatform extends EventEmitter {
threadName: thread.name || threadName,
fromMessage: !!messageId,
hasRootMessage: !!messageId,
autoArchiveMinutes: Math.max(0, Number(autoArchiveMinutes) || 0),
activeChannelId: this.activeChannelId,
guildCount: (this.guilds || []).length
})
@@ -22886,7 +22908,7 @@ class PearcordPlatform extends EventEmitter {
}
async setThreadsPanelFilter (filter) {
const allowed = new Set(['active', 'archived', 'all', 'pinned', 'muted', 'pinned-muted'])
const allowed = new Set(['active', 'archived', 'all', 'pinned', 'muted', 'pinned-muted', 'locked'])
this._threadsPanelFilter = allowed.has(filter) ? filter : 'active'
if (this._threadsPanelFilter === 'archived' || this._threadsPanelFilter === 'all') {
this._threadsPanelIncludeArchived = true
@@ -24805,6 +24827,7 @@ class PearcordPlatform extends EventEmitter {
this.mode === 'guild' && guild
? buildThreadsPanel({
channels,
guildId: guild.id,
threadsByParent,
unread,
threadMessageCounts,
@@ -25457,6 +25480,15 @@ class PearcordPlatform extends EventEmitter {
discoveryListingVersion:
(publicListings || []).length +
Number(this._discoveryHealWatermark ? 1 : 0),
threadCount:
(threadsPanel?.threads || []).length + (threadsPanel?.archivedThreads || []).length,
threadPanelVersion:
(threadsPanel?.threads || []).length +
(threadsPanel?.archivedThreads || []).length +
Number(this._threadHealWatermark ? 1 : 0),
forumPostCount: (forumPosts || []).length,
forumPostVersion:
(forumPosts || []).length + Number(this._forumHealWatermark ? 1 : 0),
communicationDisabled,
stagedAttachments,
stagedAttachmentUi:
@@ -26065,8 +26097,10 @@ const { notificationsActivityMixin } = require('./notifications-activity')
const { userSettingsMixin } = require('./user-settings')
const { moderationMixin } = require('./moderation')
const { invitesDiscoveryMixin } = require('./invites-discovery')
const { threadsForumMixin } = require('./threads-forum')
Object.assign(PearcordPlatform.prototype, pollSchedulingMixin)
Object.assign(PearcordPlatform.prototype, notificationsActivityMixin)
Object.assign(PearcordPlatform.prototype, userSettingsMixin)
Object.assign(PearcordPlatform.prototype, moderationMixin)
Object.assign(PearcordPlatform.prototype, invitesDiscoveryMixin)
Object.assign(PearcordPlatform.prototype, threadsForumMixin)
+45
View File
@@ -0,0 +1,45 @@
'use strict'
const { COLLECTIONS, CHANNEL_TYPES } = require('pearcord-shared')
const threadsForumMixin = {
_threadHealWatermark: null,
_forumHealWatermark: null,
async _healForumTagPaletteOnPartition (guildId) {
const gid = guildId || this.guild?.guild?.id || null
const span = this.log.time('forum.heal', { spanKind: 'forum.heal', guildId: gid })
try {
if (!gid || !this.guild?.listChannels) {
span.end({ relisted: 0, skipped: true })
return { relisted: 0, skipped: true }
}
const channels = await this.guild.listChannels().catch(() => [])
let relisted = 0
for (const ch of channels) {
if (ch.type !== CHANNEL_TYPES.FORUM) continue
if (this.forum?.parsePaletteFromTopic) {
const tags = this.forum.parsePaletteFromTopic(ch.topic)
if (tags?.length) relisted += 1
}
}
const watermark = Date.now()
this._forumHealWatermark = watermark
span.end({ relisted, watermark, forumPostCount: relisted })
return { relisted, watermark }
} catch (err) {
this.log.error('forum.heal error', { guildId: gid, error: err?.message || String(err) })
span.fail(err)
return { relisted: 0, error: err?.message || String(err) }
}
},
listGuildThreadChannels (guildId) {
const gid = guildId || this.guild?.guild?.id || null
if (!gid) return []
const channels = this._lastViewChannelsCache || []
return channels.filter((c) => c.type === CHANNEL_TYPES.THREAD && c.guildId === gid)
}
}
module.exports = { threadsForumMixin }