add favorite/unfavorite feature
This commit is contained in:
		
							parent
							
								
									3a17f7ff7b
								
							
						
					
					
						commit
						1b7a01f1ee
					
				
					 24 changed files with 291 additions and 108 deletions
				
			
		
							
								
								
									
										3
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -4,4 +4,5 @@ node_modules | |||
| yarn.lock | ||||
| templates/.* | ||||
| assets/*.css | ||||
| /mastodon | ||||
| /mastodon | ||||
| mastodon.log | ||||
|  | @ -60,13 +60,10 @@ async function runMastodon () { | |||
|     await exec(cmd, {cwd: mastodonDir}) | ||||
|   } | ||||
|   const promise = spawn('foreman', ['start'], {cwd: mastodonDir}) | ||||
|   const log = fs.createWriteStream('mastodon.log', {flags: 'a'}) | ||||
|   childProc = promise.childProcess | ||||
|   childProc.stdout.on('data', function (data) { | ||||
|     console.log(data.toString('utf8').replace(/\n$/, '')) | ||||
|   }) | ||||
|   childProc.stderr.on('data', function (data) { | ||||
|     console.error(data.toString('utf8').replace(/\n$/, '')) | ||||
|   }) | ||||
|   childProc.stdout.pipe(log) | ||||
|   childProc.stderr.pipe(log) | ||||
| 
 | ||||
|   await waitForMastodonToStart() | ||||
| } | ||||
|  |  | |||
|  | @ -3,20 +3,28 @@ import { store } from '../_store/store' | |||
| import { database } from '../_database/database' | ||||
| import { toast } from '../_utils/toast' | ||||
| 
 | ||||
| export async function setFavorited(statusId, favorited) { | ||||
| export async function setFavorited (statusId, favorited) { | ||||
|   if (!store.get('online')) { | ||||
|     toast.say('You cannot favorite or unfavorite while offline.') | ||||
|     return | ||||
|   } | ||||
|   let instanceName = store.get('currentInstance') | ||||
|   let accessToken = store.get('accessToken') | ||||
|   try { | ||||
|     let status = await (favorited | ||||
|     let result = await (favorited | ||||
|         ? favoriteStatus(instanceName, accessToken, statusId) | ||||
|         : unfavoriteStatus(instanceName, accessToken, statusId)) | ||||
|     await database.insertStatus(instanceName, status) | ||||
|     if (result.error) { | ||||
|       throw new Error(result.error) | ||||
|     } | ||||
|     await database.setStatusFavorited(instanceName, statusId, favorited) | ||||
|     let statusModifications = store.get('statusModifications') | ||||
|     let currentStatusModifications = statusModifications[instanceName] = | ||||
|       (statusModifications[instanceName] || {favorites: {}, reblogs: {}}) | ||||
|     currentStatusModifications.favorites[statusId] = favorited | ||||
|     store.set({statusModifications: statusModifications}) | ||||
|   } catch (e) { | ||||
|     toast.say('Failed to favorite/unfavorite. Please try again.') | ||||
|     console.error(e) | ||||
|     toast.say('Failed to favorite or unfavorite. ' + (e.message || '')) | ||||
|   } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -1,18 +1,14 @@ | |||
| import { getWithTimeout, paramsString } from '../_utils/ajax' | ||||
| import { basename } from './utils' | ||||
| import { auth, basename } from './utils' | ||||
| 
 | ||||
| export async function getBlockedAccounts (instanceName, accessToken, limit = 80) { | ||||
|   let url = `${basename(instanceName)}/api/v1/blocks` | ||||
|   url += '?' + paramsString({ limit }) | ||||
|   return getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return getWithTimeout(url, auth(accessToken)) | ||||
| } | ||||
| 
 | ||||
| export async function getMutedAccounts (instanceName, accessToken, limit = 80) { | ||||
|   let url = `${basename(instanceName)}/api/v1/mutes` | ||||
|   url += '?' + paramsString({ limit }) | ||||
|   return getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return getWithTimeout(url, auth(accessToken)) | ||||
| } | ||||
|  |  | |||
|  | @ -1,16 +1,12 @@ | |||
| import { post } from '../_utils/ajax' | ||||
| import { basename } from './utils' | ||||
| import { basename, auth } from './utils' | ||||
| 
 | ||||
| export async function favoriteStatus(instanceName, accessToken, statusId) { | ||||
| export async function favoriteStatus (instanceName, accessToken, statusId) { | ||||
|   let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/favourite` | ||||
|   return post(url, null, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return post(url, null, auth(accessToken)) | ||||
| } | ||||
| 
 | ||||
| export async function unfavoriteStatus(instanceName, accessToken, statusId) { | ||||
| export async function unfavoriteStatus (instanceName, accessToken, statusId) { | ||||
|   let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unfavourite` | ||||
|   return post(url, null, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
| } | ||||
|   return post(url, null, auth(accessToken)) | ||||
| } | ||||
|  |  | |||
|  | @ -1,9 +1,7 @@ | |||
| import { getWithTimeout } from '../_utils/ajax' | ||||
| import { basename } from './utils' | ||||
| import { auth, basename } from './utils' | ||||
| 
 | ||||
| export function getLists (instanceName, accessToken) { | ||||
|   let url = `${basename(instanceName)}/api/v1/lists` | ||||
|   return getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return getWithTimeout(url, auth(accessToken)) | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { getWithTimeout, paramsString } from '../_utils/ajax' | ||||
| import { basename } from './utils' | ||||
| import { auth, basename } from './utils' | ||||
| 
 | ||||
| export async function getPinnedStatuses (instanceName, accessToken, accountId) { | ||||
|   let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/statuses` | ||||
|  | @ -7,7 +7,5 @@ export async function getPinnedStatuses (instanceName, accessToken, accountId) { | |||
|     limit: 40, | ||||
|     pinned: true | ||||
|   }) | ||||
|   return getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return getWithTimeout(url, auth(accessToken)) | ||||
| } | ||||
|  |  | |||
|  | @ -1,18 +1,14 @@ | |||
| import { getWithTimeout, paramsString } from '../_utils/ajax' | ||||
| import { basename } from './utils' | ||||
| import { auth, basename } from './utils' | ||||
| 
 | ||||
| export async function getReblogs (instanceName, accessToken, statusId, limit = 80) { | ||||
|   let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/reblogged_by` | ||||
|   url += '?' + paramsString({ limit }) | ||||
|   return getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return getWithTimeout(url, auth(accessToken)) | ||||
| } | ||||
| 
 | ||||
| export async function getFavorites (instanceName, accessToken, statusId, limit = 80) { | ||||
|   let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/favourited_by` | ||||
|   url += '?' + paramsString({ limit }) | ||||
|   return getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return getWithTimeout(url, auth(accessToken)) | ||||
| } | ||||
|  |  | |||
|  | @ -1,12 +1,10 @@ | |||
| import { getWithTimeout, paramsString } from '../_utils/ajax' | ||||
| import { basename } from './utils' | ||||
| import { auth, basename } from './utils' | ||||
| 
 | ||||
| export function search (instanceName, accessToken, query) { | ||||
|   let url = `${basename(instanceName)}/api/v1/search?` + paramsString({ | ||||
|     q: query, | ||||
|     resolve: true | ||||
|   }) | ||||
|   return getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return getWithTimeout(url, auth(accessToken)) | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { getWithTimeout, paramsString } from '../_utils/ajax' | ||||
| import { basename } from './utils' | ||||
| import { auth, basename } from './utils' | ||||
| 
 | ||||
| function getTimelineUrlPath (timeline) { | ||||
|   switch (timeline) { | ||||
|  | @ -57,14 +57,12 @@ export function getTimeline (instanceName, accessToken, timeline, maxId, since) | |||
|     // special case - this is a list of descendents and ancestors
 | ||||
|     let statusUrl = `${basename(instanceName)}/api/v1/statuses/${timeline.split('/').slice(-1)[0]}}` | ||||
|     return Promise.all([ | ||||
|       getWithTimeout(url, {'Authorization': `Bearer ${accessToken}`}), | ||||
|       getWithTimeout(statusUrl, {'Authorization': `Bearer ${accessToken}`}) | ||||
|       getWithTimeout(url, auth(accessToken)), | ||||
|       getWithTimeout(statusUrl, auth(accessToken)) | ||||
|     ]).then(res => { | ||||
|       return [].concat(res[0].ancestors).concat([res[1]]).concat(res[0].descendants) | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   return getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return getWithTimeout(url, auth(accessToken)) | ||||
| } | ||||
|  |  | |||
|  | @ -1,25 +1,19 @@ | |||
| import { getWithTimeout, paramsString } from '../_utils/ajax' | ||||
| import { basename } from './utils' | ||||
| import { auth, basename } from './utils' | ||||
| 
 | ||||
| export function getVerifyCredentials (instanceName, accessToken) { | ||||
|   let url = `${basename(instanceName)}/api/v1/accounts/verify_credentials` | ||||
|   return getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return getWithTimeout(url, auth(accessToken)) | ||||
| } | ||||
| 
 | ||||
| export function getAccount (instanceName, accessToken, accountId) { | ||||
|   let url = `${basename(instanceName)}/api/v1/accounts/${accountId}` | ||||
|   return getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   return getWithTimeout(url, auth(accessToken)) | ||||
| } | ||||
| 
 | ||||
| export async function getRelationship (instanceName, accessToken, accountId) { | ||||
|   let url = `${basename(instanceName)}/api/v1/accounts/relationships` | ||||
|   url += '?' + paramsString({id: accountId}) | ||||
|   let res = await getWithTimeout(url, { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   }) | ||||
|   let res = await getWithTimeout(url, auth(accessToken)) | ||||
|   return res[0] | ||||
| } | ||||
|  |  | |||
|  | @ -13,3 +13,9 @@ export function basename (instanceName) { | |||
|   } | ||||
|   return `https://${instanceName}` | ||||
| } | ||||
| 
 | ||||
| export function auth (accessToken) { | ||||
|   return { | ||||
|     'Authorization': `Bearer ${accessToken}` | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -2,8 +2,10 @@ | |||
|   <button type="button" | ||||
|           aria-label="{{label}}" | ||||
|           aria-pressed="{{!!pressed}}" | ||||
|           class="icon-button {{pressed ? 'pressed' : ''}} {{big ? 'big-icon' : ''}}" | ||||
|           class="{{computedClass}}" | ||||
|           disabled="{{disabled}}" | ||||
|           delegate-click-key="{{delegateKey}}" | ||||
|           delegate-keydown-key="{{delegateKey}}" | ||||
|           on:click | ||||
|   > | ||||
|     <svg> | ||||
|  | @ -13,8 +15,10 @@ | |||
| {{else}} | ||||
|   <button type="button" | ||||
|           aria-label="{{label}}" | ||||
|           class="icon-button {{big ? 'big-icon' : ''}}" | ||||
|           class="{{computedClass}}" | ||||
|           disabled="{{disabled}}" | ||||
|           delegate-click-key="{{delegateKey}}" | ||||
|           delegate-keydown-key="{{delegateKey}}" | ||||
|           on:click | ||||
|   > | ||||
|     <svg> | ||||
|  | @ -44,7 +48,7 @@ | |||
|     fill: var(--action-button-fill-color-hover); | ||||
|   } | ||||
| 
 | ||||
|   button.icon-button:active svg { | ||||
|   button.icon-button.not-pressable:active svg { | ||||
|     fill: var(--action-button-fill-color-active); | ||||
|   } | ||||
| 
 | ||||
|  | @ -59,4 +63,20 @@ | |||
|   button.icon-button.pressed:active svg { | ||||
|     fill: var(--action-button-fill-color-pressed-active); | ||||
|   } | ||||
| </style> | ||||
| </style> | ||||
| <script> | ||||
|   import identity from 'lodash/identity' | ||||
| 
 | ||||
|   export default { | ||||
|     computed: { | ||||
|       computedClass: (pressable, pressed, big) => { | ||||
|         return [ | ||||
|           'icon-button', | ||||
|           !pressable && 'not-pressable', | ||||
|           pressed && 'pressed', | ||||
|           big && 'big-icon', | ||||
|         ].filter(identity).join(' ') | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </script> | ||||
|  | @ -28,7 +28,7 @@ | |||
|   {{#if isStatusInOwnThread}} | ||||
|   <StatusDetails status="{{originalStatus}}" /> | ||||
|   {{/if}} | ||||
|   <StatusToolbar :status :isStatusInOwnThread /> | ||||
|   <StatusToolbar status="{{originalStatus}}" :isStatusInOwnThread :timelineType :timelineValue /> | ||||
| </article> | ||||
| 
 | ||||
| <style> | ||||
|  |  | |||
|  | @ -15,6 +15,8 @@ | |||
|     pressable="true" | ||||
|     pressed="{{favorited}}" | ||||
|     href="#fa-star" | ||||
|     delegateKey="{{favoriteKey}}" | ||||
|     ref:favoriteNode | ||||
|     /> | ||||
|   <IconButton | ||||
|     label="Show more actions" | ||||
|  | @ -34,12 +36,33 @@ | |||
| <script> | ||||
|   import IconButton from '../IconButton.html' | ||||
|   import { store } from '../../_store/store' | ||||
|   import { registerDelegate, unregisterDelegate } from '../../_utils/delegate' | ||||
|   import { setFavorited } from '../../_actions/favorite' | ||||
| 
 | ||||
|   export default { | ||||
|     oncreate() { | ||||
|       this.onFavoriteClick = this.onFavoriteClick.bind(this) | ||||
| 
 | ||||
|       let favoriteKey = this.get('favoriteKey') | ||||
|       registerDelegate('click', favoriteKey, this.onFavoriteClick) | ||||
|       registerDelegate('keydown', favoriteKey, this.onFavoriteClick) | ||||
|     }, | ||||
|     ondestroy() { | ||||
|       let favoriteKey = this.get('favoriteKey') | ||||
|       unregisterDelegate('click', favoriteKey, this.onFavoriteClick) | ||||
|       unregisterDelegate('keydown', favoriteKey, this.onFavoriteClick) | ||||
|     }, | ||||
|     components: { | ||||
|       IconButton | ||||
|     }, | ||||
|     store: () => store, | ||||
|     methods: { | ||||
|       onFavoriteClick() { | ||||
|         let statusId = this.get('statusId') | ||||
|         let favorited = this.get('favorited') | ||||
|         /* no await */ setFavorited(statusId, !favorited) | ||||
|       } | ||||
|     }, | ||||
|     computed: { | ||||
|       visibility: (status) => status.visibility, | ||||
|       boostLabel: (visibility) => { | ||||
|  | @ -70,7 +93,9 @@ | |||
|           return $currentStatusModifications.favorites[status.id] | ||||
|         } | ||||
|         return status.favourited | ||||
|       } | ||||
|       }, | ||||
|       statusId: (status) => status.id, | ||||
|       favoriteKey: (statusId, timelineType, timelineValue) => `fav-${timelineType}-${timelineValue}-${statusId}` | ||||
|     } | ||||
|   } | ||||
| </script> | ||||
|  | @ -390,13 +390,29 @@ export async function getNotificationIdsForStatus (instanceName, statusId) { | |||
| } | ||||
| 
 | ||||
