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
templates/.*
assets/*.css
/mastodon
/mastodon
mastodon.log

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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