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/src/scss/themes/_base.scss b/src/scss/themes/_base.scss index da971375..ff91c302 100644 --- a/src/scss/themes/_base.scss +++ b/src/scss/themes/_base.scss @@ -42,12 +42,12 @@ --nav-svg-fill-hover: #{$secondary-text-color}; --nav-text-color-hover: #{$secondary-text-color}; - --action-button-fill-color: #{lighten($main-theme-color, 18%)}; - --action-button-fill-color-hover: #{lighten($main-theme-color, 22%)}; - --action-button-fill-color-active: #{lighten($main-theme-color, 5%)}; - --action-button-fill-color-pressed: #{darken($main-theme-color, 7%)}; - --action-button-fill-color-pressed-hover: #{darken($main-theme-color, 2%)}; - --action-button-fill-color-pressed-active: #{darken($main-theme-color, 15%)}; + --action-button-fill-color: #{lighten($main-theme-color, 11.5%)}; + --action-button-fill-color-hover: #{lighten($main-theme-color, 6%)}; + --action-button-fill-color-active: #{$main-theme-color}; + --action-button-fill-color-pressed: #{darken(saturate($main-theme-color, 5%), 6%)}; + --action-button-fill-color-pressed-hover: #{darken(saturate($main-theme-color, 5%), 12%)}; + --action-button-fill-color-pressed-active: #{darken(saturate($main-theme-color, 5%), 15%)}; --action-button-deemphasized-fill-color: #{$deemphasized-color}; --action-button-deemphasized-fill-color-hover: #{lighten($deemphasized-color, 22%)}; diff --git a/src/scss/themes/pitchblack.scss b/src/scss/themes/pitchblack.scss index 62844910..b97ff1cc 100644 --- a/src/scss/themes/pitchblack.scss +++ b/src/scss/themes/pitchblack.scss @@ -33,9 +33,9 @@ $compose-background: darken($main-theme-color, 12%); --form-bg: #{$body-bg-color}; --form-border: #{darken($border-color, 10%)}; - --action-button-fill-color: #{lighten($main-theme-color, 20%)}; - --action-button-fill-color-hover: #{lighten($main-theme-color, 30%)}; - --action-button-fill-color-active: #{darken($main-theme-color, 40%)}; + --action-button-fill-color: #{lighten($main-theme-color, 50%)}; + --action-button-fill-color-hover: #{lighten($main-theme-color, 60%)}; + --action-button-fill-color-active: #{darken($main-theme-color, 70%)}; --action-button-fill-color-pressed: #{lighten($main-theme-color, 85%)}; --action-button-fill-color-pressed-hover: #{lighten($main-theme-color, 100%)}; --action-button-fill-color-pressed-active: #{lighten($main-theme-color, 80%)}; 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 }) +})