feat: show unread follow requests on community page (#1493)

* feat: show unread follow requests on community page

fixes #477

* fixup

* fixup
This commit is contained in:
Nolan Lawson 2019-09-16 22:36:24 -07:00 committed by GitHub
parent 3496d7e4ea
commit d3fb67bec3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 153 additions and 33 deletions

View file

@ -1,17 +1,25 @@
import { DEFAULT_TIMEOUT, get, post, WRITE_TIMEOUT } from '../_utils/ajax' import { store } from '../_store/store'
import { auth, basename } from '../_api/utils' import { cacheFirstUpdateAfter } from '../_utils/sync'
import { database } from '../_database/database'
import { getFollowRequests } from '../_api/followRequests'
export async function getFollowRequests (instanceName, accessToken) { export async function updateFollowRequestCountIfLockedAccount (instanceName) {
const url = `${basename(instanceName)}/api/v1/follow_requests` const { verifyCredentials, loggedInInstances } = store.get()
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
if (!verifyCredentials[instanceName].locked) {
return
} }
export async function authorizeFollowRequest (instanceName, accessToken, id) { const accessToken = loggedInInstances[instanceName].access_token
const url = `${basename(instanceName)}/api/v1/follow_requests/${id}/authorize`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
}
export async function rejectFollowRequest (instanceName, accessToken, id) { await cacheFirstUpdateAfter(
const url = `${basename(instanceName)}/api/v1/follow_requests/${id}/reject` async () => (await getFollowRequests(instanceName, accessToken)).length,
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT }) () => database.getFollowRequestCount(instanceName),
followReqsCount => database.setFollowRequestCount(instanceName, followReqsCount),
followReqsCount => {
const { followRequestCounts } = store.get()
followRequestCounts[instanceName] = followReqsCount
store.set({ followRequestCounts })
}
)
} }

View file

@ -0,0 +1,17 @@
import { DEFAULT_TIMEOUT, get, post, WRITE_TIMEOUT } from '../_utils/ajax'
import { auth, basename } from './utils'
export async function getFollowRequests (instanceName, accessToken) {
const url = `${basename(instanceName)}/api/v1/follow_requests`
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
}
export async function authorizeFollowRequest (instanceName, accessToken, id) {
const url = `${basename(instanceName)}/api/v1/follow_requests/${id}/authorize`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
}
export async function rejectFollowRequest (instanceName, accessToken, id) {
const url = `${basename(instanceName)}/api/v1/follow_requests/${id}/reject`
return post(url, null, auth(accessToken), { timeout: WRITE_TIMEOUT })
}

View file

@ -148,18 +148,24 @@
// special case these should both highlight the notifications tab icon // special case these should both highlight the notifications tab icon
(name === 'notifications' && page === 'notifications/mentions') (name === 'notifications' && page === 'notifications/mentions')
}, },
ariaLabel: ({ selected, name, label, $numberOfNotifications }) => { ariaLabel: ({ selected, name, label, $numberOfNotifications, $numberOfFollowRequests }) => {
let res = label let res = label
if (selected) { if (selected) {
res += ' (current page)' res += ' (current page)'
} }
if (name === 'notifications' && $numberOfNotifications) { if (name === 'notifications' && $numberOfNotifications) {
res += ` (${$numberOfNotifications} notification${$numberOfNotifications === 1 ? '' : 's'})` res += ` (${$numberOfNotifications} notification${$numberOfNotifications === 1 ? '' : 's'})`
} else if (name === 'community' && $numberOfFollowRequests) {
res += ` (${$numberOfFollowRequests} follow request${$numberOfFollowRequests === 1 ? '' : 's'})`
} }
return res return res
}, },
showBadge: ({ name, $hasNotifications }) => name === 'notifications' && $hasNotifications, showBadge: ({ name, $hasNotifications, $hasFollowRequests }) => (
badgeNumber: ({ name, $numberOfNotifications }) => name === 'notifications' && $numberOfNotifications (name === 'notifications' && $hasNotifications) || (name === 'community' && $hasFollowRequests)
),
badgeNumber: ({ name, $numberOfNotifications, $numberOfFollowRequests }) => (
(name === 'notifications' && $numberOfNotifications) || (name === 'community' && $numberOfFollowRequests)
)
}, },
methods: { methods: {
onClick (e) { onClick (e) {

View file

@ -55,3 +55,11 @@ export async function getCustomEmoji (instanceName) {
export async function setCustomEmoji (instanceName, value) { export async function setCustomEmoji (instanceName, value) {
return setMetaProperty(instanceName, 'customEmoji', value) return setMetaProperty(instanceName, 'customEmoji', value)
} }
export async function getFollowRequestCount (instanceName) {
return getMetaProperty(instanceName, 'followRequestCount')
}
export async function setFollowRequestCount (instanceName, value) {
return setMetaProperty(instanceName, 'followRequestCount', value)
}

View file

@ -53,7 +53,7 @@
<PageList label="Instance settings"> <PageList label="Instance settings">
{#if isLockedAccount} {#if isLockedAccount}
<PageListItem href="/requests" <PageListItem href="/requests"
label="Follow requests" label={followRequestsLabel}
icon="#fa-user-plus" icon="#fa-user-plus"
/> />
{/if} {/if}
@ -109,12 +109,16 @@
import PageList from '../../_components/community/PageList.html' import PageList from '../../_components/community/PageList.html'
import PageListItem from '../../_components/community/PageListItem.html' import PageListItem from '../../_components/community/PageListItem.html'
import { updateListsForInstance } from '../../_actions/lists' import { updateListsForInstance } from '../../_actions/lists'
import { updateFollowRequestCountIfLockedAccount } from '../../_actions/followRequests'
export default { export default {
async oncreate () { async oncreate () {
const { currentInstance } = this.store.get() const { currentInstance } = this.store.get()
if (currentInstance) { if (currentInstance) {
await updateListsForInstance(currentInstance) await Promise.all([
updateListsForInstance(currentInstance),
updateFollowRequestCountIfLockedAccount(currentInstance)
])
} }
}, },
store: () => store, store: () => store,
@ -125,7 +129,10 @@
PageListItem PageListItem
}, },
computed: { computed: {
isLockedAccount: ({ $currentVerifyCredentials }) => $currentVerifyCredentials && $currentVerifyCredentials.locked isLockedAccount: ({ $currentVerifyCredentials }) => $currentVerifyCredentials && $currentVerifyCredentials.locked,
followRequestsLabel: ({ $hasFollowRequests, $numberOfFollowRequests }) => (
`Follow requests${$hasFollowRequests ? ` (${$numberOfFollowRequests})` : ''}`
)
} }
} }
</script> </script>

View file

@ -5,9 +5,18 @@
<script> <script>
import AccountsListPage from '../_components/AccountsListPage.html' import AccountsListPage from '../_components/AccountsListPage.html'
import { store } from '../_store/store' import { store } from '../_store/store'
import { getFollowRequests } from '../_actions/followRequests' import { getFollowRequests } from '../_api/followRequests'
import DynamicPageBanner from '../_components/DynamicPageBanner.html' import DynamicPageBanner from '../_components/DynamicPageBanner.html'
import { setFollowRequestApprovedOrRejected } from '../_actions/requests' import { setFollowRequestApprovedOrRejected } from '../_actions/requests'
import { database } from '../_database/database'
// sneakily update the follow reqs count in the cache, since we just fetched it
function updateFollowReqsCount ($currentInstance, followReqs) {
/* no await */ database.setFollowRequestCount($currentInstance, followReqs.length)
const { followRequestCounts } = store.get()
followRequestCounts[$currentInstance] = followReqs.length
store.set({ followRequestCounts })
}
export default { export default {
data: () => ({ data: () => ({
@ -25,7 +34,11 @@
] ]
}), }),
computed: { computed: {
accountsFetcher: ({ $currentInstance, $accessToken }) => () => getFollowRequests($currentInstance, $accessToken) accountsFetcher: ({ $currentInstance, $accessToken }) => async () => {
const followReqs = await getFollowRequests($currentInstance, $accessToken)
updateFollowReqsCount($currentInstance, followReqs)
return followReqs
}
}, },
store: () => store, store: () => store,
components: { components: {

View file

@ -181,4 +181,14 @@ export function timelineComputations (store) {
currentPage !== 'notifications' && !!numberOfNotifications currentPage !== 'notifications' && !!numberOfNotifications
) )
) )
store.compute('numberOfFollowRequests',
['followRequestCounts', 'currentInstance'],
(followRequestCounts, currentInstance) => get(followRequestCounts, [currentInstance], 0)
)
store.compute('hasFollowRequests',
['numberOfFollowRequests'],
(numberOfFollowRequests) => !!numberOfFollowRequests
)
} }

View file

@ -6,6 +6,7 @@ import { setupCustomEmojiForInstance } from '../../_actions/emoji'
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { mark, stop } from '../../_utils/marks' import { mark, stop } from '../../_utils/marks'
import { store } from '../store' import { store } from '../store'
import { updateFollowRequestCountIfLockedAccount } from '../../_actions/followRequests'
// stream to watch for home timeline updates and notifications // stream to watch for home timeline updates and notifications
let currentInstanceStream let currentInstanceStream
@ -48,7 +49,10 @@ async function refreshInstanceData (instanceName) {
// these are the only critical ones // these are the only critical ones
await Promise.all([ await Promise.all([
updateInstanceInfo(instanceName), updateInstanceInfo(instanceName),
updateVerifyCredentialsForInstance(instanceName) updateVerifyCredentialsForInstance(instanceName).then(() => {
// Once we have the verifyCredentials (so we know if the account is locked), lazily update the follow requests
scheduleIdleTask(() => updateFollowRequestCountIfLockedAccount(instanceName))
})
]) ])
} }

View file

@ -42,6 +42,7 @@ const persistedState = {
const nonPersistedState = { const nonPersistedState = {
customEmoji: {}, customEmoji: {},
followRequestCounts: {},
instanceInfos: {}, instanceInfos: {},
instanceLists: {}, instanceLists: {},
online: !process.browser || navigator.onLine, online: !process.browser || navigator.onLine,

View file

@ -4,7 +4,7 @@ import FileApi from 'file-api'
import { users } from './users' import { users } from './users'
import { postStatus } from '../src/routes/_api/statuses' import { postStatus } from '../src/routes/_api/statuses'
import { deleteStatus } from '../src/routes/_api/delete' import { deleteStatus } from '../src/routes/_api/delete'
import { authorizeFollowRequest, getFollowRequests } from '../src/routes/_actions/followRequests' import { authorizeFollowRequest, getFollowRequests } from '../src/routes/_api/followRequests'
import { followAccount, unfollowAccount } from '../src/routes/_api/follow' import { followAccount, unfollowAccount } from '../src/routes/_api/follow'
import { updateCredentials } from '../src/routes/_api/updateCredentials' import { updateCredentials } from '../src/routes/_api/updateCredentials'
import { reblogStatus } from '../src/routes/_api/reblog' import { reblogStatus } from '../src/routes/_api/reblog'

View file

@ -13,15 +13,23 @@ fixture`116-follow-requests.js`
const timeout = 30000 const timeout = 30000
test('Can approve and reject follow requests', async t => { const requestsButton = $('a[href="/requests"]')
await loginAsLockedAccount(t) const approveAdminButton = () => getSearchResultByHref(`/accounts/${users.admin.id}`).find('button:nth-child(1)')
const rejectBazButton = () => getSearchResultByHref(`/accounts/${users.baz.id}`).find('button:nth-child(2)')
const approveQuuxButton = () => getSearchResultByHref(`/accounts/${users.quux.id}`).find('button:nth-child(1)')
async function resetFollows () {
// necessary for re-running this test in local testing // necessary for re-running this test in local testing
await Promise.all([ await Promise.all([
unfollowAs('admin', 'LockedAccount'), unfollowAs('admin', 'LockedAccount'),
unfollowAs('baz', 'LockedAccount'), unfollowAs('baz', 'LockedAccount'),
unfollowAs('quux', 'LockedAccount') unfollowAs('quux', 'LockedAccount')
]) ])
}
test('Can approve and reject follow requests', async t => {
await loginAsLockedAccount(t)
await resetFollows()
await Promise.all([ await Promise.all([
followAs('admin', 'LockedAccount'), followAs('admin', 'LockedAccount'),
@ -31,12 +39,10 @@ test('Can approve and reject follow requests', async t => {
await sleep(2000) await sleep(2000)
const approveAdminButton = () => getSearchResultByHref(`/accounts/${users.admin.id}`).find('button:nth-child(1)') await t
const rejectBazButton = () => getSearchResultByHref(`/accounts/${users.baz.id}`).find('button:nth-child(2)') .expect(communityNavButton.getAttribute('aria-label')).eql('Community (3 follow requests)')
const approveQuuxButton = () => getSearchResultByHref(`/accounts/${users.quux.id}`).find('button:nth-child(1)') .click(communityNavButton)
.click(requestsButton)
await t.click(communityNavButton)
.click($('a[href="/requests"]'))
// no guaranteed order on these // no guaranteed order on these
.expect(getNthSearchResult(1).innerText).match(/(@admin|@baz|@quux)/) .expect(getNthSearchResult(1).innerText).match(/(@admin|@baz|@quux)/)
.expect(getNthSearchResult(2).innerText).match(/(@admin|@baz|@quux)/) .expect(getNthSearchResult(2).innerText).match(/(@admin|@baz|@quux)/)
@ -51,7 +57,7 @@ test('Can approve and reject follow requests', async t => {
.expect(getNthSearchResult(3).exists).notOk() .expect(getNthSearchResult(3).exists).notOk()
await goBack() await goBack()
await t await t
.click($('a[href="/requests"]')) .click(requestsButton)
// reject baz // reject baz
.expect(rejectBazButton().getAttribute('aria-label')).eql('Reject') .expect(rejectBazButton().getAttribute('aria-label')).eql('Reject')
.hover(rejectBazButton()) .hover(rejectBazButton())
@ -60,7 +66,7 @@ test('Can approve and reject follow requests', async t => {
.expect(getNthSearchResult(2).exists).notOk() .expect(getNthSearchResult(2).exists).notOk()
await goBack() await goBack()
await t await t
.click($('a[href="/requests"]')) .click(requestsButton)
// approve quux // approve quux
.expect(approveQuuxButton().getAttribute('aria-label')).eql('Approve') .expect(approveQuuxButton().getAttribute('aria-label')).eql('Approve')
.hover(approveQuuxButton()) .hover(approveQuuxButton())
@ -75,3 +81,43 @@ test('Can approve and reject follow requests', async t => {
.expect(getNthSearchResult(2).innerText).match(/(@admin|@quux)/) .expect(getNthSearchResult(2).innerText).match(/(@admin|@quux)/)
.expect(getNthSearchResult(3).exists).notOk() .expect(getNthSearchResult(3).exists).notOk()
}) })
test('Shows unresolved follow requests', async t => {
await resetFollows()
await sleep(2000)
await Promise.all([
followAs('admin', 'LockedAccount'),
followAs('baz', 'LockedAccount')
])
await sleep(2000)
await loginAsLockedAccount(t)
await t
.expect(communityNavButton.getAttribute('aria-label')).eql('Community (2 follow requests)')
.click(communityNavButton)
.expect(requestsButton.innerText).contains('Follow requests (2)')
.click(requestsButton)
.expect(getUrl()).contains('/requests')
.expect(communityNavButton.getAttribute('aria-label')).eql('Community (2 follow requests)')
.click(approveAdminButton())
.expect(communityNavButton.getAttribute('aria-label')).eql('Community (1 follow request)')
.click(rejectBazButton())
.expect(communityNavButton.getAttribute('aria-label')).eql('Community')
await goBack()
await t
.expect(requestsButton.innerText).contains('Follow requests')
})
test('Shows unresolved follow requests immediately upon opening community page', async t => {
await resetFollows()
await sleep(2000)
await loginAsLockedAccount(t)
await sleep(2000)
await followAs('admin', 'LockedAccount')
await sleep(2000)
await t
.expect(communityNavButton.getAttribute('aria-label')).eql('Community')
.click(communityNavButton)
.expect(communityNavButton.getAttribute('aria-label')).eql('Community (current page) (1 follow request)')
.expect(requestsButton.innerText).contains('Follow requests (1)')
})