From 808920297769c487ec81208bc9d41493c9781fa6 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 29 Apr 2018 12:28:44 -0700 Subject: [PATCH] add ability to pin and unpin statuses (#235) * add ability to pin and unpin statuses * add another test --- bin/restore-mastodon-data.js | 3 +- routes/_actions/pin.js | 28 ++++++++++ routes/_api/pin.js | 12 +++++ routes/_api/statuses.js | 10 ---- .../components/StatusOptionsDialog.html | 23 +++++++-- .../creators/showStatusOptionsDialog.js | 4 +- routes/_components/status/StatusToolbar.html | 4 +- .../_components/timeline/PinnedStatuses.html | 31 ++++++----- routes/_database/timelines/pinnedStatuses.js | 17 +++++-- routes/_database/timelines/updateStatus.js | 6 +++ tests/spec/004-pinned-statuses.js | 22 ++++---- tests/spec/116-follow-requests.js | 3 +- tests/spec/117-pin-unpin.js | 51 +++++++++++++++++++ tests/utils.js | 9 ++++ 14 files changed, 175 insertions(+), 48 deletions(-) create mode 100644 routes/_actions/pin.js create mode 100644 routes/_api/pin.js create mode 100644 tests/spec/117-pin-unpin.js diff --git a/bin/restore-mastodon-data.js b/bin/restore-mastodon-data.js index c59e60e2..ce22e786 100644 --- a/bin/restore-mastodon-data.js +++ b/bin/restore-mastodon-data.js @@ -1,6 +1,6 @@ import { actions } from './mastodon-data' import { users } from '../tests/users' -import { pinStatus, postStatus } from '../routes/_api/statuses' +import { postStatus } from '../routes/_api/statuses' import { followAccount } from '../routes/_api/follow' import { favoriteStatus } from '../routes/_api/favorite' import { reblogStatus } from '../routes/_api/reblog' @@ -10,6 +10,7 @@ import path from 'path' import fs from 'fs' import FormData from 'form-data' import { auth } from '../routes/_api/utils' +import { pinStatus } from '../routes/_api/pin' global.File = FileApi.File global.FormData = FileApi.FormData diff --git a/routes/_actions/pin.js b/routes/_actions/pin.js new file mode 100644 index 00000000..0c020f77 --- /dev/null +++ b/routes/_actions/pin.js @@ -0,0 +1,28 @@ +import { store } from '../_store/store' +import { toast } from '../_utils/toast' +import { pinStatus, unpinStatus } from '../_api/pin' +import { setStatusPinned as setStatusPinnedInDatabase } from '../_database/timelines/updateStatus' +import { emit } from '../_utils/eventBus' + +export async function setStatusPinnedOrUnpinned (statusId, pinned, toastOnSuccess) { + let { currentInstance, accessToken } = store.get() + try { + if (pinned) { + await pinStatus(currentInstance, accessToken, statusId) + } else { + await unpinStatus(currentInstance, accessToken, statusId) + } + if (toastOnSuccess) { + if (pinned) { + toast.say('Pinned status') + } else { + toast.say('Unpinned status') + } + } + await setStatusPinnedInDatabase(currentInstance, statusId, pinned) + emit('updatePinnedStatuses') + } catch (e) { + console.error(e) + toast.say(`Unable to ${pinned ? 'pin' : 'unpin'} status: ` + (e.message || '')) + } +} diff --git a/routes/_api/pin.js b/routes/_api/pin.js new file mode 100644 index 00000000..21269b8e --- /dev/null +++ b/routes/_api/pin.js @@ -0,0 +1,12 @@ +import { postWithTimeout } from '../_utils/ajax' +import { auth, basename } from './utils' + +export async function pinStatus (instanceName, accessToken, statusId) { + let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/pin` + return postWithTimeout(url, null, auth(accessToken)) +} + +export async function unpinStatus (instanceName, accessToken, statusId) { + let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unpin` + return postWithTimeout(url, null, auth(accessToken)) +} diff --git a/routes/_api/statuses.js b/routes/_api/statuses.js index 31fb5af7..cd02c818 100644 --- a/routes/_api/statuses.js +++ b/routes/_api/statuses.js @@ -23,13 +23,3 @@ export async function postStatus (instanceName, accessToken, text, inReplyToId, return postWithTimeout(url, body, auth(accessToken)) } - -export async function pinStatus (instanceName, accessToken, statusId) { - let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/pin` - return postWithTimeout(url, null, auth(accessToken)) -} - -export async function unpinStatus (instanceName, accessToken, statusId) { - let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unpin` - return postWithTimeout(url, null, auth(accessToken)) -} diff --git a/routes/_components/dialog/components/StatusOptionsDialog.html b/routes/_components/dialog/components/StatusOptionsDialog.html index ced5002b..6a025455 100644 --- a/routes/_components/dialog/components/StatusOptionsDialog.html +++ b/routes/_components/dialog/components/StatusOptionsDialog.html @@ -17,6 +17,7 @@ import { close } from '../helpers/closeDialog' import { oncreate } from '../helpers/onCreateDialog' import { setAccountBlocked } from '../../../_actions/block' import { setAccountMuted } from '../../../_actions/mute' +import { setStatusPinnedOrUnpinned } from '../../../_actions/pin' export default { oncreate, @@ -24,6 +25,8 @@ export default { relationship: ($currentAccountRelationship) => $currentAccountRelationship, account: ($currentAccountProfile) => $currentAccountProfile, verifyCredentials: ($currentVerifyCredentials) => $currentVerifyCredentials, + statusId: (status) => status.id, + pinned: (status) => status.pinned, // begin account data copypasta verifyCredentialsId: (verifyCredentials) => verifyCredentials.id, following: (relationship) => relationship && relationship.following, @@ -52,16 +55,21 @@ export default { }, muteIcon: (muting) => muting ? '#fa-volume-up' : '#fa-volume-off', // end account data copypasta - items: (blockLabel, blocking, blockIcon, muteLabel, muteIcon, - followLabel, followIcon, following, followRequested, - accountId, verifyCredentialsId) => { - let isUser = accountId === verifyCredentialsId + isUser: (accountId, verifyCredentialsId) => accountId === verifyCredentialsId, + pinLabel: (pinned, isUser) => isUser ? (pinned ? 'Unpin from profile' : 'Pin to profile') : '', + items: (blockLabel, blocking, blockIcon, muteLabel, muteIcon, followLabel, followIcon, + following, followRequested, pinLabel, isUser) => { return [ isUser && { key: 'delete', label: 'Delete', icon: '#fa-trash' }, + isUser && { + key: 'pin', + label: pinLabel, + icon: '#fa-thumb-tack' + }, !isUser && !blocking && { key: 'follow', label: followLabel, @@ -93,6 +101,8 @@ export default { switch (item.key) { case 'delete': return this.onDeleteClicked() + case 'pin': + return this.onPinClicked() case 'follow': return this.onFollowClicked() case 'block': @@ -106,6 +116,11 @@ export default { this.close() await doDeleteStatus(statusId) }, + async onPinClicked () { + let { statusId, pinned } = this.get() + this.close() + await setStatusPinnedOrUnpinned(statusId, !pinned, true) + }, async onFollowClicked () { let { accountId, following } = this.get() this.close() diff --git a/routes/_components/dialog/creators/showStatusOptionsDialog.js b/routes/_components/dialog/creators/showStatusOptionsDialog.js index 9dd5ce8e..f3c3eac7 100644 --- a/routes/_components/dialog/creators/showStatusOptionsDialog.js +++ b/routes/_components/dialog/creators/showStatusOptionsDialog.js @@ -2,14 +2,14 @@ import StatusOptionsDialog from '../components/StatusOptionsDialog.html' import { createDialogElement } from '../helpers/createDialogElement' import { createDialogId } from '../helpers/createDialogId' -export default function showStatusOptionsDialog (statusId) { +export default function showStatusOptionsDialog (status) { let dialog = new StatusOptionsDialog({ target: createDialogElement(), data: { id: createDialogId(), label: 'Status options dialog', title: '', - statusId: statusId + status: status } }) dialog.show() diff --git a/routes/_components/status/StatusToolbar.html b/routes/_components/status/StatusToolbar.html index 4872f389..c9b3910b 100644 --- a/routes/_components/status/StatusToolbar.html +++ b/routes/_components/status/StatusToolbar.html @@ -107,11 +107,11 @@ async onOptionsClick (e) { e.preventDefault() e.stopPropagation() - let { originalStatusId, originalAccountId } = this.get() + let { originalStatus, originalAccountId } = this.get() let updateRelationshipPromise = updateProfileAndRelationship(originalAccountId) let showStatusOptionsDialog = await importShowStatusOptionsDialog() await updateRelationshipPromise - showStatusOptionsDialog(originalStatusId) + showStatusOptionsDialog(originalStatus) }, onPostedStatus (realm, inReplyToUuid) { let { diff --git a/routes/_components/timeline/PinnedStatuses.html b/routes/_components/timeline/PinnedStatuses.html index 91e53e45..7112dd27 100644 --- a/routes/_components/timeline/PinnedStatuses.html +++ b/routes/_components/timeline/PinnedStatuses.html @@ -1,33 +1,38 @@
- {{#if pinnedStatuses}} - {{#each pinnedStatuses as status, index}} - - {{/each}} - {{/if}} + {{#each pinnedStatuses as status, index @id}} + + {{/each}}
\ No newline at end of file diff --git a/routes/_database/timelines/pinnedStatuses.js b/routes/_database/timelines/pinnedStatuses.js index f987a428..9e388b61 100644 --- a/routes/_database/timelines/pinnedStatuses.js +++ b/routes/_database/timelines/pinnedStatuses.js @@ -13,10 +13,19 @@ export async function insertPinnedStatuses (instanceName, accountId, statuses) { let storeNames = [PINNED_STATUSES_STORE, STATUSES_STORE, ACCOUNTS_STORE] await dbPromise(db, storeNames, 'readwrite', (stores) => { let [ pinnedStatusesStore, statusesStore, accountsStore ] = stores - statuses.forEach((status, i) => { - storeStatus(statusesStore, accountsStore, status) - pinnedStatusesStore.put(status.id, createPinnedStatusId(accountId, i)) - }) + + let keyRange = createPinnedStatusKeyRange(accountId) + pinnedStatusesStore.getAll(keyRange).onsuccess = e => { + // if there was e.g. 1 pinned status before and 2 now, then we need to delete the old one + let existingPinnedStatuses = e.target.result + for (let i = statuses.length; i < existingPinnedStatuses.length; i++) { + pinnedStatusesStore.delete(createPinnedStatusKeyRange(accountId, i)) + } + statuses.forEach((status, i) => { + storeStatus(statusesStore, accountsStore, status) + pinnedStatusesStore.put(status.id, createPinnedStatusId(accountId, i)) + }) + } }) } diff --git a/routes/_database/timelines/updateStatus.js b/routes/_database/timelines/updateStatus.js index a41283a5..33a1af9f 100644 --- a/routes/_database/timelines/updateStatus.js +++ b/routes/_database/timelines/updateStatus.js @@ -39,3 +39,9 @@ export async function setStatusReblogged (instanceName, statusId, reblogged) { status.reblogs_count = (status.reblogs_count || 0) + delta }) } + +export async function setStatusPinned (instanceName, statusId, pinned) { + return updateStatus(instanceName, statusId, status => { + status.pinned = pinned + }) +} diff --git a/tests/spec/004-pinned-statuses.js b/tests/spec/004-pinned-statuses.js index 0bb82293..583409f0 100644 --- a/tests/spec/004-pinned-statuses.js +++ b/tests/spec/004-pinned-statuses.js @@ -1,5 +1,5 @@ import { Selector as $ } from 'testcafe' -import { getUrl } from '../utils' +import { communityNavButton, getNthPinnedStatus, getUrl } from '../utils' import { foobarRole } from '../roles' fixture`004-pinned-statuses.js` @@ -7,9 +7,9 @@ fixture`004-pinned-statuses.js` test("shows a user's pinned statuses", async t => { await t.useRole(foobarRole) - .click($('nav a[aria-label=Community]')) + .click(communityNavButton) .expect(getUrl()).contains('/community') - .click($('a').withText(('Pinned'))) + .click($('a[href="/pinned"]')) .expect(getUrl()).contains('/pinned') .expect($('.status-article').getAttribute('aria-posinset')).eql('0') .expect($('.status-article').getAttribute('aria-setsize')).eql('1') @@ -19,17 +19,17 @@ test("shows a user's pinned statuses", async t => { test("shows pinned statuses on a user's account page", async t => { await t.useRole(foobarRole) .navigateTo('/accounts/2') - .expect($('.pinned-statuses .status-article').getAttribute('aria-posinset')).eql('0') - .expect($('.pinned-statuses .status-article').getAttribute('aria-setsize')).eql('1') - .expect($('.pinned-statuses .status-article').innerText).contains('this is unlisted') + .expect(getNthPinnedStatus(0).getAttribute('aria-posinset')).eql('0') + .expect(getNthPinnedStatus(0).getAttribute('aria-setsize')).eql('1') + .expect(getNthPinnedStatus(0).innerText).contains('this is unlisted') }) test("shows pinned statuses on a user's account page 2", async t => { await t.useRole(foobarRole) .navigateTo('/accounts/3') - .expect($('.pinned-statuses .status-article').getAttribute('aria-posinset')).eql('0') - .expect($('.pinned-statuses .status-article').getAttribute('aria-setsize')).eql('2') - .expect($('.pinned-statuses .status-article').innerText).contains('pinned toot 1') - .expect($('.pinned-statuses .status-article[aria-posinset="1"]').getAttribute('aria-setsize')).eql('2') - .expect($('.pinned-statuses .status-article[aria-posinset="1"]').innerText).contains('pinned toot 2') + .expect(getNthPinnedStatus(0).getAttribute('aria-posinset')).eql('0') + .expect(getNthPinnedStatus(0).getAttribute('aria-setsize')).eql('2') + .expect(getNthPinnedStatus(0).innerText).contains('pinned toot 1') + .expect(getNthPinnedStatus(1).getAttribute('aria-setsize')).eql('2') + .expect(getNthPinnedStatus(1).innerText).contains('pinned toot 2') }) diff --git a/tests/spec/116-follow-requests.js b/tests/spec/116-follow-requests.js index 418e9a16..5287b7e5 100644 --- a/tests/spec/116-follow-requests.js +++ b/tests/spec/116-follow-requests.js @@ -1,6 +1,7 @@ import { lockedAccountRole } from '../roles' import { followAs, unfollowAs } from '../serverActions' import { + avatarInComposeBox, communityNavButton, followersButton, getNthSearchResult, getSearchResultByHref, getUrl, goBack, homeNavButton, sleep } from '../utils' @@ -67,7 +68,7 @@ test('Can approve and reject follow requests', async t => { .expect(getNthSearchResult(1).exists).notOk({timeout}) // check our follow list to make sure they follow us .click(homeNavButton) - .click($('.compose-box-avatar')) + .click(avatarInComposeBox) .expect(getUrl()).contains(`/accounts/${users.LockedAccount.id}`) .click(followersButton) .expect(getNthSearchResult(1).innerText).match(/(@admin|@quux)/) diff --git a/tests/spec/117-pin-unpin.js b/tests/spec/117-pin-unpin.js new file mode 100644 index 00000000..b4049ea0 --- /dev/null +++ b/tests/spec/117-pin-unpin.js @@ -0,0 +1,51 @@ +import { foobarRole } from '../roles' +import { postAs } from '../serverActions' +import { + avatarInComposeBox, getNthDialogOptionsOption, getNthPinnedStatus, getNthPinnedStatusFavoriteButton, getNthStatus, + getNthStatusOptionsButton, getUrl, sleep +} from '../utils' +import { users } from '../users' + +fixture`117-pin-unpin.js` + .page`http://localhost:4002` + +test('Can pin statuses', async t => { + await t.useRole(foobarRole) + + await postAs('foobar', 'I am going to pin this') + + await sleep(2000) + + await t.click(avatarInComposeBox) + .expect(getUrl()).contains(`/accounts/${users.foobar.id}`) + .expect(getNthPinnedStatus(0).getAttribute('aria-setsize')).eql('1') + .expect(getNthPinnedStatus(0).innerText).contains('this is unlisted') + .expect(getNthStatus(0).innerText).contains('I am going to pin this') + .click(getNthStatusOptionsButton(0)) + .expect(getNthDialogOptionsOption(1).innerText).contains('Delete') + .expect(getNthDialogOptionsOption(2).innerText).contains('Pin to profile') + .click(getNthDialogOptionsOption(2)) + .expect(getNthPinnedStatus(0).getAttribute('aria-setsize')).eql('2') + .expect(getNthPinnedStatus(0).innerText).contains('I am going to pin this') + .expect(getNthPinnedStatus(1).innerText).contains('this is unlisted') + .expect(getNthStatus(0).innerText).contains('I am going to pin this') + .click(getNthStatusOptionsButton(0)) + .expect(getNthDialogOptionsOption(1).innerText).contains('Delete') + .expect(getNthDialogOptionsOption(2).innerText).contains('Unpin from profile') + .click(getNthDialogOptionsOption(2)) + .expect(getUrl()).contains(`/accounts/${users.foobar.id}`) + .expect(getNthPinnedStatus(0).getAttribute('aria-setsize')).eql('1') + .expect(getNthPinnedStatus(0).innerText).contains('this is unlisted') + .expect(getNthStatus(0).innerText).contains('I am going to pin this') +}) + +test('Can favorite a pinned status', async t => { + await t.useRole(foobarRole) + .click(avatarInComposeBox) + .expect(getNthPinnedStatus(0).getAttribute('aria-setsize')).eql('1') + .expect(getNthPinnedStatusFavoriteButton(0).getAttribute('aria-pressed')).eql('false') + .click(getNthPinnedStatusFavoriteButton(0)) + .expect(getNthPinnedStatusFavoriteButton(0).getAttribute('aria-pressed')).eql('true') + .click(getNthPinnedStatusFavoriteButton(0)) + .expect(getNthPinnedStatusFavoriteButton(0).getAttribute('aria-pressed')).eql('false') +}) diff --git a/tests/utils.js b/tests/utils.js index d0aa59a2..27219590 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -41,6 +41,7 @@ export const addInstanceButton = $('#submitButton') export const mastodonLogInButton = $('button[type="submit"]') export const followsButton = $('.account-profile-details > *:nth-child(2)') export const followersButton = $('.account-profile-details > *:nth-child(3)') +export const avatarInComposeBox = $('.compose-box-avatar') export const favoritesCountElement = $('.status-favs-reblogs:nth-child(3)').addCustomDOMProperties({ innerCount: el => parseInt(el.innerText, 10) @@ -224,6 +225,14 @@ export function getReblogsCount () { return reblogsCountElement.innerCount } +export function getNthPinnedStatus (n) { + return $(`.pinned-statuses article[aria-posinset="${n}"]`) +} + +export function getNthPinnedStatusFavoriteButton (n) { + return getNthPinnedStatus(n).find('.status-toolbar button:nth-child(3)') +} + export async function validateTimeline (t, timeline) { for (let i = 0; i < timeline.length; i++) { let status = timeline[i]