| //
 | ||||
| // insert statuses
 | ||||
| // update statuses
 | ||||
| //
 | ||||
| 
 | ||||
| export async function insertStatus(instanceName, status) { | ||||
| async function updateStatus (instanceName, statusId, updateFunc) { | ||||
|   const db = await getDatabase(instanceName) | ||||
|   cacheStatus(statusesCache, status) | ||||
|   if (hasInCache(statusesCache, instanceName, statusId)) { | ||||
|     let status = getInCache(statusesCache, instanceName, statusId) | ||||
|     updateFunc(status) | ||||
|     cacheStatus(status, instanceName) | ||||
|   } | ||||
|   return dbPromise(db, STATUSES_STORE, 'readwrite', (statusesStore) => { | ||||
|     putStatus(statusesStore, status) | ||||
|     statusesStore.get(statusId).onsuccess = e => { | ||||
|       let status = e.target.result | ||||
|       updateFunc(status) | ||||
|       putStatus(statusesStore, status) | ||||
|     } | ||||
|   }) | ||||
| } | ||||
| } | ||||
| 
 | ||||
| export async function setStatusFavorited (instanceName, statusId, favorited) { | ||||
|   return updateStatus(instanceName, statusId, status => { | ||||
|     let delta = (favorited ? 1 : 0) - (status.favourited ? 1 : 0) | ||||
|     status.favourited = favorited | ||||
|     status.favourites_count = (status.favourites_count || 0) + delta | ||||
|   }) | ||||
| } | ||||
|  |  | |||
|  | @ -97,8 +97,8 @@ export function instanceComputations (store) { | |||
|   ) | ||||
| 
 | ||||
