add ability to pin and unpin statuses (#235)
* add ability to pin and unpin statuses * add another test
This commit is contained in:
		
							parent
							
								
									d079b6d9e1
								
							
						
					
					
						commit
						8089202977
					
				
					 14 changed files with 175 additions and 48 deletions
				
			
		|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										28
									
								
								routes/_actions/pin.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								routes/_actions/pin.js
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										12
									
								
								routes/_api/pin.js
									
										
									
									
									
										Normal 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)) | ||||
| } | ||||
|  | @ -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)) | ||||
| } | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -1,33 +1,38 @@ | |||
| <div role="feed" aria-label="Pinned toots" class="pinned-statuses"> | ||||
|   {{#if pinnedStatuses}} | ||||
|     {{#each pinnedStatuses as status, index}} | ||||
|       <Status :status | ||||
|               timelineType="pinned" | ||||
|               timelineValue="{{accountId}}" | ||||
|               :index | ||||
|               length="{{pinnedStatuses.length}}" | ||||
|       /> | ||||
|     {{/each}} | ||||
|   {{/if}} | ||||
|   {{#each pinnedStatuses as status, index @id}} | ||||
|     <Status :status | ||||
|             timelineType="pinned" | ||||
|             timelineValue="{{accountId}}" | ||||
|             :index | ||||
|             length="{{pinnedStatuses.length}}" | ||||
|     /> | ||||
|   {{/each}} | ||||
| </div> | ||||
| <script> | ||||
|   import { store } from '../../_store/store' | ||||
|   import Status from '../status/Status.html' | ||||
|   import { updatePinnedStatusesForAccount } from '../../_actions/pinnedStatuses' | ||||
|   import { on } from '../../_utils/eventBus' | ||||
| 
 | ||||
|   export default { | ||||
|     async oncreate () { | ||||
|       let { accountId } = this.get() | ||||
|       await updatePinnedStatusesForAccount(accountId) | ||||
|       on('updatePinnedStatuses', this, () => this.updatePinnedStatuses()) | ||||
|       await this.updatePinnedStatuses() | ||||
|     }, | ||||
|     computed: { | ||||
|       pinnedStatuses: ($pinnedStatuses, $currentInstance, accountId) => { | ||||
|         return $pinnedStatuses[$currentInstance] && $pinnedStatuses[$currentInstance][accountId] | ||||
|         return ($pinnedStatuses[$currentInstance] && $pinnedStatuses[$currentInstance][accountId]) || [] | ||||
|       } | ||||
|     }, | ||||
|     store: () => store, | ||||
|     components: { | ||||
|       Status | ||||
|     }, | ||||
|     methods: { | ||||
|       async updatePinnedStatuses () { | ||||
|         let { accountId } = this.get() | ||||
|         await updatePinnedStatusesForAccount(accountId) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </script> | ||||
|  | @ -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)) | ||||
|       }) | ||||
|     } | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
|   }) | ||||
| } | ||||
|  |  | |||
|  | @ -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') | ||||
| }) | ||||
|  |  | |||
|  | @ -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)/) | ||||
|  |  | |||
							
								
								
									
										51
									
								
								tests/spec/117-pin-unpin.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								tests/spec/117-pin-unpin.js
									
										
									
									
									
										Normal 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') | ||||
| }) | ||||
|  | @ -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] | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue