fix: bot send confirmation, preview picker dialog nesting, upload button

- Bot: wait for updateMessageSendSucceeded/Failed before marking send
  complete (was returning on temp message, actual send was async)
- Preview picker: move ArchivePreviewPicker outside parent Dialog to
  fix Radix nested dialog focus trap conflict
- Upload: add explicit "Upload Preview" button always visible in the
  action bar alongside "Pick Preview"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-03-22 13:15:31 +01:00
parent f4488a079f
commit a4c264a144
2 changed files with 142 additions and 31 deletions

View File

@@ -73,6 +73,11 @@ export async function closeBotClient(): Promise<void> {
*
* The fromChatId is the Telegram chat ID from the DB (e.g. -1003767441152).
* The messageId is the TDLib message ID stored in the DB.
*
* IMPORTANT: forwardMessages with send_copy returns a *temporary* message
* synchronously. The actual file copy/send is asynchronous inside TDLib.
* We must listen for updateMessageSendSucceeded / updateMessageSendFailed
* to know whether the message actually reached the user.
*/
export async function copyMessageToUser(
fromChatId: bigint,
@@ -94,25 +99,114 @@ export async function copyMessageToUser(
log.warn({ err, chatId: fromChatId.toString() }, "getChat failed for source channel");
}
const result = await withFloodWait(
() =>
c.invoke({
_: "forwardMessages",
chat_id: Number(toUserId),
from_chat_id: Number(fromChatId),
message_ids: [Number(messageId)],
send_copy: true,
remove_caption: false,
}),
"copyMessageToUser"
);
// Wait for the actual send to complete, not just the temporary message.
// Pattern mirrors worker/src/upload/channel.ts sendAndWaitForUpload.
await new Promise<void>((resolve, reject) => {
let settled = false;
let tempMsgId: number | null = null;
// forwardMessages returns immediately with temp messages — check result
const messages = (result as { messages?: unknown[] })?.messages;
log.info(
{ messageCount: messages?.length ?? 0, result: JSON.stringify(result).slice(0, 500) },
"forwardMessages result"
);
// Timeout: 5 minutes for the copy to complete
const TIMEOUT_MS = 5 * 60_000;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(
new Error(
`copyMessageToUser timed out after ${TIMEOUT_MS / 60_000}min ` +
`(from=${fromChatId}, msg=${messageId}, to=${toUserId})`
)
);
}
}, TIMEOUT_MS);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleUpdate = (update: any) => {
if (update?._ === "updateMessageSendSucceeded") {
const oldMsgId = update.old_message_id;
if (tempMsgId !== null && oldMsgId === tempMsgId) {
if (!settled) {
settled = true;
cleanup();
const finalId = update.message?.id;
log.info(
{ tempMsgId, finalMsgId: finalId, toUserId: toUserId.toString() },
"Message copy confirmed by Telegram"
);
resolve();
}
}
}
if (update?._ === "updateMessageSendFailed") {
const oldMsgId = update.old_message_id;
if (tempMsgId !== null && oldMsgId === tempMsgId) {
if (!settled) {
settled = true;
cleanup();
const errorMsg = update.error?.message ?? "Unknown send error";
const errorCode = update.error?.code ?? 0;
log.error(
{ tempMsgId, errorCode, errorMsg, toUserId: toUserId.toString() },
"Message copy failed"
);
reject(new Error(`copyMessageToUser failed: [${errorCode}] ${errorMsg}`));
}
}
}
};
const cleanup = () => {
clearTimeout(timer);
c.off("update", handleUpdate);
};
// Attach listener BEFORE sending to avoid missing fast completions
c.on("update", handleUpdate);
// Send the copy — returns a temporary message immediately
withFloodWait(
() =>
c.invoke({
_: "forwardMessages",
chat_id: Number(toUserId),
from_chat_id: Number(fromChatId),
message_ids: [Number(messageId)],
send_copy: true,
remove_caption: false,
}),
"copyMessageToUser"
)
.then((result) => {
// forwardMessages returns { messages: [tempMsg, ...] }
const messages = (result as { messages?: Array<{ id: number }> })?.messages;
if (messages && messages.length > 0 && messages[0]) {
tempMsgId = messages[0].id;
log.debug(
{ tempMsgId, toUserId: toUserId.toString() },
"forwardMessages returned temp message, waiting for send confirmation"
);
} else {
// No temp message returned — likely an error in the API call itself
if (!settled) {
settled = true;
cleanup();
reject(
new Error(
`forwardMessages returned no messages (result: ${JSON.stringify(result).slice(0, 300)})`
)
);
}
}
})
.catch((err) => {
if (!settled) {
settled = true;
cleanup();
reject(err);
}
});
});
}
/**

View File

@@ -335,6 +335,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
}, [filtered]);
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-3">
@@ -408,6 +409,20 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
Pick Preview
</Button>
)}
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
Upload Preview
</Button>
</div>
)}
</div>
@@ -512,19 +527,21 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
</ScrollArea>
</DialogContent>
{/* Archive preview picker modal */}
{pkg && pkg.archiveType !== "DOCUMENT" && !pkg.isMultipart && (
<ArchivePreviewPicker
packageId={pkg.id}
packageName={pkg.fileName}
open={showPreviewPicker}
onOpenChange={setShowPreviewPicker}
onPreviewSet={() => {
// Refresh the preview by setting a cache-busting URL
setLocalPreviewUrl(`/api/zips/${pkg.id}/preview?t=${Date.now()}`);
}}
/>
)}
</Dialog>
{/* Archive preview picker modal — rendered as sibling to avoid nested Dialog issues */}
{pkg && pkg.archiveType !== "DOCUMENT" && !pkg.isMultipart && (
<ArchivePreviewPicker
packageId={pkg.id}
packageName={pkg.fileName}
open={showPreviewPicker}
onOpenChange={setShowPreviewPicker}
onPreviewSet={() => {
// Refresh the preview by setting a cache-busting URL
setLocalPreviewUrl(`/api/zips/${pkg.id}/preview?t=${Date.now()}`);
}}
/>
)}
</>
);
}