feat(shangwutong): render inbound images inline

This commit is contained in:
2026-09-12 15:26:03 +08:00
parent c415d6d13f
commit 6e62f5094f
9 changed files with 227 additions and 31 deletions
@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
import { formatShangwutongInlineContent } from '../inlineMedia.js';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
@@ -77,11 +78,19 @@ const shouldShowMeta = computed(
);
const replyToPreview = computed(() => {
if (!inReplyTo) return '';
if (!inReplyTo?.value) return '';
const { content, attachments } = inReplyTo.value;
const { content, attachments, contentAttributes, content_attributes } =
inReplyTo.value;
if (content) return new MessageFormatter(content).formattedMessage;
if (content) {
const formattedContent = formatShangwutongInlineContent(
content,
attachments,
contentAttributes ?? content_attributes
);
return new MessageFormatter(formattedContent).formattedMessage;
}
if (attachments?.length) {
const firstAttachment = attachments[0];
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
@@ -4,6 +4,7 @@ import { useMessageContext } from '../../provider.js';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS } from '../../constants';
import { formatShangwutongInlineContent } from '../../inlineMedia.js';
const props = defineProps({
content: {
@@ -12,14 +13,20 @@ const props = defineProps({
},
});
const { variant } = useMessageContext();
const { variant, attachments, contentAttributes } = useMessageContext();
const formattedContent = computed(() => {
const content = formatShangwutongInlineContent(
props.content,
attachments.value,
contentAttributes.value
);
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
return props.content;
return content;
}
return new MessageFormatter(props.content).formattedMessage;
return new MessageFormatter(content).formattedMessage;
});
</script>
@@ -8,6 +8,7 @@ import FileChip from 'next/message/chips/File.vue';
import { useMessageContext } from '../provider.js';
import { ATTACHMENT_TYPES } from '../constants';
import { shangwutongInlineMediaIndexes } from '../inlineMedia.js';
/**
* @typedef {Object} Attachment
@@ -34,7 +35,7 @@ defineOptions({
});
const attrs = useAttrs();
const { orientation } = useMessageContext();
const { orientation, contentAttributes } = useMessageContext();
const classToApply = computed(() => {
const baseClasses = [attrs.class, 'flex', 'flex-wrap'];
@@ -46,8 +47,15 @@ const classToApply = computed(() => {
return baseClasses;
});
const inlineAttachmentIndexes = computed(() =>
shangwutongInlineMediaIndexes(contentAttributes.value)
);
const allAttachments = computed(() => {
return Array.isArray(props.attachments) ? props.attachments : [];
const attachments = Array.isArray(props.attachments) ? props.attachments : [];
return attachments.filter(
(_, index) => !inlineAttachmentIndexes.value.has(index)
);
});
const mediaAttachments = computed(() => {
@@ -0,0 +1,61 @@
const INLINE_MEDIA_PATTERN = /!\[([^\]]*)\]\(swt-attachment:\/\/(\d+)\)/g;
export const shangwutongInlineMediaIndexes = contentAttributes => {
const indexes = contentAttributes?.swt?.inline_media;
if (!Array.isArray(indexes)) return new Set();
return new Set(
indexes.map(Number).filter(index => Number.isInteger(index) && index >= 0)
);
};
const attachmentURL = attachment => attachment?.dataUrl ?? attachment?.data_url;
const resolveAttachmentURL = dataUrl => {
if (!dataUrl) return '';
try {
return new URL(
dataUrl,
typeof window === 'undefined'
? 'http://localhost'
: window.location.origin
).href;
} catch {
return dataUrl;
}
};
export const formatShangwutongInlineContent = (
content,
attachments,
contentAttributes
) => {
const indexes = shangwutongInlineMediaIndexes(contentAttributes);
if (!indexes.size) return content || '';
const renderedIndexes = new Set();
const formattedContent = (content || '').replace(
INLINE_MEDIA_PATTERN,
(match, alt, rawIndex) => {
const index = Number(rawIndex);
if (!indexes.has(index)) return match;
renderedIndexes.add(index);
const dataUrl = resolveAttachmentURL(attachmentURL(attachments?.[index]));
if (!dataUrl) return alt || '商务通表情';
return `![${alt || '商务通表情'}](<${dataUrl}>)`;
}
);
const missingImages = [...indexes]
.filter(index => !renderedIndexes.has(index))
.map(index => {
const dataUrl = resolveAttachmentURL(attachmentURL(attachments?.[index]));
return dataUrl ? `![商务通表情](<${dataUrl}>)` : '商务通表情';
});
if (!missingImages.length) return formattedContent;
return [formattedContent, missingImages.join('')].filter(Boolean).join('\n');
};
@@ -0,0 +1,54 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import {
formatShangwutongInlineContent,
shangwutongInlineMediaIndexes,
} from './inlineMedia.js';
describe('Shangwutong inline media', () => {
it('replaces declared attachment markers while preserving order', () => {
const content =
'前![商务通表情](swt-attachment://0)中![商务通表情](swt-attachment://1)后';
const attachments = [
{ dataUrl: '/uploads/emoji-a.gif' },
{ dataUrl: '/uploads/emoji-b.gif' },
];
expect(
formatShangwutongInlineContent(content, attachments, {
swt: { inline_media: [0, 1] },
})
).toBe(
'前![商务通表情](<http://localhost/uploads/emoji-a.gif>)中![商务通表情](<http://localhost/uploads/emoji-b.gif>)后'
);
});
it('appends inline images when translated content omits markers', () => {
expect(
formatShangwutongInlineContent(
'translated',
[{ dataUrl: '/emoji.gif' }],
{
swt: { inline_media: [0] },
}
)
).toBe('translated\n![商务通表情](<http://localhost/emoji.gif>)');
});
it('does not resolve undeclared markers', () => {
const content = '![商务通表情](swt-attachment://0)';
expect(
formatShangwutongInlineContent(content, [{ dataUrl: '/private.png' }], {
swt: { inline_media: [] },
})
).toBe(content);
});
it('accepts numeric strings from JSON attributes', () => {
expect(
shangwutongInlineMediaIndexes({ swt: { inline_media: ['2'] } })
).toEqual(new Set([2]));
});
});