add favorite/unfavorite feature

This commit is contained in:
Nolan Lawson 2018-02-24 14:49:28 -08:00
parent 3a17f7ff7b
commit 1b7a01f1ee
24 changed files with 291 additions and 108 deletions

3
.gitignore vendored
View File

@ -4,4 +4,5 @@ node_modules
yarn.lock yarn.lock
templates/.* templates/.*
assets/*.css assets/*.css
/mastodon /mastodon
mastodon.log

View File

@ -60,13 +60,10 @@ async function runMastodon () {
await exec(cmd, {cwd: mastodonDir}) await exec(cmd, {cwd: mastodonDir})
} }
const promise = spawn('foreman', ['start'], {cwd: mastodonDir}) const promise = spawn('foreman', ['start'], {cwd: mastodonDir})
const log = fs.createWriteStream('mastodon.log', {flags: 'a'})
childProc = promise.childProcess childProc = promise.childProcess
childProc.stdout.on('data', function (data) { childProc.stdout.pipe(log)
console.log(data.toString('utf8').replace(/\n$/, '')) childProc.stderr.pipe(log)
})
childProc.stderr.on('data', function (data) {
console.error(data.toString('utf8').replace(/\n$/, ''))
})
await waitForMastodonToStart() await waitForMastodonToStart()
} }

View File

@ -3,20 +3,28 @@ import { store } from '../_store/store'
import { database } from '../_database/database' import { database } from '../_database/database'
import { toast } from '../_utils/toast' 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 instanceName = store.get('currentInstance')
let accessToken = store.get('accessToken') let accessToken = store.get('accessToken')
try { try {
let status = await (favorited let result = await (favorited
? favoriteStatus(instanceName, accessToken, statusId) ? favoriteStatus(instanceName, accessToken, statusId)
: unfavoriteStatus(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 statusModifications = store.get('statusModifications')
let currentStatusModifications = statusModifications[instanceName] = let currentStatusModifications = statusModifications[instanceName] =
(statusModifications[instanceName] || {favorites: {}, reblogs: {}}) (statusModifications[instanceName] || {favorites: {}, reblogs: {}})
currentStatusModifications.favorites[statusId] = favorited currentStatusModifications.favorites[statusId] = favorited
store.set({statusModifications: statusModifications}) store.set({statusModifications: statusModifications})
} catch (e) { } catch (e) {
toast.say('Failed to favorite/unfavorite. Please try again.') console.error(e)
toast.say('Failed to favorite or unfavorite. ' + (e.message || ''))
} }
} }

View File

@ -1,18 +1,14 @@
import { getWithTimeout, paramsString } from '../_utils/ajax' import { getWithTimeout, paramsString } from '../_utils/ajax'
import { basename } from './utils' import { auth, basename } from './utils'
export async function getBlockedAccounts (instanceName, accessToken, limit = 80) { export async function getBlockedAccounts (instanceName, accessToken, limit = 80) {
let url = `${basename(instanceName)}/api/v1/blocks` let url = `${basename(instanceName)}/api/v1/blocks`
url += '?' + paramsString({ limit }) url += '?' + paramsString({ limit })
return getWithTimeout(url, { return getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }
export async function getMutedAccounts (instanceName, accessToken, limit = 80) { export async function getMutedAccounts (instanceName, accessToken, limit = 80) {
let url = `${basename(instanceName)}/api/v1/mutes` let url = `${basename(instanceName)}/api/v1/mutes`
url += '?' + paramsString({ limit }) url += '?' + paramsString({ limit })
return getWithTimeout(url, { return getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }

View File

@ -1,16 +1,12 @@
import { post } from '../_utils/ajax' 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` let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/favourite`
return post(url, null, { return post(url, null, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }
export async function unfavoriteStatus(instanceName, accessToken, statusId) { export async function unfavoriteStatus (instanceName, accessToken, statusId) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unfavourite` let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/unfavourite`
return post(url, null, { return post(url, null, auth(accessToken))
'Authorization': `Bearer ${accessToken}` }
})
}

View File

@ -1,9 +1,7 @@
import { getWithTimeout } from '../_utils/ajax' import { getWithTimeout } from '../_utils/ajax'
import { basename } from './utils' import { auth, basename } from './utils'
export function getLists (instanceName, accessToken) { export function getLists (instanceName, accessToken) {
let url = `${basename(instanceName)}/api/v1/lists` let url = `${basename(instanceName)}/api/v1/lists`
return getWithTimeout(url, { return getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }

View File

@ -1,5 +1,5 @@
import { getWithTimeout, paramsString } from '../_utils/ajax' import { getWithTimeout, paramsString } from '../_utils/ajax'
import { basename } from './utils' import { auth, basename } from './utils'
export async function getPinnedStatuses (instanceName, accessToken, accountId) { export async function getPinnedStatuses (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/statuses` let url = `${basename(instanceName)}/api/v1/accounts/${accountId}/statuses`
@ -7,7 +7,5 @@ export async function getPinnedStatuses (instanceName, accessToken, accountId) {
limit: 40, limit: 40,
pinned: true pinned: true
}) })
return getWithTimeout(url, { return getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }

View File

@ -1,18 +1,14 @@
import { getWithTimeout, paramsString } from '../_utils/ajax' import { getWithTimeout, paramsString } from '../_utils/ajax'
import { basename } from './utils' import { auth, basename } from './utils'
export async function getReblogs (instanceName, accessToken, statusId, limit = 80) { export async function getReblogs (instanceName, accessToken, statusId, limit = 80) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/reblogged_by` let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/reblogged_by`
url += '?' + paramsString({ limit }) url += '?' + paramsString({ limit })
return getWithTimeout(url, { return getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }
export async function getFavorites (instanceName, accessToken, statusId, limit = 80) { export async function getFavorites (instanceName, accessToken, statusId, limit = 80) {
let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/favourited_by` let url = `${basename(instanceName)}/api/v1/statuses/${statusId}/favourited_by`
url += '?' + paramsString({ limit }) url += '?' + paramsString({ limit })
return getWithTimeout(url, { return getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }

View File

@ -1,12 +1,10 @@
import { getWithTimeout, paramsString } from '../_utils/ajax' import { getWithTimeout, paramsString } from '../_utils/ajax'
import { basename } from './utils' import { auth, basename } from './utils'
export function search (instanceName, accessToken, query) { export function search (instanceName, accessToken, query) {
let url = `${basename(instanceName)}/api/v1/search?` + paramsString({ let url = `${basename(instanceName)}/api/v1/search?` + paramsString({
q: query, q: query,
resolve: true resolve: true
}) })
return getWithTimeout(url, { return getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }

View File

@ -1,5 +1,5 @@
import { getWithTimeout, paramsString } from '../_utils/ajax' import { getWithTimeout, paramsString } from '../_utils/ajax'
import { basename } from './utils' import { auth, basename } from './utils'
function getTimelineUrlPath (timeline) { function getTimelineUrlPath (timeline) {
switch (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 // special case - this is a list of descendents and ancestors
let statusUrl = `${basename(instanceName)}/api/v1/statuses/${timeline.split('/').slice(-1)[0]}}` let statusUrl = `${basename(instanceName)}/api/v1/statuses/${timeline.split('/').slice(-1)[0]}}`
return Promise.all([ return Promise.all([
getWithTimeout(url, {'Authorization': `Bearer ${accessToken}`}), getWithTimeout(url, auth(accessToken)),
getWithTimeout(statusUrl, {'Authorization': `Bearer ${accessToken}`}) getWithTimeout(statusUrl, auth(accessToken))
]).then(res => { ]).then(res => {
return [].concat(res[0].ancestors).concat([res[1]]).concat(res[0].descendants) return [].concat(res[0].ancestors).concat([res[1]]).concat(res[0].descendants)
}) })
} }
return getWithTimeout(url, { return getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }

View File

@ -1,25 +1,19 @@
import { getWithTimeout, paramsString } from '../_utils/ajax' import { getWithTimeout, paramsString } from '../_utils/ajax'
import { basename } from './utils' import { auth, basename } from './utils'
export function getVerifyCredentials (instanceName, accessToken) { export function getVerifyCredentials (instanceName, accessToken) {
let url = `${basename(instanceName)}/api/v1/accounts/verify_credentials` let url = `${basename(instanceName)}/api/v1/accounts/verify_credentials`
return getWithTimeout(url, { return getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }
export function getAccount (instanceName, accessToken, accountId) { export function getAccount (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/${accountId}` let url = `${basename(instanceName)}/api/v1/accounts/${accountId}`
return getWithTimeout(url, { return getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
} }
export async function getRelationship (instanceName, accessToken, accountId) { export async function getRelationship (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/accounts/relationships` let url = `${basename(instanceName)}/api/v1/accounts/relationships`
url += '?' + paramsString({id: accountId}) url += '?' + paramsString({id: accountId})
let res = await getWithTimeout(url, { let res = await getWithTimeout(url, auth(accessToken))
'Authorization': `Bearer ${accessToken}`
})
return res[0] return res[0]
} }

View File

@ -13,3 +13,9 @@ export function basename (instanceName) {
} }
return `https://${instanceName}` return `https://${instanceName}`
} }
export function auth (accessToken) {
return {
'Authorization': `Bearer ${accessToken}`
}
}

View File

@ -2,8 +2,10 @@
<button type="button" <button type="button"
aria-label="{{label}}" aria-label="{{label}}"
aria-pressed="{{!!pressed}}" aria-pressed="{{!!pressed}}"
class="icon-button {{pressed ? 'pressed' : ''}} {{big ? 'big-icon' : ''}}" class="{{computedClass}}"
disabled="{{disabled}}" disabled="{{disabled}}"
delegate-click-key="{{delegateKey}}"
delegate-keydown-key="{{delegateKey}}"
on:click on:click
> >
<svg> <svg>
@ -13,8 +15,10 @@
{{else}} {{else}}
<button type="button" <button type="button"
aria-label="{{label}}" aria-label="{{label}}"
class="icon-button {{big ? 'big-icon' : ''}}" class="{{computedClass}}"
disabled="{{disabled}}" disabled="{{disabled}}"
delegate-click-key="{{delegateKey}}"
delegate-keydown-key="{{delegateKey}}"
on:click on:click
> >
<svg> <svg>
@ -44,7 +48,7 @@
fill: var(--action-button-fill-color-hover); 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); fill: var(--action-button-fill-color-active);
} }
@ -59,4 +63,20 @@
button.icon-button.pressed:active svg { button.icon-button.pressed:active svg {
fill: var(--action-button-fill-color-pressed-active); 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>

View File

@ -28,7 +28,7 @@
{{#if isStatusInOwnThread}} {{#if isStatusInOwnThread}}
<StatusDetails status="{{originalStatus}}" /> <StatusDetails status="{{originalStatus}}" />
{{/if}} {{/if}}
<StatusToolbar :status :isStatusInOwnThread /> <StatusToolbar status="{{originalStatus}}" :isStatusInOwnThread :timelineType :timelineValue />
</article> </article>
<style> <style>

View File

@ -15,6 +15,8 @@
pressable="true" pressable="true"
pressed="{{favorited}}" pressed="{{favorited}}"
href="#fa-star" href="#fa-star"
delegateKey="{{favoriteKey}}"
ref:favoriteNode
/> />
<IconButton <IconButton
label="Show more actions" label="Show more actions"
@ -34,12 +36,33 @@
<script> <script>
import IconButton from '../IconButton.html' import IconButton from '../IconButton.html'
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { registerDelegate, unregisterDelegate } from '../../_utils/delegate'
import { setFavorited } from '../../_actions/favorite'
export default { 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: { components: {
IconButton IconButton
}, },
store: () => store, store: () => store,
methods: {
onFavoriteClick() {
let statusId = this.get('statusId')
let favorited = this.get('favorited')
/* no await */ setFavorited(statusId, !favorited)
}
},
computed: { computed: {
visibility: (status) => status.visibility, visibility: (status) => status.visibility,
boostLabel: (visibility) => { boostLabel: (visibility) => {
@ -70,7 +93,9 @@
return $currentStatusModifications.favorites[status.id] return $currentStatusModifications.favorites[status.id]
} }
return status.favourited return status.favourited
} },
statusId: (status) => status.id,
favoriteKey: (statusId, timelineType, timelineValue) => `fav-${timelineType}-${timelineValue}-${statusId}`
} }
} }
</script> </script>

