add ability to pin and unpin statuses (#235)

* add ability to pin and unpin statuses

* add another test
This commit is contained in:
Nolan Lawson 2018-04-29 12:28:44 -07:00 committed by GitHub
parent d079b6d9e1
commit 8089202977
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 175 additions and 48 deletions

View File

@ -1,6 +1,6 @@
import { actions } from './mastodon-data' import { actions } from './mastodon-data'
import { users } from '../tests/users' 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 { followAccount } from '../routes/_api/follow'
import { favoriteStatus } from '../routes/_api/favorite' import { favoriteStatus } from '../routes/_api/favorite'
import { reblogStatus } from '../routes/_api/reblog' import { reblogStatus } from '../routes/_api/reblog'
@ -10,6 +10,7 @@ import path from 'path'
import fs from 'fs' import fs from 'fs'
import FormData from 'form-data' import FormData from 'form-data'
import { auth } from '../routes/_api/utils' import { auth } from '../routes/_api/utils'
import { pinStatus } from '../routes/_api/pin'
global.File = FileApi.File global.File = FileApi.File
global.FormData = FileApi.FormData global.FormData = FileApi.FormData

28
routes/_actions/pin.js Normal file
View File

@ -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 || ''))
}
}

12
routes/_api/pin.js Normal file
View File

@ -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))
}

View File

@ -23,13 +23,3 @@ export async function postStatus (instanceName, accessToken, text, inReplyToId,
return postWithTimeout(url, body, auth(accessToken)) 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))
}

View File

