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]