feat(desktop): retain Bot group drafts by room
This commit is contained in:
committed by
Teknium
parent
9b36c2d43c
commit
b42d8279ed
@@ -6203,6 +6203,10 @@ async function renameGroupChat(oldName, newName, members) {
|
||||
const all = { ...$groupChats.get() }
|
||||
const room = all[oldName]
|
||||
|
||||
if (room) {
|
||||
migrateGroupComposerDraft(groupComposerDraftKey(oldName, room), groupComposerDraftKey(next, room))
|
||||
}
|
||||
|
||||
delete all[oldName]
|
||||
|
||||
if (room) {
|
||||
@@ -11301,11 +11305,131 @@ function GroupClarifyCard({ entry, members }) {
|
||||
})
|
||||
}
|
||||
|
||||
// Group composer drafts are window-local UI state. They must survive pane
|
||||
// parking/re-registration and owner switches, but must never enter shared room
|
||||
// metadata (where another Desktop would see half-typed text or attachment
|
||||
// bytes). Current rooms key by immutable roomId; legacy rooms fall back to the
|
||||
// display name until they are upgraded.
|
||||
const groupComposerDrafts = new Map()
|
||||
|
||||
function emptyGroupComposerDraft() {
|
||||
return { activeReplyThread: null, main: '', pendingAttachments: {}, replies: {}, revision: 0 }
|
||||
}
|
||||
|
||||
function groupComposerDraftKey(group, room) {
|
||||
return groupChatRoomKey(group, room)
|
||||
}
|
||||
|
||||
function groupComposerDraftSnapshot(key) {
|
||||
return groupComposerDrafts.get(key) || emptyGroupComposerDraft()
|
||||
}
|
||||
|
||||
function updateGroupComposerDraft(key, mutate) {
|
||||
const current = groupComposerDraftSnapshot(key)
|
||||
const next = mutate({
|
||||
...current,
|
||||
pendingAttachments: Object.fromEntries(
|
||||
Object.entries(current.pendingAttachments || {}).map(([thread, attachments]) => [
|
||||
thread,
|
||||
[...(attachments || [])]
|
||||
])
|
||||
),
|
||||
replies: { ...(current.replies || {}) }
|
||||
})
|
||||
|
||||
next.revision = current.revision + 1
|
||||
groupComposerDrafts.delete(key)
|
||||
groupComposerDrafts.set(key, next)
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function restoreGroupComposerDraft(key, expectedRevision, snapshot) {
|
||||
const current = groupComposerDraftSnapshot(key)
|
||||
|
||||
if (current.revision !== expectedRevision) {
|
||||
return null
|
||||
}
|
||||
|
||||
const restored = {
|
||||
...snapshot,
|
||||
pendingAttachments: Object.fromEntries(
|
||||
Object.entries(snapshot.pendingAttachments || {}).map(([thread, attachments]) => [
|
||||
thread,
|
||||
[...(attachments || [])]
|
||||
])
|
||||
),
|
||||
replies: { ...(snapshot.replies || {}) },
|
||||
revision: current.revision + 1
|
||||
}
|
||||
|
||||
groupComposerDrafts.set(key, restored)
|
||||
|
||||
return restored
|
||||
}
|
||||
|
||||
function clearGroupComposerDraft(key) {
|
||||
groupComposerDrafts.delete(key)
|
||||
}
|
||||
|
||||
function migrateGroupComposerDraft(oldKey, newKey) {
|
||||
if (oldKey === newKey || !groupComposerDrafts.has(oldKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!groupComposerDrafts.has(newKey)) {
|
||||
groupComposerDrafts.set(newKey, groupComposerDrafts.get(oldKey))
|
||||
}
|
||||
|
||||
groupComposerDrafts.delete(oldKey)
|
||||
}
|
||||
|
||||
function GroupChatWorkspace({ group, members, onBack, visible = true }) {
|
||||
const rooms = useValue($groupChats)
|
||||
const allMeta = useValue($botMeta)
|
||||
const room = rooms[group] || { log: [], running: false }
|
||||
const [draft, setDraft] = useState('')
|
||||
const composerKey = groupComposerDraftKey(group, room)
|
||||
const composerKeyRef = useRef(composerKey)
|
||||
const [composerDraft, setComposerDraft] = useState(() => groupComposerDraftSnapshot(composerKey))
|
||||
|
||||
if (composerKeyRef.current !== composerKey) {
|
||||
migrateGroupComposerDraft(composerKeyRef.current, composerKey)
|
||||
composerKeyRef.current = composerKey
|
||||
}
|
||||
|
||||
const updateComposerDraft = mutate => {
|
||||
const next = updateGroupComposerDraft(composerKeyRef.current, mutate)
|
||||
setComposerDraft(next)
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
const draft = composerDraft.main || ''
|
||||
const replyDrafts = composerDraft.replies || {}
|
||||
const replyThread = composerDraft.activeReplyThread || null
|
||||
const pendingImages = composerDraft.pendingAttachments || {}
|
||||
const setDraft = value =>
|
||||
updateComposerDraft(current => ({
|
||||
...current,
|
||||
main: typeof value === 'function' ? value(current.main || '') : value
|
||||
}))
|
||||
const setReplyDrafts = value =>
|
||||
updateComposerDraft(current => ({
|
||||
...current,
|
||||
replies: typeof value === 'function' ? value(current.replies || {}) : value
|
||||
}))
|
||||
const setReplyThread = value =>
|
||||
updateComposerDraft(current => ({
|
||||
...current,
|
||||
activeReplyThread:
|
||||
typeof value === 'function' ? value(current.activeReplyThread || null) : value
|
||||
}))
|
||||
const setPendingImages = value =>
|
||||
updateComposerDraft(current => ({
|
||||
...current,
|
||||
pendingAttachments:
|
||||
typeof value === 'function' ? value(current.pendingAttachments || {}) : value
|
||||
}))
|
||||
const [confirmDisband, setConfirmDisband] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
// Click-to-disambiguate: which log entry is showing its speaker's full
|
||||
@@ -11318,12 +11442,9 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
|
||||
// `replyThread` is the thread whose reply box currently owns the composer
|
||||
// (null = the main composer, which STARTS a new thread).
|
||||
const [openThreads, setOpenThreads] = useState({})
|
||||
const [replyThread, setReplyThread] = useState(null)
|
||||
const [replyDrafts, setReplyDrafts] = useState({})
|
||||
// Pending image attachments per composer: `null` thread key = the main
|
||||
// composer, otherwise the reply box of that thread. Data URLs, already
|
||||
// downscaled — they ride the send into every responding member's session.
|
||||
const [pendingImages, setPendingImages] = useState({})
|
||||
|
||||
// Scroll anchoring (#89835): rooms used to open at scroll position 0 and
|
||||
// stay there while replies streamed in. Scroll the bottom sentinel into
|
||||
@@ -11385,11 +11506,6 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
|
||||
setPendingImages(prev => ({ ...prev, [key]: [...(prev[key] || []), ...picked] }))
|
||||
}
|
||||
|
||||
const clearImages = thread => {
|
||||
const key = thread ?? 'main'
|
||||
setPendingImages(prev => ({ ...prev, [key]: [] }))
|
||||
}
|
||||
|
||||
const removeImage = (thread, index) => {
|
||||
const key = thread ?? 'main'
|
||||
setPendingImages(prev => ({ ...prev, [key]: (prev[key] || []).filter((_, i) => i !== index) }))
|
||||
@@ -11579,8 +11695,12 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
|
||||
return
|
||||
}
|
||||
|
||||
setDraft('')
|
||||
clearImages(null)
|
||||
const before = groupComposerDraftSnapshot(composerKeyRef.current)
|
||||
const cleared = updateComposerDraft(current => ({
|
||||
...current,
|
||||
main: '',
|
||||
pendingAttachments: { ...(current.pendingAttachments || {}), main: [] }
|
||||
}))
|
||||
// Main composer = START A NEW THREAD with the whole group (Slack shape).
|
||||
// Full descriptors ride into the turn loop: remote members keep their
|
||||
// connection fields so their turns route to their own machines.
|
||||
@@ -11588,6 +11708,12 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
|
||||
|
||||
if (minted) {
|
||||
setOpenThreads(prev => ({ ...prev, [minted]: true }))
|
||||
} else {
|
||||
const restored = restoreGroupComposerDraft(composerKeyRef.current, cleared.revision, before)
|
||||
|
||||
if (restored) {
|
||||
setComposerDraft(restored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11599,12 +11725,25 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
|
||||
return
|
||||
}
|
||||
|
||||
setReplyDrafts(prev => ({ ...prev, [thread]: '' }))
|
||||
clearImages(thread)
|
||||
const before = groupComposerDraftSnapshot(composerKeyRef.current)
|
||||
const cleared = updateComposerDraft(current => ({
|
||||
...current,
|
||||
pendingAttachments: { ...(current.pendingAttachments || {}), [thread]: [] },
|
||||
replies: { ...(current.replies || {}), [thread]: '' }
|
||||
}))
|
||||
// Reply box = CONTINUE this thread; the member turns it triggers are
|
||||
// scoped to it.
|
||||
sendToGroupChat(group, memberDescriptors(), text, thread, images)
|
||||
setOpenThreads(prev => ({ ...prev, [thread]: true }))
|
||||
const sent = sendToGroupChat(group, memberDescriptors(), text, thread, images)
|
||||
|
||||
if (sent) {
|
||||
setOpenThreads(prev => ({ ...prev, [thread]: true }))
|
||||
} else {
|
||||
const restored = restoreGroupComposerDraft(composerKeyRef.current, cleared.revision, before)
|
||||
|
||||
if (restored) {
|
||||
setComposerDraft(restored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pending-attachment chips + the picker for one composer (thread = null →
|
||||
@@ -12040,6 +12179,7 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) {
|
||||
doneLabel: 'Disbanded',
|
||||
onClose: () => setConfirmDisband(false),
|
||||
onConfirm: async () => {
|
||||
clearGroupComposerDraft(composerKeyRef.current)
|
||||
await disbandGroupChat(group, members)
|
||||
host.notify({ kind: 'success', message: `Disbanded “${group}”` })
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ test('source contract: settings dialog edits name and picture after creation', (
|
||||
test('source contract: rename re-keys the room AND local memberships, keeps sessions', () => {
|
||||
// The room record moves wholesale under the new key (sessions included, so
|
||||
// members keep resuming their per-group sessions by stored sid).
|
||||
assert.match(pluginSource, /const room = all\[oldName\]\s*\n\s*\n?\s*delete all\[oldName\]/)
|
||||
assert.match(pluginSource, /const room = all\[oldName\][\s\S]{0,320}?delete all\[oldName\]/)
|
||||
// Window-local composer drafts follow the immutable room identity too.
|
||||
assert.match(pluginSource, /migrateGroupComposerDraft\(groupComposerDraftKey\(oldName, room\), groupComposerDraftKey\(next, room\)\)/)
|
||||
// Local members' canonical groups lists swap old → new via ui_meta.
|
||||
assert.match(pluginSource, /botGroups\(meta\)\.map\(g => \(g === oldName \? next : g\)\)/)
|
||||
// Collisions are rejected, not silently suffixed — rename is explicit intent.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
|
||||
const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
|
||||
|
||||
function between(start, end) {
|
||||
const from = source.indexOf(start)
|
||||
const to = source.indexOf(end, from)
|
||||
|
||||
assert.notEqual(from, -1, `missing ${start}`)
|
||||
assert.notEqual(to, -1, `missing ${end}`)
|
||||
|
||||
return source.slice(from, to)
|
||||
}
|
||||
|
||||
function load() {
|
||||
const context = { Map, Object }
|
||||
const roomKey = between('function groupChatRoomKey(', '/** Lift any historical projection shape')
|
||||
const drafts = between('const groupComposerDrafts = new Map()', 'function GroupChatWorkspace(')
|
||||
|
||||
vm.runInNewContext(
|
||||
`${roomKey}\n${drafts}\nglobalThis.drafts = {
|
||||
clearGroupComposerDraft,
|
||||
groupComposerDraftKey,
|
||||
groupComposerDraftSnapshot,
|
||||
migrateGroupComposerDraft,
|
||||
restoreGroupComposerDraft,
|
||||
updateGroupComposerDraft
|
||||
}`,
|
||||
context
|
||||
)
|
||||
|
||||
return context.drafts
|
||||
}
|
||||
|
||||
test('workspace retirement and re-registration restore the exact room draft', () => {
|
||||
const drafts = load()
|
||||
const key = drafts.groupComposerDraftKey('Launch room', { roomId: 'room-1' })
|
||||
const attachment = { data: 'data:image/png;base64,abc', kind: 'image', name: 'plan.png' }
|
||||
|
||||
drafts.updateGroupComposerDraft(key, state => ({
|
||||
...state,
|
||||
activeReplyThread: 'thread-1',
|
||||
main: 'main draft',
|
||||
pendingAttachments: { main: [attachment], 'thread-1': [attachment] },
|
||||
replies: { 'thread-1': 'reply draft' }
|
||||
}))
|
||||
|
||||
// Dropping the component reference simulates pane retirement. A fresh
|
||||
// registration reads the same module-scope, roomId-qualified snapshot.
|
||||
const remounted = drafts.groupComposerDraftSnapshot(key)
|
||||
|
||||
assert.equal(remounted.main, 'main draft')
|
||||
assert.equal(remounted.replies['thread-1'], 'reply draft')
|
||||
assert.equal(remounted.activeReplyThread, 'thread-1')
|
||||
assert.equal(remounted.pendingAttachments.main[0].name, 'plan.png')
|
||||
})
|
||||
|
||||
test('legacy name-keyed drafts migrate when an immutable room id appears', () => {
|
||||
const drafts = load()
|
||||
const legacy = drafts.groupComposerDraftKey('Launch room', {})
|
||||
const current = drafts.groupComposerDraftKey('Renamed room', { roomId: 'room-1' })
|
||||
|
||||
drafts.updateGroupComposerDraft(legacy, state => ({ ...state, main: 'keep me' }))
|
||||
drafts.migrateGroupComposerDraft(legacy, current)
|
||||
|
||||
assert.equal(drafts.groupComposerDraftSnapshot(current).main, 'keep me')
|
||||
assert.equal(drafts.groupComposerDraftSnapshot(legacy).main, '')
|
||||
})
|
||||
|
||||
test('a failed send cannot overwrite text entered after the optimistic clear', () => {
|
||||
const drafts = load()
|
||||
const key = 'id:room-1'
|
||||
|
||||
drafts.updateGroupComposerDraft(key, state => ({ ...state, main: 'send this' }))
|
||||
const before = drafts.groupComposerDraftSnapshot(key)
|
||||
const cleared = drafts.updateGroupComposerDraft(key, state => ({ ...state, main: '' }))
|
||||
|
||||
drafts.updateGroupComposerDraft(key, state => ({ ...state, main: 'newer typing' }))
|
||||
|
||||
assert.equal(drafts.restoreGroupComposerDraft(key, cleared.revision, before), null)
|
||||
assert.equal(drafts.groupComposerDraftSnapshot(key).main, 'newer typing')
|
||||
})
|
||||
|
||||
test('disband removes only that room draft', () => {
|
||||
const drafts = load()
|
||||
|
||||
drafts.updateGroupComposerDraft('id:a', state => ({ ...state, main: 'a' }))
|
||||
drafts.updateGroupComposerDraft('id:b', state => ({ ...state, main: 'b' }))
|
||||
drafts.clearGroupComposerDraft('id:a')
|
||||
|
||||
assert.equal(drafts.groupComposerDraftSnapshot('id:a').main, '')
|
||||
assert.equal(drafts.groupComposerDraftSnapshot('id:b').main, 'b')
|
||||
})
|
||||
|
||||
test('GroupChatWorkspace owns composer state through the room draft store', () => {
|
||||
const workspace = between('function GroupChatWorkspace(', '/** Live closers for group-chat MAIN-window tabs')
|
||||
|
||||
assert.match(workspace, /groupComposerDraftKey\(group, room\)/)
|
||||
assert.match(workspace, /restoreGroupComposerDraft\(composerKeyRef\.current, cleared\.revision, before\)/)
|
||||
assert.match(workspace, /clearGroupComposerDraft\(composerKeyRef\.current\)/)
|
||||
assert.doesNotMatch(workspace, /useState\(''\).*?draft/)
|
||||
assert.doesNotMatch(workspace, /useState\(\{\}\).*?replyDrafts/)
|
||||
})
|
||||
Reference in New Issue
Block a user