View File

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

View File

@ -97,8 +97,8 @@ export function instanceComputations (store) {
) )
store.compute('currentStatusModifications', store.compute('currentStatusModifications',
['statusModifications', 'instanceName'], ['statusModifications', 'currentInstance'],
(statusModifications, instanceName) => { (statusModifications, currentInstance) => {
return statusModifications[instanceName] return statusModifications[currentInstance]
}) })
} }

View File

@ -9,14 +9,21 @@ function fetchWithTimeout (url, options) {
async function _post (url, body, headers, timeout) { async function _post (url, body, headers, timeout) {
let fetchFunc = timeout ? fetchWithTimeout : fetch let fetchFunc = timeout ? fetchWithTimeout : fetch
return (await fetchFunc(url, { let opts = {
method: 'POST', method: 'POST'
headers: Object.assign(headers, { }
if (body) {
opts.headers = Object.assign(headers, {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}), })
body: JSON.stringify(body) opts.body = JSON.stringify(body)
})).json() } else {
opts.headers = Object.assign(headers, {
'Accept': 'application/json'
})
}
return (await fetchFunc(url, opts)).json()
} }
async function _get (url, headers, timeout) { async function _get (url, headers, timeout) {
@ -51,4 +58,4 @@ export function paramsString (paramsObject) {
params.set(key, paramsObject[key]) params.set(key, paramsObject[key])
}) })
return params.toString() return params.toString()
} }