|   store.compute('currentStatusModifications', | ||||
|     ['statusModifications', 'instanceName'], | ||||
|     (statusModifications, instanceName) => { | ||||
|       return statusModifications[instanceName] | ||||
|     ['statusModifications', 'currentInstance'], | ||||
|     (statusModifications, currentInstance) => { | ||||
|       return statusModifications[currentInstance] | ||||
|     }) | ||||
| } | ||||
|  |  | |||
|  | @ -9,14 +9,21 @@ function fetchWithTimeout (url, options) { | |||
| 
 | ||||
| async function _post (url, body, headers, timeout) { | ||||
|   let fetchFunc = timeout ? fetchWithTimeout : fetch | ||||
|   return (await fetchFunc(url, { | ||||
|     method: 'POST', | ||||
|     headers: Object.assign(headers, { | ||||
|   let opts = { | ||||
|     method: 'POST' | ||||
|   } | ||||
|   if (body) { | ||||
|     opts.headers = Object.assign(headers, { | ||||
|       'Accept': 'application/json', | ||||
|       'Content-Type': 'application/json' | ||||
|     }), | ||||
|     body: JSON.stringify(body) | ||||
|   })).json() | ||||
|     }) | ||||
|     opts.body = JSON.stringify(body) | ||||
|   } else { | ||||
|     opts.headers = Object.assign(headers, { | ||||
|       'Accept': 'application/json' | ||||
|     }) | ||||
|   } | ||||
|   return (await fetchFunc(url, opts)).json() | ||||
| } | ||||
| 
 | ||||
| async function _get (url, headers, timeout) { | ||||
|  | @ -51,4 +58,4 @@ export function paramsString (paramsObject) { | |||
|     params.set(key, paramsObject[key]) | ||||
|   }) | ||||
|   return params.toString() | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ | |||
|     <form class="add-new-instance" on:submit='onSubmit(event)' aria-labelledby="add-an-instance-h1"> | ||||
| 
 | ||||
|       {{#if $logInToInstanceError && $logInToInstanceErrorForText === $instanceNameInSearch}} | ||||
|       <div class="form-error" role="alert"> | ||||
|       <div class="form-error form-error-user-error" role="alert"> | ||||
|         Error: {{$logInToInstanceError}} | ||||
|       </div> | ||||
|       {{/if}} | ||||
|  |  | |||
|  | @ -1,11 +1,9 @@ | |||
| import { Selector as $ } from 'testcafe' | ||||
| import { addInstanceButton, getUrl, instanceInput, settingsButton } from '../utils' | ||||
| import { addInstanceButton, formError, getUrl, instanceInput, settingsButton } from '../utils' | ||||
| 
 | ||||
| fixture`02-login-spec.js` | ||||
|   .page`http://localhost:4002` | ||||
| 
 | ||||
| const formError = $('.form-error') | ||||
| 
 | ||||
| function manualLogin (t, username, password) { | ||||
|   return t.click($('a').withText('log in to an instance')) | ||||
|     .expect(getUrl()).contains('/settings/instances/add') | ||||
|  |  | |||
|  | @ -1,21 +1,20 @@ | |||
| import { Selector as $ } from 'testcafe' | ||||
| import { getUrl, validateTimeline } from '../utils' | ||||
| import { getFirstVisibleStatus, getUrl, validateTimeline } from '../utils' | ||||
| import { homeTimeline, notifications, localTimeline, favorites } from '../fixtures' | ||||
| import { foobarRole } from '../roles' | ||||
| 
 | ||||
| fixture`03-basic-timeline-spec.js` | ||||
|   .page`http://localhost:4002` | ||||
| 
 | ||||
| const firstArticle = $('.virtual-list-item[aria-hidden=false] .status-article') | ||||
| 
 | ||||
| test('Shows the home timeline', async t => { | ||||
|   await t.useRole(foobarRole) | ||||
|     .expect(firstArticle.hasAttribute('aria-setsize')).ok() | ||||
|     .expect(firstArticle.getAttribute('aria-posinset')).eql('0') | ||||
|     .expect(getFirstVisibleStatus().exists).ok() | ||||
|     .expect(getFirstVisibleStatus().hasAttribute('aria-setsize')).ok() | ||||
|     .expect(getFirstVisibleStatus().getAttribute('aria-posinset')).eql('0') | ||||
| 
 | ||||
|   await validateTimeline(t, homeTimeline) | ||||
| 
 | ||||
|   await t.expect(firstArticle.getAttribute('aria-setsize')).eql('49') | ||||
|   await t.expect(getFirstVisibleStatus().getAttribute('aria-setsize')).eql('49') | ||||
| }) | ||||
| 
 | ||||
| test('Shows notifications', async t => { | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { Selector as $ } from 'testcafe' | ||||
| import { getNthStatus, getUrl } from '../utils' | ||||
| import { getFavoritesCount, getNthStatus, getReblogsCount, getUrl } from '../utils' | ||||
| import { foobarRole } from '../roles' | ||||
| 
 | ||||
| fixture`11-reblog-favorites-count.js` | ||||
|  | @ -9,7 +9,7 @@ test('shows favorites', async t => { | |||
|   await t.useRole(foobarRole) | ||||
|     .click(getNthStatus(0)) | ||||
|     .expect(getUrl()).contains('/statuses/99549266679020981') | ||||
|     .expect($('.status-favs-reblogs').nth(0).getAttribute('aria-label')).eql('Favorited 2 times') | ||||
|     .expect(getFavoritesCount()).eql(2) | ||||
|     .expect($('.icon-button[aria-label="Favorite"]').getAttribute('aria-pressed')).eql('true') | ||||
|     .click($('.status-favs-reblogs').nth(1)) | ||||
|     .expect(getUrl()).contains('/statuses/99549266679020981/favorites') | ||||
|  | @ -23,7 +23,7 @@ test('shows boosts', async t => { | |||
|   await t.useRole(foobarRole) | ||||
|     .click(getNthStatus(0)) | ||||
|     .expect(getUrl()).contains('/statuses/99549266679020981') | ||||
|     .expect($('.status-favs-reblogs').nth(1).getAttribute('aria-label')).eql('Boosted 1 time') | ||||
|     .expect(getReblogsCount()).eql(1) | ||||
|     .expect($('.icon-button[aria-label="Boost"]').getAttribute('aria-pressed')).eql('false') | ||||
|     .click($('.status-favs-reblogs').nth(0)) | ||||
|     .expect(getUrl()).contains('/statuses/99549266679020981/reblogs') | ||||
|  |  | |||
							
								
								
									
										75
									
								
								tests/spec/12-favorite-unfavorite.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								tests/spec/12-favorite-unfavorite.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,75 @@ | |||
| import { | ||||
|   getFavoritesCount, | ||||
|   getNthFavoriteButton, getNthFavorited, getNthStatus, getUrl, homeNavButton, notificationsNavButton, | ||||
|   scrollToBottomOfTimeline, scrollToTopOfTimeline | ||||
| } from '../utils' | ||||
| import { foobarRole } from '../roles' | ||||
| 
 | ||||
| fixture`12-favorite-unfavorite.js` | ||||
|   .page`http://localhost:4002` | ||||
| 
 | ||||
| test('favorites a status', async t => { | ||||
|   await t.useRole(foobarRole) | ||||
|     .hover(getNthStatus(4)) | ||||
|     .expect(getNthFavorited(4)).eql('false') | ||||
|     .click(getNthFavoriteButton(4)) | ||||
|     .expect(getNthFavorited(4)).eql('true') | ||||
| 
 | ||||
|   // scroll down and back up to force an unrender
 | ||||
|   await scrollToBottomOfTimeline(t) | ||||
|   await scrollToTopOfTimeline(t) | ||||
|   await t | ||||
|     .hover(getNthStatus(4)) | ||||
|     .expect(getNthFavorited(4)).eql('true') | ||||
|     .click(notificationsNavButton) | ||||
|     .click(homeNavButton) | ||||
|     .expect(getNthFavorited(4)).eql('true') | ||||
|     .click(notificationsNavButton) | ||||
|     .expect(getUrl()).contains('/notifications') | ||||
|     .click(homeNavButton) | ||||
|     .expect(getUrl()).eql('http://localhost:4002/') | ||||
|     .expect(getNthFavorited(4)).eql('true') | ||||
|     .click(getNthFavoriteButton(4)) | ||||
|     .expect(getNthFavorited(4)).eql('false') | ||||
| }) | ||||
| 
 | ||||
| test('unfavorites a status', async t => { | ||||
|   await t.useRole(foobarRole) | ||||
|     .expect(getNthFavorited(1)).eql('true') | ||||
|     .click(getNthFavoriteButton(1)) | ||||
|     .expect(getNthFavorited(1)).eql('false') | ||||
| 
 | ||||
|   // scroll down and back up to force an unrender
 | ||||
|   await scrollToBottomOfTimeline(t) | ||||
|   await scrollToTopOfTimeline(t) | ||||
|   await t | ||||
|     .expect(getNthFavorited(1)).eql('false') | ||||
|     .click(notificationsNavButton) | ||||
|     .click(homeNavButton) | ||||
|     .expect(getNthFavorited(1)).eql('false') | ||||
|     .click(notificationsNavButton) | ||||
|     .navigateTo('/') | ||||
|     .expect(getNthFavorited(1)).eql('false') | ||||
|     .click(getNthFavoriteButton(1)) | ||||
|     .expect(getNthFavorited(1)).eql('true') | ||||
| }) | ||||
| 
 | ||||
| test('Keeps the correct count', async t => { | ||||
|   await t.useRole(foobarRole) | ||||
|     .hover(getNthStatus(4)) | ||||
|     .click(getNthFavoriteButton(4)) | ||||
|     .expect(getNthFavorited(4)).eql('true') | ||||
|     .click(getNthStatus(4)) | ||||
|     .expect(getUrl()).contains('/status') | ||||
|     .expect(getNthFavorited(0)).eql('true') | ||||
|     .expect(getFavoritesCount()).eql(2) | ||||
|     .click(homeNavButton) | ||||
|     .expect(getUrl()).eql('http://localhost:4002/') | ||||
|     .hover(getNthStatus(4)) | ||||
|     .click(getNthFavoriteButton(4)) | ||||
|     .expect(getNthFavorited(4)).eql('false') | ||||
|     .click(getNthStatus(4)) | ||||
|     .expect(getUrl()).contains('/status') | ||||
|     .expect(getNthFavorited(0)).eql('false') | ||||
|     .expect(getFavoritesCount()).eql(1) | ||||
| }) | ||||
|  | @ -1,10 +1,23 @@ | |||
| import { ClientFunction as exec, Selector as $ } from 'testcafe' | ||||
| 
 | ||||
| const SCROLL_INTERVAL = 3 | ||||
| 
 | ||||
| export const settingsButton = $('nav a[aria-label=Settings]') | ||||
| export const instanceInput = $('#instanceInput') | ||||
| export const addInstanceButton = $('.add-new-instance button') | ||||
| export const modalDialogContents = $('.modal-dialog-contents') | ||||
| export const closeDialogButton = $('.close-dialog-button') | ||||
| export const notificationsNavButton = $('nav a[href="/notifications"]') | ||||
| export const homeNavButton = $('nav a[href="/"]') | ||||
| export const formError = $('.form-error-user-error') | ||||
| 
 | ||||
| export const favoritesCountElement = $('.status-favs-reblogs:nth-child(3)').addCustomDOMProperties({ | ||||
|   innerCount: el => parseInt(el.innerText, 10) | ||||
| }) | ||||
| 
 | ||||
| export const reblogsCountElement = $('.status-favs-reblogs:nth-child(2)').addCustomDOMProperties({ | ||||
|   innerCount: el => parseInt(el.innerText, 10) | ||||
| }) | ||||
| 
 | ||||
| export const getUrl = exec(() => window.location.href) | ||||
| 
 | ||||
|  | @ -15,11 +28,31 @@ export const getActiveElementClass = exec(() => | |||
| export const goBack = exec(() => window.history.back()) | ||||
| 
 | ||||
| export function getNthStatus (n) { | ||||
|   return $(`[aria-hidden="false"] > article[aria-posinset="${n}"]`) | ||||
|   return $(`div[aria-hidden="false"] > article[aria-posinset="${n}"]`) | ||||
| } | ||||
| 
 | ||||
| export function getLastVisibleStatus () { | ||||
|   return $(`[aria-hidden="false"] > article[aria-posinset]`).nth(-1) | ||||
|   return $(`div[aria-hidden="false"] > article[aria-posinset]`).nth(-1) | ||||
| } | ||||
| 
 | ||||
| export function getFirstVisibleStatus () { | ||||
|   return $(`div[aria-hidden="false"] > article[aria-posinset]`).nth(0) | ||||
| } | ||||
| 
 | ||||
| export function getNthFavoriteButton (n) { | ||||
|   return getNthStatus(n).find('.status-toolbar button:nth-child(3)') | ||||
| } | ||||
| 
 | ||||
| export function getNthFavorited (n) { | ||||
|   return getNthFavoriteButton(n).getAttribute('aria-pressed') | ||||
| } | ||||
| 
 | ||||
| export function getFavoritesCount () { | ||||
|   return favoritesCountElement.innerCount | ||||
| } | ||||
| 
 | ||||
| export function getReblogsCount () { | ||||
|   return reblogsCountElement.innerCount | ||||
| } | ||||
| 
 | ||||
| export async function validateTimeline (t, timeline) { | ||||
|  | @ -47,23 +80,47 @@ export async function validateTimeline (t, timeline) { | |||
|     } | ||||
| 
 | ||||
|     // hovering forces TestCafé to scroll to that element: https://git.io/vABV2
 | ||||
|     if (i % 3 === 2) { // only scroll every nth element
 | ||||
|     if (i % SCROLL_INTERVAL === (SCROLL_INTERVAL - 1)) { // only scroll every nth element
 | ||||
|       await t.hover(getNthStatus(i)) | ||||
|         .expect($('.loading-footer').exist).notOk() | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export async function scrollToBottomOfTimeline (t) { | ||||
|   let lastSize = null | ||||
| export async function scrollTimelineUp (t) { | ||||
|   let oldFirstItem = await getFirstVisibleStatus().getAttribute('aria-posinset') | ||||
|   await t.hover(getFirstVisibleStatus()) | ||||
|   let newFirstItem | ||||
|   while (true) { | ||||
|     await t.hover(getLastVisibleStatus()) | ||||
|       .expect($('.loading-footer').exist).notOk() | ||||
|     let newSize = await getLastVisibleStatus().getAttribute('aria-setsize') | ||||
|     if (newSize === lastSize) { | ||||
|     newFirstItem = await getFirstVisibleStatus().getAttribute('aria-posinset') | ||||
|     if (newFirstItem === '0' || newFirstItem !== oldFirstItem) { | ||||
|       break | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export async function scrollToTopOfTimeline (t) { | ||||
|   let i = await getFirstVisibleStatus().getAttribute('aria-posinset') | ||||
|   while (true) { | ||||
|     await t.hover(getNthStatus(i)) | ||||
|       .expect($('.loading-footer').exist).notOk() | ||||
|     i -= SCROLL_INTERVAL | ||||
|     if (i <= 0) { | ||||
|       break | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export async function scrollToBottomOfTimeline (t) { | ||||
|   let i = 0 | ||||
|   while (true) { | ||||
|     await t.hover(getNthStatus(i)) | ||||
|       .expect($('.loading-footer').exist).notOk() | ||||
|     let size = await getNthStatus(i).getAttribute('aria-setsize') | ||||
|     i += SCROLL_INTERVAL | ||||
|     if (i >= size - 1) { | ||||
|       break | ||||
|     } | ||||
|     lastSize = newSize | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue