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 { auth, basename } from '../_api/utils'
import { store } from '../_store/store'
import { cacheFirstUpdateAfter } from '../_utils/sync'
import { database } from '../_database/database'
import { getFollowRequests } from '../_api/followRequests'
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 updateFollowRequestCountIfLockedAccount (instanceName) {
const { verifyCredentials, loggedInInstances } = store.get()
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 })
}
if (!verifyCredentials[instanceName].locked) {
return
}
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 })
const accessToken = loggedInInstances[instanceName].access_token
await cacheFirstUpdateAfter(
async () => (await getFollowRequests(instanceName, accessToken)).length,
() => 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
(name === 'notifications' && page === 'notifications/mentions')
},
ariaLabel: ({ selected, name, label, $numberOfNotifications }) => {
ariaLabel: ({ selected, name, label, $numberOfNotifications, $numberOfFollowRequests }) => {
let res = label
if (selected) {
res += ' (current page)'
}
if (name === 'notifications' && $numberOfNotifications) {
res += ` (${$numberOfNotifications} notification${$numberOfNotifications === 1 ? '' : 's'})`
} else if (name === 'community' && $numberOfFollowRequests) {
res += ` (${$numberOfFollowRequests} follow request${$numberOfFollowRequests === 1 ? '' : 's'})`
}
return res
},
showBadge: ({ name, $hasNotifications }) => name === 'notifications' && $hasNotifications,
badgeNumber: ({ name, $numberOfNotifications }) => name === 'notifications' && $numberOfNotifications
showBadge: ({ name, $hasNotifications, $hasFollowRequests }) => (
(name === 'notifications' && $hasNotifications) || (name === 'community' && $hasFollowRequests)
),
badgeNumber: ({ name, $numberOfNotifications, $numberOfFollowRequests }) => (
(name === 'notifications' && $numberOfNotifications) || (name === 'community' && $numberOfFollowRequests)
)
},
methods: {
onClick (e) {

View file

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

View file

@ -5,9 +5,18 @@
<script>
import AccountsListPage from '../_components/AccountsListPage.html'
import { store } from '../_store/store'
import { getFollowRequests } from '../_actions/followRequests'
import { getFollowRequests } from '../_api/followRequests'
import DynamicPageBanner from '../_components/DynamicPageBanner.html'
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 {
data: () => ({
@ -25,7 +34,11 @@
]
}),
computed: {
accountsFetcher: ({ $currentInstance, $accessToken }) => () => getFollowRequests($currentInstance, $accessToken)
accountsFetcher: ({ $currentInstance, $accessToken }) => async () => {
const followReqs = await getFollowRequests($currentInstance, $accessToken)
updateFollowReqsCount($currentInstance, followReqs)
return followReqs
}
},
store: () => store,
components: {

View file

@ -181,4 +181,14 @@ export function timelineComputations (store) {
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 { mark, stop } from '../../_utils/marks'
import { store } from '../store'
import { updateFollowRequestCountIfLockedAccount } from '../../_actions/followRequests'
// stream to watch for home timeline updates and notifications
let currentInstanceStream
@ -48,7 +49,10 @@ async function refreshInstanceData (instanceName) {
// these are the only critical ones
await Promise.all([
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 = {
customEmoji: {},
followRequestCounts: {},
instanceInfos: {},
instanceLists: {},
online: !process.browser || navigator.onLine,

View file

@ -4,7 +4,7 @@ import FileApi from 'file-api'
import { users } from './users'
import { postStatus } from '../src/routes/_api/statuses'
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 { updateCredentials } from '../src/routes/_api/updateCredentials'
import { reblogStatus } from '../src/routes/_api/reblog'

View file

@ -13,15 +13,23 @@ fixture`116-follow-requests.js`
const timeout = 30000
test('Can approve and reject follow requests', async t => {
await loginAsLockedAccount(t)
const requestsButton = $('a[href="/requests"]')
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
await Promise.all([
unfollowAs('admin', 'LockedAccount'),
unfollowAs('baz', 'LockedAccount'),
unfollowAs('quux', 'LockedAccount')
])
}
test('Can approve and reject follow requests', async t => {
await loginAsLockedAccount(t)
await resetFollows()
await Promise.all([
followAs('admin', 'LockedAccount'),
@ -31,12 +39,10 @@ test('Can approve and reject follow requests', async t => {
await sleep(2000)
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)')
await t.click(communityNavButton)
.click($('a[href="/requests"]'))
await t
.expect(communityNavButton.getAttribute('aria-label')).eql('Community (3 follow requests)')
.click(communityNavButton)
.click(requestsButton)
// no guaranteed order on these
.expect(getNthSearchResult(1).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()
await goBack()
await t
.click($('a[href="/requests"]'))
.click(requestsButton)
// reject baz
.expect(rejectBazButton().getAttribute('aria-label')).eql('Reject')
.hover(rejectBazButton())
@ -60,7 +66,7 @@ test('Can approve and reject follow requests', async t => {
.expect(getNthSearchResult(2).exists).notOk()
await goBack()
await t
.click($('a[href="/requests"]'))
.click(requestsButton)
// approve quux
.expect(approveQuuxButton().getAttribute('aria-label')).eql('Approve')
.hover(approveQuuxButton())
@ -75,3 +81,43 @@ test('Can approve and reject follow requests', async t => {
.expect(getNthSearchResult(2).innerText).match(/(@admin|@quux)/)
.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)')
})