From 02f1dad098a91dbc3288cbd97512eb6153fc845e Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 18 Dec 2022 11:20:17 -0800 Subject: [PATCH] fix: handle status edit events (#2325) --- src/routes/_actions/stream/processMessage.js | 8 +++-- src/routes/_actions/updateStatus.js | 13 +++++++ src/routes/_api/statuses.js | 34 ++++++++++++++----- .../_database/timelines/updateStatus.js | 30 ++++++++++++---- tests/serverActions.js | 7 +++- tests/spec/140-editing.js | 28 +++++++++++++++ 6 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 src/routes/_actions/updateStatus.js create mode 100644 tests/spec/140-editing.js diff --git a/src/routes/_actions/stream/processMessage.js b/src/routes/_actions/stream/processMessage.js index 2165b9d2..4073a790 100644 --- a/src/routes/_actions/stream/processMessage.js +++ b/src/routes/_actions/stream/processMessage.js @@ -2,8 +2,9 @@ import { mark, stop } from '../../_utils/marks.js' import { deleteStatus } from '../deleteStatuses.js' import { addStatusOrNotification } from '../addStatusOrNotification.js' import { emit } from '../../_utils/eventBus.js' +import { updateStatus } from '../updateStatus.js' -const KNOWN_EVENTS = ['update', 'delete', 'notification', 'conversation', 'filters_changed'] +const KNOWN_EVENTS = ['update', 'delete', 'notification', 'conversation', 'filters_changed', 'status.update'] export function processMessage (instanceName, timelineName, message) { let { event, payload } = (message || {}) @@ -12,7 +13,7 @@ export function processMessage (instanceName, timelineName, message) { return } mark('processMessage') - if (['update', 'notification', 'conversation'].includes(event)) { + if (['update', 'notification', 'conversation', 'status.update'].includes(event)) { payload = JSON.parse(payload) // only these payloads are JSON-encoded for some reason } @@ -43,6 +44,9 @@ export function processMessage (instanceName, timelineName, message) { case 'filters_changed': emit('wordFiltersChanged', instanceName) break + case 'status.update': + updateStatus(instanceName, payload) + break } stop('processMessage') } diff --git a/src/routes/_actions/updateStatus.js b/src/routes/_actions/updateStatus.js new file mode 100644 index 00000000..41b27d73 --- /dev/null +++ b/src/routes/_actions/updateStatus.js @@ -0,0 +1,13 @@ +import { database } from '../_database/database.js' +import { scheduleIdleTask } from '../_utils/scheduleIdleTask.js' + +async function doUpdateStatus (instanceName, newStatus) { + console.log('updating status', newStatus) + await database.updateStatus(instanceName, newStatus) +} + +export function updateStatus (instanceName, newStatus) { + scheduleIdleTask(() => { + /* no await */ doUpdateStatus(instanceName, newStatus) + }) +} diff --git a/src/routes/_api/statuses.js b/src/routes/_api/statuses.js index 77a22b7c..2ab1a5db 100644 --- a/src/routes/_api/statuses.js +++ b/src/routes/_api/statuses.js @@ -1,18 +1,20 @@ import { auth, basename } from './utils.js' -import { DEFAULT_TIMEOUT, get, post, WRITE_TIMEOUT } from '../_utils/ajax.js' +import { DEFAULT_TIMEOUT, get, post, put, WRITE_TIMEOUT } from '../_utils/ajax.js' -export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds, +// post is create, put is edit +async function postOrPutStatus (url, accessToken, method, text, inReplyToId, mediaIds, sensitive, spoilerText, visibility, poll) { - const url = `${basename(instanceName)}/api/v1/statuses` - const body = { status: text, - in_reply_to_id: inReplyToId, media_ids: mediaIds, sensitive, spoiler_text: spoilerText, - visibility, - poll + poll, + ...(method === 'post' && { + // you can't change these properties when editing + in_reply_to_id: inReplyToId, + visibility + }) } for (const key of Object.keys(body)) { @@ -23,7 +25,23 @@ export async function postStatus (instanceName, accessToken, text, inReplyToId, } } - return post(url, body, auth(accessToken), { timeout: WRITE_TIMEOUT }) + const func = method === 'post' ? post : put + + return func(url, body, auth(accessToken), { timeout: WRITE_TIMEOUT }) +} + +export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds, + sensitive, spoilerText, visibility, poll) { + const url = `${basename(instanceName)}/api/v1/statuses` + return postOrPutStatus(url, accessToken, 'post', text, inReplyToId, mediaIds, + sensitive, spoilerText, visibility, poll) +} + +export async function putStatus (instanceName, accessToken, id, text, inReplyToId, mediaIds, + sensitive, spoilerText, visibility, poll) { + const url = `${basename(instanceName)}/api/v1/statuses/${id}` + return postOrPutStatus(url, accessToken, 'put', text, inReplyToId, mediaIds, + sensitive, spoilerText, visibility, poll) } export async function getStatusContext (instanceName, accessToken, statusId) { diff --git a/src/routes/_database/timelines/updateStatus.js b/src/routes/_database/timelines/updateStatus.js index 89396e35..5967084e 100644 --- a/src/routes/_database/timelines/updateStatus.js +++ b/src/routes/_database/timelines/updateStatus.js @@ -3,12 +3,13 @@ import { getInCache, hasInCache, statusesCache } from '../cache.js' import { STATUSES_STORE } from '../constants.js' import { cacheStatus } from './cacheStatus.js' import { putStatus } from './insertion.js' +import { cloneForStorage } from '../helpers.js' // // update statuses // -async function updateStatus (instanceName, statusId, updateFunc) { +async function doUpdateStatus (instanceName, statusId, updateFunc) { const db = await getDatabase(instanceName) if (hasInCache(statusesCache, instanceName, statusId)) { const status = getInCache(statusesCache, instanceName, statusId) @@ -25,7 +26,7 @@ async function updateStatus (instanceName, statusId, updateFunc) { } export async function setStatusFavorited (instanceName, statusId, favorited) { - return updateStatus(instanceName, statusId, status => { + return doUpdateStatus(instanceName, statusId, status => { const delta = (favorited ? 1 : 0) - (status.favourited ? 1 : 0) status.favourited = favorited status.favourites_count = (status.favourites_count || 0) + delta @@ -33,7 +34,7 @@ export async function setStatusFavorited (instanceName, statusId, favorited) { } export async function setStatusReblogged (instanceName, statusId, reblogged) { - return updateStatus(instanceName, statusId, status => { + return doUpdateStatus(instanceName, statusId, status => { const delta = (reblogged ? 1 : 0) - (status.reblogged ? 1 : 0) status.reblogged = reblogged status.reblogs_count = (status.reblogs_count || 0) + delta @@ -41,19 +42,36 @@ export async function setStatusReblogged (instanceName, statusId, reblogged) { } export async function setStatusPinned (instanceName, statusId, pinned) { - return updateStatus(instanceName, statusId, status => { + return doUpdateStatus(instanceName, statusId, status => { status.pinned = pinned }) } export async function setStatusMuted (instanceName, statusId, muted) { - return updateStatus(instanceName, statusId, status => { + return doUpdateStatus(instanceName, statusId, status => { status.muted = muted }) } export async function setStatusBookmarked (instanceName, statusId, bookmarked) { - return updateStatus(instanceName, statusId, status => { + return doUpdateStatus(instanceName, statusId, status => { status.bookmarked = bookmarked }) } + +// For the full list, see https://docs.joinmastodon.org/methods/statuses/#edit +const PROPS_THAT_CAN_BE_EDITED = ['content', 'spoiler_text', 'sensitive', 'language', 'media_ids', 'poll'] + +export async function updateStatus (instanceName, newStatus) { + const clonedNewStatus = cloneForStorage(newStatus) + return doUpdateStatus(instanceName, newStatus.id, status => { + // We can't use a simple Object.assign() to merge because a prop might have been deleted + for (const prop of PROPS_THAT_CAN_BE_EDITED) { + if (!(prop in clonedNewStatus)) { + delete status[prop] + } else { + status[prop] = clonedNewStatus[prop] + } + } + }) +} diff --git a/tests/serverActions.js b/tests/serverActions.js index 990a6e25..a0b2e42e 100644 --- a/tests/serverActions.js +++ b/tests/serverActions.js @@ -2,7 +2,7 @@ import { favoriteStatus } from '../src/routes/_api/favorite.js' import fetch from 'node-fetch' import FileApi from 'file-api' import { users } from './users.js' -import { postStatus } from '../src/routes/_api/statuses.js' +import { postStatus, putStatus } from '../src/routes/_api/statuses.js' import { deleteStatus } from '../src/routes/_api/delete.js' import { authorizeFollowRequest, getFollowRequests } from '../src/routes/_api/followRequests.js' import { followAccount, unfollowAccount } from '../src/routes/_api/follow.js' @@ -33,6 +33,11 @@ export async function postAs (username, text) { null, null, false, null, 'public') } +export async function putAs (username, text, statusId) { + return putStatus(instanceName, users[username].accessToken, statusId, text, + null, null, false, null, 'public') +} + export async function postWithSpoilerAndPrivacyAs (username, text, spoiler, privacy) { return postStatus(instanceName, users[username].accessToken, text, null, null, true, spoiler, privacy) diff --git a/tests/spec/140-editing.js b/tests/spec/140-editing.js new file mode 100644 index 00000000..349fa4bf --- /dev/null +++ b/tests/spec/140-editing.js @@ -0,0 +1,28 @@ +import { + getNthStatus, getUrl, goBack, + sleep +} from '../utils' +import { loginAsFoobar } from '../roles' +import { postAs, putAs } from '../serverActions' + +fixture`140-editing.js` + .page`http://localhost:4002` + +test('Edited toots are updated in the UI', async t => { + const { id: statusId } = await postAs('admin', 'yolo') + await sleep(500) + + await loginAsFoobar(t) + await t.expect(getNthStatus(1).innerText).contains('yolo', { timeout: 20000 }) + + await putAs('admin', 'wait I mean YOLO', statusId) + await sleep(500) + + await t.click(getNthStatus(1)) + .expect(getUrl()).contains('/statuses') + .expect(getNthStatus(1).innerText).contains('wait I mean YOLO', { timeout: 20000 }) + await goBack() + await t + .expect(getUrl()).eql('http://localhost:4002/') + .expect(getNthStatus(1).innerText).contains('wait I mean YOLO', { timeout: 20000 }) +})