@ -17,6 +17,7 @@ import { close } from '../helpers/closeDialog'
import { oncreate } from '../helpers/onCreateDialog' import { oncreate } from '../helpers/onCreateDialog'
import { setAccountBlocked } from '../../../_actions/block' import { setAccountBlocked } from '../../../_actions/block'
import { setAccountMuted } from '../../../_actions/mute' import { setAccountMuted } from '../../../_actions/mute'
import { setStatusPinnedOrUnpinned } from '../../../_actions/pin'
export default { export default {
oncreate, oncreate,
@ -24,6 +25,8 @@ export default {
relationship: ($currentAccountRelationship) => $currentAccountRelationship, relationship: ($currentAccountRelationship) => $currentAccountRelationship,
account: ($currentAccountProfile) => $currentAccountProfile, account: ($currentAccountProfile) => $currentAccountProfile,
verifyCredentials: ($currentVerifyCredentials) => $currentVerifyCredentials, verifyCredentials: ($currentVerifyCredentials) => $currentVerifyCredentials,
statusId: (status) => status.id,
pinned: (status) => status.pinned,
// begin account data copypasta // begin account data copypasta
verifyCredentialsId: (verifyCredentials) => verifyCredentials.id, verifyCredentialsId: (verifyCredentials) => verifyCredentials.id,
following: (relationship) => relationship && relationship.following, following: (relationship) => relationship && relationship.following,
@ -52,16 +55,21 @@ export default {
}, },
muteIcon: (muting) => muting ? '#fa-volume-up' : '#fa-volume-off', muteIcon: (muting) => muting ? '#fa-volume-up' : '#fa-volume-off',
// end account data copypasta // end account data copypasta
items: (blockLabel, blocking, blockIcon, muteLabel, muteIcon, isUser: (accountId, verifyCredentialsId) => accountId === verifyCredentialsId,
followLabel, followIcon, following, followRequested, pinLabel: (pinned, isUser) => isUser ? (pinned ? 'Unpin from profile' : 'Pin to profile') : '',
accountId, verifyCredentialsId) => { items: (blockLabel, blocking, blockIcon, muteLabel, muteIcon, followLabel, followIcon,
let isUser = accountId === verifyCredentialsId following, followRequested, pinLabel, isUser) => {
return [ return [
isUser && { isUser && {
key: 'delete', key: 'delete',
label: 'Delete', label: 'Delete',
icon: '#fa-trash' icon: '#fa-trash'
}, },
isUser && {
key: 'pin',
label: pinLabel,
icon: '#fa-thumb-tack'
},
!isUser && !blocking && { !isUser && !blocking && {
key: 'follow', key: 'follow',
label: followLabel, label: followLabel,
@ -93,6 +101,8 @@ export default {
switch (item.key) { switch (item.key) {
case 'delete': case 'delete':
return this.onDeleteClicked() return this.onDeleteClicked()
case 'pin':
return this.onPinClicked()
case 'follow': case 'follow':
return this.onFollowClicked() return this.onFollowClicked()
case 'block': case 'block':
@ -106,6 +116,11 @@ export default {
this.close() this.close()
await doDeleteStatus(statusId) await doDeleteStatus(statusId)
}, },
async onPinClicked () {
let { statusId, pinned } = this.get()
this.close()
await setStatusPinnedOrUnpinned(statusId, !pinned, true)
},
async onFollowClicked () { async onFollowClicked () {
let { accountId, following } = this.get() let { accountId, following } = this.get()
this.close() this.close()

View File

@ -2,14 +2,14 @@ import StatusOptionsDialog from '../components/StatusOptionsDialog.html'
import { createDialogElement } from '../helpers/createDialogElement' import { createDialogElement } from '../helpers/createDialogElement'
import { createDialogId } from '../helpers/createDialogId' import { createDialogId } from '../helpers/createDialogId'
export default function showStatusOptionsDialog (statusId) { export default function showStatusOptionsDialog (status) {
let dialog = new StatusOptionsDialog({ let dialog = new StatusOptionsDialog({
target: createDialogElement(), target: createDialogElement(),
data: { data: {
id: createDialogId(), id: createDialogId(),
label: 'Status options dialog', label: 'Status options dialog',
title: '', title: '',
statusId: statusId status: status
} }
}) })
dialog.show() dialog.show()

View File

@ -107,11 +107,11 @@
async onOptionsClick (e) { async onOptionsClick (e) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
let { originalStatusId, originalAccountId } = this.get() let { originalStatus, originalAccountId } = this.get()
let updateRelationshipPromise = updateProfileAndRelationship(originalAccountId) let updateRelationshipPromise = updateProfileAndRelationship(originalAccountId)
let showStatusOptionsDialog = await importShowStatusOptionsDialog() let showStatusOptionsDialog = await importShowStatusOptionsDialog()
await updateRelationshipPromise await updateRelationshipPromise
showStatusOptionsDialog(originalStatusId) showStatusOptionsDialog(originalStatus)
}, },
onPostedStatus (realm, inReplyToUuid) { onPostedStatus (realm, inReplyToUuid) {
let { let {

View File

@ -1,33 +1,38 @@
<div role="feed" aria-label="Pinned toots" class="pinned-statuses"> <div role="feed" aria-label="Pinned toots" class="pinned-statuses">
{{#if pinnedStatuses}} {{#each pinnedStatuses as status, index @id}}
{{#each pinnedStatuses as status, index}} <Status :status
<Status :status timelineType="pinned"
timelineType="pinned" timelineValue="{{accountId}}"
timelineValue="{{accountId}}" :index
:index length="{{pinnedStatuses.length}}"
length="{{pinnedStatuses.length}}" />
/> {{/each}}
{{/each}}
{{/if}}
</div> </div>
<script> <script>
import { store } from '../../_store/store' import { store } from '../../_store/store'
import Status from '../status/Status.html' import Status from '../status/Status.html'
import { updatePinnedStatusesForAccount } from '../../_actions/pinnedStatuses' import { updatePinnedStatusesForAccount } from '../../_actions/pinnedStatuses'
import { on } from '../../_utils/eventBus'
export default { export default {
async oncreate () { async oncreate () {
let { accountId } = this.get() on('updatePinnedStatuses', this, () => this.updatePinnedStatuses())
await updatePinnedStatusesForAccount(accountId) await this.updatePinnedStatuses()
}, },
computed: { computed: {
pinnedStatuses: ($pinnedStatuses, $currentInstance, accountId) => { pinnedStatuses: ($pinnedStatuses, $currentInstance, accountId) => {
return $pinnedStatuses[$currentInstance] && $pinnedStatuses[$currentInstance][accountId] return ($pinnedStatuses[$currentInstance] && $pinnedStatuses[$currentInstance][accountId]) || []
} }
}, },
store: () => store, store: () => store,
components: { components: {
Status Status
},
methods: {
async updatePinnedStatuses () {
let { accountId } = this.get()
await updatePinnedStatusesForAccount(accountId)
}
} }
} }
</script> </script>

View File

@ -13,10 +13,19 @@ export async function insertPinnedStatuses (instanceName, accountId, statuses) {
let storeNames = [PINNED_STATUSES_STORE, STATUSES_STORE, ACCOUNTS_STORE] let storeNames = [PINNED_STATUSES_STORE, STATUSES_STORE, ACCOUNTS_STORE]
await dbPromise(db, storeNames, 'readwrite', (stores) => { await dbPromise(db, storeNames, 'readwrite', (stores) => {
let [ pinnedStatusesStore, statusesStore, accountsStore ] = stores let [ pinnedStatusesStore, statusesStore, accountsStore ] = stores
statuses.forEach((status, i) => {
storeStatus(statusesStore, accountsStore, status) let keyRange = createPinnedStatusKeyRange(accountId)
pinnedStatusesStore.put(status.id, createPinnedStatusId(accountId, i)) 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))
})
}
}) })
} }

View File

@ -39,3 +39,9 @@ export async function setStatusReblogged (instanceName, statusId, reblogged) {
status.reblogs_count = (status.reblogs_count || 0) + delta status.reblogs_count = (status.reblogs_count || 0) + delta
}) })
} }
export async function setStatusPinned (instanceName, statusId, pinned) {
return updateStatus(instanceName, statusId, status => {
status.pinned = pinned
})
}

View File

@ -1,5 +1,5 @@
import { Selector as $ } from 'testcafe' import { Selector as $ } from 'testcafe'
import { getUrl } from '../utils' import { communityNavButton, getNthPinnedStatus, getUrl } from '../utils'
import { foobarRole } from '../roles' import { foobarRole } from '../roles'
fixture`004-pinned-statuses.js` fixture`004-pinned-statuses.js`
@ -7,9 +7,9 @@ fixture`004-pinned-statuses.js`
test("shows a user's pinned statuses", async t => { test("shows a user's pinned statuses", async t => {
await t.useRole(foobarRole) await t.useRole(foobarRole)
.click($('nav a[aria-label=Community]')) .click(communityNavButton)
.expect(getUrl()).contains('/community') .expect(getUrl()).contains('/community')
.click($('a').withText(('Pinned'))) .click($('a[href="/pinned"]'))
.expect(getUrl()).contains('/pinned') .expect(getUrl()).contains('/pinned')
.expect($('.status-article').getAttribute('aria-posinset')).eql('0') .expect($('.status-article').getAttribute('aria-posinset')).eql('0')
.expect($('.status-article').getAttribute('aria-setsize')).eql('1') .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 => { test("shows pinned statuses on a user's account page", async t => {
await t.useRole(foobarRole) await t.useRole(foobarRole)
.navigateTo('/accounts/2') .navigateTo('/accounts/2')
.expect($('.pinned-statuses .status-article').getAttribute('aria-posinset')).eql('0') .expect(getNthPinnedStatus(0).getAttribute('aria-posinset')).eql('0')
.expect($('.pinned-statuses .status-article').getAttribute('aria-setsize')).eql('1') .expect(getNthPinnedStatus(0).getAttribute('aria-setsize')).eql('1')
.expect($('.pinned-statuses .status-article').innerText).contains('this is unlisted') .expect(getNthPinnedStatus(0).innerText).contains('this is unlisted')
}) })
test("shows pinned statuses on a user's account page 2", async t => { test("shows pinned statuses on a user's account page 2", async t => {
await t.useRole(foobarRole) await t.useRole(foobarRole)
.navigateTo('/accounts/3') .navigateTo('/accounts/3')
.expect($('.pinned-statuses .status-article').getAttribute('aria-posinset')).eql('0') .expect(getNthPinnedStatus(0).getAttribute('aria-posinset')).eql('0')
.expect($('.pinned-statuses .status-article').getAttribute('aria-setsize')).eql('2') .expect(getNthPinnedStatus(0).getAttribute('aria-setsize')).eql('2')
.expect($('.pinned-statuses .status-article').innerText).contains('pinned toot 1') .expect(getNthPinnedStatus(0).innerText).contains('pinned toot 1')
.expect($('.pinned-statuses .status-article[aria-posinset="1"]').getAttribute('aria-setsize')).eql('2') .expect(getNthPinnedStatus(1).getAttribute('aria-setsize')).eql('2')
.expect($('.pinned-statuses .status-article[aria-posinset="1"]').innerText).contains('pinned toot 2') .expect(getNthPinnedStatus(1).innerText).contains('pinned toot 2')
}) })

View File

@ -1,6 +1,7 @@
import { lockedAccountRole } from '../roles' import { lockedAccountRole } from '../roles'
import { followAs, unfollowAs } from '../serverActions' import { followAs, unfollowAs } from '../serverActions'
import { import {
avatarInComposeBox,
communityNavButton, followersButton, getNthSearchResult, getSearchResultByHref, getUrl, goBack, communityNavButton, followersButton, getNthSearchResult, getSearchResultByHref, getUrl, goBack,
homeNavButton, sleep homeNavButton, sleep
} from '../utils' } from '../utils'
@ -67,7 +68,7 @@ test('Can approve and reject follow requests', async t => {
.expect(getNthSearchResult(1).exists).notOk({timeout}) .expect(getNthSearchResult(1).exists).notOk({timeout})
// check our follow list to make sure they follow us // check our follow list to make sure they follow us
.click(homeNavButton) .click(homeNavButton)
.click($('.compose-box-avatar')) .click(avatarInComposeBox)
.expect(getUrl()).contains(`/accounts/${users.LockedAccount.id}`) .expect(getUrl()).contains(`/accounts/${users.LockedAccount.id}`)
.click(followersButton) .click(followersButton)
.expect(getNthSearchResult(1).innerText).match(/(@admin|@quux)/) .expect(getNthSearchResult(1).innerText).match(/(@admin|@quux)/)

View File

@ -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')
})

View File

@ -41,6 +41,7 @@ export const addInstanceButton = $('#submitButton')
export const mastodonLogInButton = $('button[type="submit"]') export const mastodonLogInButton = $('button[type="submit"]')
export const followsButton = $('.account-profile-details > *:nth-child(2)') export const followsButton = $('.account-profile-details > *:nth-child(2)')
export const followersButton = $('.account-profile-details > *:nth-child(3)') 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({ export const favoritesCountElement = $('.status-favs-reblogs:nth-child(3)').addCustomDOMProperties({
innerCount: el => parseInt(el.innerText, 10) innerCount: el => parseInt(el.innerText, 10)
@ -224,6 +225,14 @@ export function getReblogsCount () {
return reblogsCountElement.innerCount 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) { export async function validateTimeline (t, timeline) {
for (let i = 0; i < timeline.length; i++) { for (let i = 0; i < timeline.length; i++) {
let status = timeline[i] let status = timeline[i]