Aliyun OSS sometimes returns presigned URLs as http:// even though every
page here is served over https:// -- the browser silently blocks these
as mixed content, which is why images (e.g. avatar) failed to display
after upload. Added upgradeToHttps() and wired it into fileDownloadUrl()
(the single chokepoint every image/attachment fetch in the app resolves
through) plus the three chat-attachment send paths, so both freshly
fetched and newly-persisted URLs get the scheme fix.
Also fixed ProfileSettingsPage's avatar upload handler, which extracted
the file id by parsing the upload response's `url` with .split('/').pop()
instead of just reading the `file_id` field the response already
provides -- fragile against exactly this kind of full-URL response and
the most likely actual trigger for the reported bug.
vite.config.ts: dev server port now respects $PORT if set, falls back
to 8094 -- unrelated cleanup, needed it to run a second local dev
server for verification alongside another concurrent session's.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
91 lines
4.2 KiB
TypeScript
91 lines
4.2 KiB
TypeScript
import { Drafty, type Topic } from 'tinode-sdk';
|
|
|
|
import { uploadFile, upgradeToHttps } from '@/lib/api/upload';
|
|
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
|
|
import { newClientMid } from '@/lib/tinode/send';
|
|
|
|
function fileToBase64(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '');
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
function imageDimensions(file: File): Promise<{ width: number; height: number }> {
|
|
return new Promise((resolve) => {
|
|
const url = URL.createObjectURL(file);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
img.onerror = () => {
|
|
resolve({ width: 0, height: 0 });
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Small files go inline as base64 (Drafty.insertImage's `bits` field) -- no upload round trip at
|
|
* all. Larger ones upload first, then reference the returned URL (`refurl`). Simplified from the
|
|
* old app's own urlPromise dance (which inserts an optimistic local preview immediately and lets
|
|
* the upload happen in the background): this awaits the upload before creating the message at all,
|
|
* trading that "shows immediately while uploading" nicety for meaningfully simpler code.
|
|
*/
|
|
export async function sendImage(topic: Topic, file: File): Promise<unknown> {
|
|
const { width, height } = await imageDimensions(file);
|
|
const content =
|
|
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
|
? Drafty.insertImage(undefined, 0, { mime: file.type, bits: await fileToBase64(file), width, height, filename: file.name, size: file.size })
|
|
: Drafty.insertImage(undefined, 0, {
|
|
mime: file.type,
|
|
refurl: upgradeToHttps((await uploadFile(file, topic.name)).url),
|
|
width,
|
|
height,
|
|
filename: file.name,
|
|
size: file.size,
|
|
});
|
|
const msg = topic.createMessage(content, false);
|
|
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() });
|
|
return topic.publishDraft(msg, Promise.resolve());
|
|
}
|
|
|
|
export async function sendFile(topic: Topic, file: File): Promise<unknown> {
|
|
const content =
|
|
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
|
? Drafty.attachFile(undefined, { mime: file.type, data: await fileToBase64(file), filename: file.name, size: file.size })
|
|
: Drafty.attachFile(undefined, { mime: file.type, refurl: upgradeToHttps((await uploadFile(file, topic.name)).url), filename: file.name, size: file.size });
|
|
const msg = topic.createMessage(content, false);
|
|
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file', 'x-im-client-mid': newClientMid() });
|
|
return topic.publishDraft(msg, Promise.resolve());
|
|
}
|
|
|
|
/**
|
|
* `durationMs` is measured by this app's own recording timer, independent of whatever duration
|
|
* metadata (if any) ends up embedded in the audio file itself -- Chrome's MediaRecorder webm output
|
|
* is well known to omit duration metadata entirely (the old app worked around this with the
|
|
* `webm-duration-fix` package; skipped here as a deliberate scope cut since the Drafty `duration`
|
|
* field this app actually displays from doesn't depend on it, only a browser's native <audio>
|
|
* scrubber would).
|
|
*/
|
|
export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): Promise<unknown> {
|
|
const file = new File([blob], `voice.${blob.type.includes('mp4') ? 'm4a' : 'webm'}`, { type: blob.type });
|
|
const content =
|
|
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
|
? Drafty.insertAudio(undefined, 0, { mime: file.type, bits: await fileToBase64(file), duration: durationMs, size: file.size, filename: file.name })
|
|
: Drafty.insertAudio(undefined, 0, {
|
|
mime: file.type,
|
|
refurl: upgradeToHttps((await uploadFile(file, topic.name)).url),
|
|
duration: durationMs,
|
|
size: file.size,
|
|
filename: file.name,
|
|
});
|
|
const msg = topic.createMessage(content, false);
|
|
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() });
|
|
return topic.publishDraft(msg, Promise.resolve());
|
|
}
|