openvibers/openvibe.community Commits

A picture alone is a post (#413)

OP OpenVibers committed 18 h ago · 723c81726d · 4 files, +28 −10

A status post or a thread with a picture and no words was refused ("Write something first"), and every picture needed alt text typed by hand. Words are now optional beside a picture or a file (the owner, 11-12: "it shouldn't require you to type text if you just put a picture … like other social media"); the composer fills alt text with AI (Network /api/frame/caption) and a picture without it is still posted. A picture-only status is titled by what it shows, so two are not "the same thread".

Changes

ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 40de699..3fc2f9a 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -133,12 +133,14 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '', me
const body = req.body || {};
if (Object.keys(body).some((k) => !['text', 'pictures', 'attachments'].includes(k))) fail(400, 'post.invalid', 'Only text, pictures and attachments are accepted');
const text = typeof body.text === 'string' ? body.text.trim() : '';
-- if (!text) fail(400, 'post.invalid', 'Write something first');
++ const hasFiles = (Array.isArray(body.pictures) && body.pictures.length > 0) || (Array.isArray(body.attachments) && body.attachments.length > 0);
++ // A picture alone is a post, as on any social app (the owner, 11-12); with nothing at all it is not.
++ if (!text && !hasFiles) fail(400, 'post.invalid', 'Write something or add a picture');
if (text.length > 2000) fail(400, 'post.invalid', 'Text is at most 2000 characters');
const pictures = body.pictures == null ? [] : body.pictures;
if (!Array.isArray(pictures) || pictures.length > 4) fail(400, 'post.invalid_pictures', 'pictures is an array of at most four { media_id, alt }');
for (const p of pictures) {
-- if (!p || typeof p.media_id !== 'string' || typeof p.alt !== 'string') fail(400, 'post.invalid_pictures', 'Every picture is { media_id, alt }');
++ if (!p || typeof p.media_id !== 'string' || (p.alt != null && typeof p.alt !== 'string')) fail(400, 'post.invalid_pictures', 'Every picture is { media_id, alt? }');
}
const fileIds = body.attachments == null ? [] : body.attachments;
if (!Array.isArray(fileIds) || fileIds.length > 10 || fileIds.some((id) => !/^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) || new Set(fileIds).size !== fileIds.length) {
@@@ -164,8 +166,10 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '', me
}
files.push({ id, name: String(f.name || 'File').slice(0, 255), mime: String(f.mime || 'application/octet-stream').slice(0, 120), size: Number(f.size), url: url.href });
}
-- const title = `Status: ${text.replace(/https?:\/\/[^\s<>]+/gi, '').trim().slice(0, 110) || 'Shared a picture'}`;
-- const { thread, post } = await forum.createThread(v, 'status', { title, body: text, pictures: pictures.map((p) => ({ media_id: p.media_id, alt: p.alt })) });
++ // A picture-only post is named by what it shows (its alt text), so two of them are not "the same thread".
++ const firstAlt = String((pictures[0] && pictures[0].alt) || '').trim();
++ const title = `Status: ${text.replace(/https?:\/\/[^\s<>]+/gi, '').trim().slice(0, 110) || firstAlt.slice(0, 110) || `Shared a picture · ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`}`;
++ const { thread, post } = await forum.createThread(v, 'status', { title, body: text, pictures: pictures.map((p) => ({ media_id: p.media_id, alt: String(p.alt || '') })) });
if (files.length) await forum.attachComposerFiles(v, post.id, files);
if (pulse) {
await pulse.forgetLocal('thread', thread.id);
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex d6a1ca4..11e0749 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -247,9 +247,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
}
const cleanTitle = (t) => String(t == null ? '' : t).replace(/\u0000/g, '').replace(/\s+/g, ' ').trim();
-- function cleanBody(b) {
++ /** A post's words. Empty only beside a picture or a file (a picture alone is a post, as on any social app, 11-12). */
++ function cleanBody(b, { allowEmpty = false } = {}) {
const body = String(b == null ? '' : b).replace(/\u0000/g, '').replace(/\r\n?/g, '\n').replace(/\s+$/, '');
-- if (!body.trim()) fail(400, 'post.empty', 'Write something first');
++ if (!body.trim()) { if (allowEmpty) return ''; fail(400, 'post.empty', 'Write something or add a picture'); }
if (body.length > BODY_MAX) fail(400, 'post.too_long', `Posts are limited to ${BODY_MAX.toLocaleString('en-US')} characters`);
return body;
}
@@@ -307,7 +308,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return rows;
}
/**
-- * The pictures a post names: [{ media_id, alt }], alt required, at most four. An upload made through
++ * The pictures a post names: [{ media_id, alt }], at most four. The alt text is the composer's (written by the person
++ * or by AI when they leave it, 11-12); a picture without one is still posted, its alt left empty. An upload made through
* /api/v1/spaces/:space/attachments is already an attachments row; a direct Media upload (the SDK's
* posts.create path) is checked with Media's object API, where the person owns the image, and remembered
* here with the alt text. → rows ready for claimable()
@@@ -322,7 +324,6 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const mediaId = String((p && p.media_id) || '');
const alt = String((p && p.alt) == null ? '' : p.alt).replace(/\s+/g, ' ').trim();
if (!MED_ID.test(mediaId)) fail(400, 'pictures.invalid', 'Every picture needs a Media media_id');
-- if (!alt) fail(400, 'pictures.alt_required', 'Every picture needs alt text');
if (alt.length > 300) fail(400, 'pictures.alt_too_long', 'Alt text is at most 300 characters');
let row = await db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(mediaId);
if (row) {
@@@ -1024,7 +1025,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
const labels = body.labels === undefined || body.labels === null ? null : contentLabels.parseLabels(body.labels);
if (body.labels !== undefined && body.labels !== null && labels === null) fail(400, 'thread.labels_invalid', `Labels are from this list: ${contentLabels.LABELS.join(', ')}`);
-- const text = cleanBody(body.body != null ? body.body : body.body_markdown);
++ const withFiles = [body.pictures, body.attachments].some((x) => (Array.isArray(x) ? x.length > 0 : x != null));
++ const text = cleanBody(body.body != null ? body.body : body.body_markdown, { allowEmpty: withFiles });
const pictureRows = await preparePictures(w, body.pictures);
const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);
const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
iindex 95fca93..1b05d3a 100644
---- a/test/attachments.test.js
++++ b/test/attachments.test.js
@@@ -129,6 +129,18 @@ const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR',
assert.ok(!html.includes('enctype="multipart/form-data"'), 'the form no longer posts files itself');
});
++ await check('a picture alone is a post (11-12); nothing at all is not', async () => {
++ const up = await upload(alexJwt, PNG, 'only.png');
++ const pic = up.json().attachment;
++ let r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'Just a picture', body: '', attachments: [pic.media_id] });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().post.body_markdown || '', '', 'no words');
++ assert.deepStrictEqual(r.json().post.attachments.map((a) => a.media_id), [pic.media_id]);
++ r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'Nothing', body: ' ' });
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.json().code, 'post.empty');
++ });
++
await check('the reply form names images the shared uploader already stored', async () => {
const a = await upload(samJwt, PNG, 'a.png');
const b = await upload(samJwt, PNG, 'b.png');
ddiff --git a/test/forum-ssr.test.js b/test/forum-ssr.test.js
iindex 8171114..5deedb0 100644
---- a/test/forum-ssr.test.js
++++ b/test/forum-ssr.test.js
@@@ -174,7 +174,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
assert.match(reply.headers.get('location'), new RegExp(`^${url}#post-\\d+$`));
const empty = await t.get(`${url}/reply`, as(samJwt, form({ body: ' ' })));
assert.strictEqual(empty.status, 400);
-- has(empty.text, 'Write something first');
++ has(empty.text, 'Write something or add a picture');
const vote = await t.get(`${url}/vote`, as(samJwt, form({ value: '1' })));
assert.strictEqual(vote.status, 303);
assert.strictEqual(vote.headers.get('location'), url);