View File

@ -15,7 +15,7 @@
<form class="add-new-instance" on:submit='onSubmit(event)' aria-labelledby="add-an-instance-h1"> <form class="add-new-instance" on:submit='onSubmit(event)' aria-labelledby="add-an-instance-h1">
{{#if $logInToInstanceError && $logInToInstanceErrorForText === $instanceNameInSearch}} {{#if $logInToInstanceError && $logInToInstanceErrorForText === $instanceNameInSearch}}
<div class="form-error" role="alert"> <div class="form-error form-error-user-error" role="alert">
Error: {{$logInToInstanceError}} Error: {{$logInToInstanceError}}
</div> </div>
{{/if}} {{/if}}

View File

@ -1,11 +1,9 @@
import { Selector as $ } from 'testcafe' 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` fixture`02-login-spec.js`
.page`http://localhost:4002` .page`http://localhost:4002`
const formError = $('.form-error')
function manualLogin (t, username, password) { function manualLogin (t, username, password) {
return t.click($('a').withText('log in to an instance')) return t.click($('a').withText('log in to an instance'))
.expect(getUrl()).contains('/settings/instances/add') .expect(getUrl()).contains('/settings/instances/add')

View File

@ -1,21 +1,20 @@
import { Selector as $ } from 'testcafe' import { Selector as $ } from 'testcafe'
import { getUrl, validateTimeline } from '../utils' import { getFirstVisibleStatus, getUrl, validateTimeline } from '../utils'
import { homeTimeline, notifications, localTimeline, favorites } from '../fixtures' import { homeTimeline, notifications, localTimeline, favorites } from '../fixtures'
import { foobarRole } from '../roles' import { foobarRole } from '../roles'
fixture`03-basic-timeline-spec.js` fixture`03-basic-timeline-spec.js`
.page`http://localhost:4002` .page`http://localhost:4002`
const firstArticle = $('.virtual-list-item[aria-hidden=false] .status-article')
test('Shows the home timeline', async t => { test('Shows the home timeline', async t => {
await t.useRole(foobarRole) await t.useRole(foobarRole)
.expect(firstArticle.hasAttribute('aria-setsize')).ok() .expect(getFirstVisibleStatus().exists).ok()
.expect(firstArticle.getAttribute('aria-posinset')).eql('0') .expect(getFirstVisibleStatus().hasAttribute('aria-setsize')).ok()
.expect(getFirstVisibleStatus().getAttribute('aria-posinset')).eql('0')
await validateTimeline(t, homeTimeline) 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 => { test('Shows notifications', async t => {

View File

@ -1,5 +1,5 @@
import { Selector as $ } from 'testcafe' import { Selector as $ } from 'testcafe'
import { getNthStatus, getUrl } from '../utils' import { getFavoritesCount, getNthStatus, getReblogsCount, getUrl } from '../utils'
import { foobarRole } from '../roles' import { foobarRole } from '../roles'
fixture`11-reblog-favorites-count.js` fixture`11-reblog-favorites-count.js`
@ -9,7 +9,7 @@ test('shows favorites', async t => {
await t.useRole(foobarRole) await t.useRole(foobarRole)
.click(getNthStatus(0)) .click(getNthStatus(0))
.expect(getUrl()).contains('/statuses/99549266679020981') .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') .expect($('.icon-button[aria-label="Favorite"]').getAttribute('aria-pressed')).eql('true')
.click($('.status-favs-reblogs').nth(1)) .click($('.status-favs-reblogs').nth(1))
.expect(getUrl()).contains('/statuses/99549266679020981/favorites') .expect(getUrl()).contains('/statuses/99549266679020981/favorites')
@ -23,7 +23,7 @@ test('shows boosts', async t => {
await t.useRole(foobarRole) await t.useRole(foobarRole)
.click(getNthStatus(0)) .click(getNthStatus(0))
.expect(getUrl()).contains('/statuses/99549266679020981') .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') .expect($('.icon-button[aria-label="Boost"]').getAttribute('aria-pressed')).eql('false')
.click($('.status-favs-reblogs').nth(0)) .click($('.status-favs-reblogs').nth(0))
.expect(getUrl()).contains('/statuses/99549266679020981/reblogs') .expect(getUrl()).contains('/statuses/99549266679020981/reblogs')

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

View File

@ -1,10 +1,23 @@
import { ClientFunction as exec, Selector as $ } from 'testcafe' import { ClientFunction as exec, Selector as $ } from 'testcafe'
const SCROLL_INTERVAL = 3
export const settingsButton = $('nav a[aria-label=Settings]') export const settingsButton = $('nav a[aria-label=Settings]')
export const instanceInput = $('#instanceInput') export const instanceInput = $('#instanceInput')
export const addInstanceButton = $('.add-new-instance button') export const addInstanceButton = $('.add-new-instance button')
export const modalDialogContents = $('.modal-dialog-contents') export const modalDialogContents = $('.modal-dialog-contents')
export const closeDialogButton = $('.close-dialog-button') 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) export const getUrl = exec(() => window.location.href)
@ -15,11 +28,31 @@ export const getActiveElementClass = exec(() =>
export const goBack = exec(() => window.history.back()) export const goBack = exec(() => window.history.back())
export function getNthStatus (n) { export function getNthStatus (n) {
return $(`[aria-hidden="false"] > article[aria-posinset="${n}"]`) return $(`div[aria-hidden="false"] > article[aria-posinset="${n}"]`)
} }
export function getLastVisibleStatus () { 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) { 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 // 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)) await t.hover(getNthStatus(i))
.expect($('.loading-footer').exist).notOk() .expect($('.loading-footer').exist).notOk()
} }
} }
} }
export async function scrollToBottomOfTimeline (t) { export async function scrollTimelineUp (t) {
let lastSize = null let oldFirstItem = await getFirstVisibleStatus().getAttribute('aria-posinset')
await t.hover(getFirstVisibleStatus())
let newFirstItem
while (true) { while (true) {
await t.hover(getLastVisibleStatus()) newFirstItem = await getFirstVisibleStatus().getAttribute('aria-posinset')
.expect($('.loading-footer').exist).notOk() if (newFirstItem === '0' || newFirstItem !== oldFirstItem) {
let newSize = await getLastVisibleStatus().getAttribute('aria-setsize') break
if (newSize === lastSize) { }
}
}
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 break
} }
lastSize = newSize
} }
} }