approve/reject follow requests, unblock, unmute (#230)

* approve/reject follow requests, unblock, unmute

* make tests less flaky
This commit is contained in:
Nolan Lawson 2018-04-28 14:19:39 -07:00 committed by GitHub
parent e342eadbd0
commit ffb00fcc5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 250 additions and 18 deletions

View File

@ -31,8 +31,7 @@ Lint:
Automatically fix most linting issues: Automatically fix most linting issues:
npx standard --fix npm run lint-fix
npx standard --fix --plugin html 'routes/**/*.html'
## Testing ## Testing

View File

@ -4,6 +4,7 @@
"version": "0.2.3", "version": "0.2.3",
"scripts": { "scripts": {
"lint": "standard && standard --plugin html 'routes/**/*.html'", "lint": "standard && standard --plugin html 'routes/**/*.html'",
"lint-fix": "standard --fix && standard --fix --plugin html 'routes/**/*.html'",
"dev": "run-s build-svg build-inline-script serve-dev", "dev": "run-s build-svg build-inline-script serve-dev",
"serve-dev": "run-p --race build-sass-watch serve", "serve-dev": "run-p --race build-sass-watch serve",
"serve": "node server.js", "serve": "node server.js",

View File

@ -2,6 +2,7 @@ import { store } from '../_store/store'
import { blockAccount, unblockAccount } from '../_api/block' import { blockAccount, unblockAccount } from '../_api/block'
import { toast } from '../_utils/toast' import { toast } from '../_utils/toast'
import { updateProfileAndRelationship } from './accounts' import { updateProfileAndRelationship } from './accounts'
import { emit } from '../_utils/eventBus'
export async function setAccountBlocked (accountId, block, toastOnSuccess) { export async function setAccountBlocked (accountId, block, toastOnSuccess) {
let { currentInstance, accessToken } = store.get() let { currentInstance, accessToken } = store.get()
@ -19,6 +20,7 @@ export async function setAccountBlocked (accountId, block, toastOnSuccess) {
toast.say('Unblocked account') toast.say('Unblocked account')
} }
} }
emit('refreshAccountsList')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${block ? 'block' : 'unblock'} account: ` + (e.message || '')) toast.say(`Unable to ${block ? 'block' : 'unblock'} account: ` + (e.message || ''))

View File

@ -2,6 +2,7 @@ import { store } from '../_store/store'
import { muteAccount, unmuteAccount } from '../_api/mute' import { muteAccount, unmuteAccount } from '../_api/mute'
import { toast } from '../_utils/toast' import { toast } from '../_utils/toast'
import { updateProfileAndRelationship } from './accounts' import { updateProfileAndRelationship } from './accounts'
import { emit } from '../_utils/eventBus'
export async function setAccountMuted (accountId, mute, toastOnSuccess) { export async function setAccountMuted (accountId, mute, toastOnSuccess) {
let { currentInstance, accessToken } = store.get() let { currentInstance, accessToken } = store.get()
@ -19,6 +20,7 @@ export async function setAccountMuted (accountId, mute, toastOnSuccess) {
toast.say('Unmuted account') toast.say('Unmuted account')
} }
} }
emit('refreshAccountsList')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.say(`Unable to ${mute ? 'mute' : 'unmute'} account: ` + (e.message || '')) toast.say(`Unable to ${mute ? 'mute' : 'unmute'} account: ` + (e.message || ''))

View File

@ -0,0 +1,29 @@
import { store } from '../_store/store'
import { approveFollowRequest, rejectFollowRequest } from '../_api/requests'
import { emit } from '../_utils/eventBus'
import { toast } from '../_utils/toast'
export async function setFollowRequestApprovedOrRejected (accountId, approved, toastOnSuccess) {
let {
currentInstance,
accessToken
} = store.get()
try {
if (approved) {
await approveFollowRequest(currentInstance, accessToken, accountId)
} else {
await rejectFollowRequest(currentInstance, accessToken, accountId)
}
if (toastOnSuccess) {
if (approved) {
toast.say('Approved follow request')
} else {
toast.say('Rejected follow request')
}
}
emit('refreshAccountsList')
} catch (e) {
console.error(e)
toast.say(`Unable to ${approved ? 'approve' : 'reject'} account: ` + (e.message || ''))
}
}

12
routes/_api/requests.js Normal file
View File

@ -0,0 +1,12 @@
import { postWithTimeout } from '../_utils/ajax'
import { auth, basename } from './utils'
export async function approveFollowRequest (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/follow_requests/${accountId}/authorize`
return postWithTimeout(url, null, auth(accessToken))
}
export async function rejectFollowRequest (instanceName, accessToken, accountId) {
let url = `${basename(instanceName)}/api/v1/follow_requests/${accountId}/reject`
return postWithTimeout(url, null, auth(accessToken))
}

View File

@ -4,7 +4,11 @@
{{elseif accounts && accounts.length}} {{elseif accounts && accounts.length}}
<ul class="accounts-results"> <ul class="accounts-results">
{{#each accounts as account}} {{#each accounts as account}}
<AccountSearchResult :account /> <AccountSearchResult
:account
actions={{accountActions}}
on:click="onClickAction(event)"
/>
{{/each}} {{/each}}
</ul> </ul>
{{/if}} {{/if}}
@ -31,19 +35,19 @@
import LoadingPage from '../_components/LoadingPage.html' import LoadingPage from '../_components/LoadingPage.html'
import AccountSearchResult from '../_components/search/AccountSearchResult.html' import AccountSearchResult from '../_components/search/AccountSearchResult.html'
import { toast } from '../_utils/toast' import { toast } from '../_utils/toast'
import { on } from '../_utils/eventBus'
// TODO: paginate
export default { export default {
async oncreate () { async oncreate () {
let { accountsFetcher } = this.get()
try { try {
// TODO: paginate await this.refreshAccounts()
let accounts = await accountsFetcher()
this.set({ accounts: accounts })
} catch (e) { } catch (e) {
toast.say('Error: ' + (e.name || '') + ' ' + (e.message || '')) toast.say('Error: ' + (e.name || '') + ' ' + (e.message || ''))
} finally { } finally {
this.set({loading: false}) this.set({loading: false})
} }
on('refreshAccountsList', this, () => this.refreshAccounts())
}, },
data: () => ({ data: () => ({
loading: true, loading: true,
@ -53,6 +57,17 @@
components: { components: {
LoadingPage, LoadingPage,
AccountSearchResult AccountSearchResult
},
methods: {
onClickAction (event) {
let { action, accountId } = event
action.onclick(accountId)
},
async refreshAccounts () {
let { accountsFetcher } = this.get()
let accounts = await accountsFetcher()
this.set({ accounts: accounts })
}
} }
} }
</script> </script>

View File

@ -9,7 +9,7 @@
{{#if pinnable}} {{#if pinnable}}
<IconButton pressable="true" <IconButton pressable="true"
pressed="{{$pinnedPage === href}}" pressed="{{$pinnedPage === href}}"
label="Pin page" label="{{$pinnedPage === href ? 'Unpin timeline' : 'Pin timeline'}}"
href="#fa-thumb-tack" href="#fa-thumb-tack"
on:click="onPinClick(event)" /> on:click="onPinClick(event)" />
{{/if}} {{/if}}

View File

@ -7,16 +7,28 @@
<div class="search-result-account-username"> <div class="search-result-account-username">
{{'@' + account.acct}} {{'@' + account.acct}}
</div> </div>
{{#if actions && actions.length}}
<div class="search-result-account-buttons">
{{#each actions as action}}
<IconButton
label="{{action.label}}"
on:click="onButtonClick(event, action, account.id)"
href="{{action.icon}}"
big="true"
/>
{{/each}}
</div>
{{/if}}
</div> </div>
</SearchResult> </SearchResult>
<style> <style>
.search-result-account { .search-result-account {
display: grid; display: grid;
grid-template-areas: grid-template-areas:
"avatar name" "avatar name buttons"
"avatar username"; "avatar username buttons";
grid-column-gap: 20px; grid-column-gap: 20px;
grid-template-columns: max-content 1fr; grid-template-columns: max-content 1fr max-content;
align-items: center; align-items: center;
} }
:global(.search-result-account-avatar) { :global(.search-result-account-avatar) {
@ -36,19 +48,45 @@
text-overflow: ellipsis; text-overflow: ellipsis;
color: var(--deemphasized-text-color); color: var(--deemphasized-text-color);
} }
.search-result-account-buttons {
grid-area: buttons;
display: flex;
}
:global(.search-result-account-buttons .icon-button) {
margin-right: 20px;
}
:global(.search-result-account-buttons .icon-button:last-child) {
margin-right: 0;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.search-result-account { .search-result-account {
grid-column-gap: 10px; grid-column-gap: 10px;
} }
:global(.search-result-account-buttons .icon-button) {
margin-right: 10px;
}
} }
</style> </style>
<script> <script>
import Avatar from '../Avatar.html' import Avatar from '../Avatar.html'
import SearchResult from './SearchResult.html' import SearchResult from './SearchResult.html'
import IconButton from '../IconButton.html'
export default { export default {
components: { components: {
Avatar, Avatar,
SearchResult SearchResult,
IconButton
},
methods: {
onButtonClick (event, action, accountId) {
event.preventDefault()
event.stopPropagation()
this.fire('click', {
action,
accountId
})
}
} }
} }
</script> </script>

View File

@ -1,12 +1,22 @@
<DynamicPageBanner title="Blocked users" icon="#fa-ban" /> <DynamicPageBanner title="Blocked users" icon="#fa-ban" />
<AccountsListPage :accountsFetcher /> <AccountsListPage :accountsFetcher :accountActions />
<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 { getBlockedAccounts } from '.././_api/blockedAndMuted' import { getBlockedAccounts } from '.././_api/blockedAndMuted'
import DynamicPageBanner from '.././_components/DynamicPageBanner.html' import DynamicPageBanner from '.././_components/DynamicPageBanner.html'
import { setAccountBlocked } from '../_actions/block'
export default { export default {
data: () => ({
accountActions: [
{
icon: '#fa-unlock',
label: 'Unblock',
onclick: (accountId) => setAccountBlocked(accountId, false, true)
}
]
}),
computed: { computed: {
accountsFetcher: ($currentInstance, $accessToken) => () => getBlockedAccounts($currentInstance, $accessToken) accountsFetcher: ($currentInstance, $accessToken) => () => getBlockedAccounts($currentInstance, $accessToken)
}, },

View File

@ -1,12 +1,22 @@
<DynamicPageBanner title="Muted users" icon="#fa-volume-off" /> <DynamicPageBanner title="Muted users" icon="#fa-volume-off" />
<AccountsListPage :accountsFetcher /> <AccountsListPage :accountsFetcher :accountActions />
<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 { getMutedAccounts } from '.././_api/blockedAndMuted' import { getMutedAccounts } from '.././_api/blockedAndMuted'
import DynamicPageBanner from '.././_components/DynamicPageBanner.html' import DynamicPageBanner from '.././_components/DynamicPageBanner.html'
import { setAccountMuted } from '../_actions/mute'
export default { export default {
data: () => ({
accountActions: [
{
icon: '#fa-volume-up',
label: 'Unmute',
onclick: (accountId) => setAccountMuted(accountId, false, true)
}
]
}),
computed: { computed: {
accountsFetcher: ($currentInstance, $accessToken) => () => getMutedAccounts($currentInstance, $accessToken) accountsFetcher: ($currentInstance, $accessToken) => () => getMutedAccounts($currentInstance, $accessToken)
}, },

View File

@ -1,15 +1,29 @@
<DynamicPageBanner title="Follow requests" icon="#fa-user-plus" /> <DynamicPageBanner title="Follow requests" icon="#fa-user-plus" />
<AccountsListPage :accountsFetcher /> <AccountsListPage :accountsFetcher :accountActions />
<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 '../_actions/followRequests'
import DynamicPageBanner from '.././_components/DynamicPageBanner.html' import DynamicPageBanner from '.././_components/DynamicPageBanner.html'
import { setFollowRequestApprovedOrRejected } from '../_actions/requests'
export default { export default {
data: () => ({
accountActions: [
{
icon: '#fa-check',
label: 'Approve',
onclick: (accountId) => setFollowRequestApprovedOrRejected(accountId, true, true)
},
{
icon: '#fa-times',
label: 'Reject',
onclick: (accountId) => setFollowRequestApprovedOrRejected(accountId, false, true)
}
]
}),
computed: { computed: {
statusId: params => params.statusId, accountsFetcher: ($currentInstance, $accessToken) => () => getFollowRequests($currentInstance, $accessToken)
accountsFetcher: ($currentInstance, $accessToken, statusId) => () => getFollowRequests($currentInstance, $accessToken, statusId)
}, },
store: () => store, store: () => store,
components: { components: {

View File

@ -4,6 +4,7 @@ import {
authorizeInput, emailInput, getUrl, instanceInput, mastodonLogInButton, authorizeInput, emailInput, getUrl, instanceInput, mastodonLogInButton,
passwordInput passwordInput
} from './utils' } from './utils'
import { users } from './users'
function login (t, username, password) { function login (t, username, password) {
return t.typeText(instanceInput, 'localhost:3000', {paste: true}) return t.typeText(instanceInput, 'localhost:3000', {paste: true})
@ -18,5 +19,9 @@ function login (t, username, password) {
} }
export const foobarRole = Role('http://localhost:4002/settings/instances/add', async t => { export const foobarRole = Role('http://localhost:4002/settings/instances/add', async t => {
await login(t, 'foobar@localhost:3000', 'foobarfoobar') await login(t, users.foobar.email, users.foobar.password)
})
export const lockedAccountRole = Role('http://localhost:4002/settings/instances/add', async t => {
await login(t, users.LockedAccount.email, users.LockedAccount.password)
}) })

View File

@ -5,6 +5,7 @@ import { users } from './users'
import { postStatus } from '../routes/_api/statuses' import { postStatus } from '../routes/_api/statuses'
import { deleteStatus } from '../routes/_api/delete' import { deleteStatus } from '../routes/_api/delete'
import { authorizeFollowRequest, getFollowRequests } from '../routes/_actions/followRequests' import { authorizeFollowRequest, getFollowRequests } from '../routes/_actions/followRequests'
import { followAccount, unfollowAccount } from '../routes/_api/follow'
global.fetch = fetch global.fetch = fetch
global.File = FileApi.File global.File = FileApi.File
@ -37,3 +38,11 @@ export async function getFollowRequestsAs (username) {
export async function authorizeFollowRequestAs (username, id) { export async function authorizeFollowRequestAs (username, id) {
return authorizeFollowRequest(instanceName, users[username].accessToken, id) return authorizeFollowRequest(instanceName, users[username].accessToken, id)
} }
export async function followAs (username, userToFollow) {
return followAccount(instanceName, users[username].accessToken, users[userToFollow].id)
}
export async function unfollowAs (username, userToFollow) {
return unfollowAccount(instanceName, users[username].accessToken, users[userToFollow].id)
}

View File

@ -0,0 +1,76 @@
import { lockedAccountRole } from '../roles'
import { followAs, unfollowAs } from '../serverActions'
import {
communityNavButton, followersButton, getNthSearchResult, getSearchResultByHref, getUrl, goBack,
homeNavButton, sleep
} from '../utils'
import { users } from '../users'
import { Selector as $ } from 'testcafe'
fixture`116-follow-requests.js`
.page`http://localhost:4002`
const timeout = 30000
test('Can approve and reject follow requests', async t => {
await t.useRole(lockedAccountRole)
// necessary for re-running this test in local testing
await Promise.all([
unfollowAs('admin', 'LockedAccount'),
unfollowAs('baz', 'LockedAccount'),
unfollowAs('quux', 'LockedAccount')
])
await Promise.all([
followAs('admin', 'LockedAccount'),
followAs('baz', 'LockedAccount'),
followAs('quux', 'LockedAccount')
])
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"]'))
// no guaranteed order on these
.expect(getNthSearchResult(1).innerText).match(/(@admin|@baz|@quux)/)
.expect(getNthSearchResult(2).innerText).match(/(@admin|@baz|@quux)/)
.expect(getNthSearchResult(3).innerText).match(/(@admin|@baz|@quux)/)
.expect(getNthSearchResult(4).exists).notOk()
// approve admin
.expect(approveAdminButton().getAttribute('aria-label')).eql('Approve')
.hover(approveAdminButton())
.click(approveAdminButton())
.expect(getNthSearchResult(1).innerText).match(/(@baz|@quux)/, {timeout})
.expect(getNthSearchResult(2).innerText).match(/(@baz|@quux)/)
.expect(getNthSearchResult(3).exists).notOk()
await goBack()
await t
.click($('a[href="/requests"]'))
// reject baz
.expect(rejectBazButton().getAttribute('aria-label')).eql('Reject')
.hover(rejectBazButton())
.click(rejectBazButton())
.expect(getNthSearchResult(1).innerText).contains('@quux', {timeout})
.expect(getNthSearchResult(2).exists).notOk()
await goBack()
await t
.click($('a[href="/requests"]'))
// approve quux
.expect(approveQuuxButton().getAttribute('aria-label')).eql('Approve')
.hover(approveQuuxButton())
.click(approveQuuxButton())
.expect(getNthSearchResult(1).exists).notOk({timeout})
// check our follow list to make sure they follow us
.click(homeNavButton)
.click($('.compose-box-avatar'))
.expect(getUrl()).contains(`/accounts/${users.LockedAccount.id}`)
.click(followersButton)
.expect(getNthSearchResult(1).innerText).match(/(@admin|@quux)/)
.expect(getNthSearchResult(2).innerText).match(/(@admin|@quux)/)
.expect(getNthSearchResult(3).exists).notOk()
})

View File

@ -1,36 +1,42 @@
export const users = { export const users = {
admin: { admin: {
username: 'admin', username: 'admin',
email: 'admin@localhost:3000',
password: 'mastodonadmin', password: 'mastodonadmin',
accessToken: 'f954c8de1fcc0080ff706fa2516d05b60de0d8f5b536255a85ef85a6c32e4afb', accessToken: 'f954c8de1fcc0080ff706fa2516d05b60de0d8f5b536255a85ef85a6c32e4afb',
id: 1 id: 1
}, },
foobar: { foobar: {
username: 'foobar', username: 'foobar',
email: 'foobar@localhost:3000',
password: 'foobarfoobar', password: 'foobarfoobar',
accessToken: 'b48d72074a467e77a18eafc0d52e373dcf2492bcb3fefadc302a81300ec69002', accessToken: 'b48d72074a467e77a18eafc0d52e373dcf2492bcb3fefadc302a81300ec69002',
id: 2 id: 2
}, },
quux: { quux: {
username: 'quux', username: 'quux',
email: 'quux@localhost:3000',
password: 'quuxquuxquux', password: 'quuxquuxquux',
accessToken: '894d3583dbf7d0f4f4784a06db86bdadb6ef0d99453d15afbc03e0c103bd78af', accessToken: '894d3583dbf7d0f4f4784a06db86bdadb6ef0d99453d15afbc03e0c103bd78af',
id: 3 id: 3
}, },
ExternalLinks: { ExternalLinks: {
username: 'ExternalLinks', username: 'ExternalLinks',
email: 'ExternalLinks@localhost:3000',
password: 'ExternalLinksExternalLink', password: 'ExternalLinksExternalLink',
accessToken: 'e9a463ba1729ae0049a97a312af702cb3d08d84de1cc8d6da3fad90af068117b', accessToken: 'e9a463ba1729ae0049a97a312af702cb3d08d84de1cc8d6da3fad90af068117b',
id: 4 id: 4
}, },
baz: { baz: {
username: 'baz', username: 'baz',
email: 'baz@localhost:3000',
password: 'bazbazbaz', password: 'bazbazbaz',
accessToken: '0639238783efdfde849304bc89ec0c4b60b5ef5f261f60859fcd597de081cfdc', accessToken: '0639238783efdfde849304bc89ec0c4b60b5ef5f261f60859fcd597de081cfdc',
id: 5 id: 5
}, },
LockedAccount: { LockedAccount: {
username: 'LockedAccount', username: 'LockedAccount',
email: 'LockedAccount@localhost:3000',
password: 'LockedAccountLockedAccount', password: 'LockedAccountLockedAccount',
accessToken: '39ed9aeffa4b25eda4940f22f29fea66e625c6282c2a8bf0430203c9779e9e98', accessToken: '39ed9aeffa4b25eda4940f22f29fea66e625c6282c2a8bf0430203c9779e9e98',
id: 6 id: 6

View File

@ -132,6 +132,10 @@ export function getNthAutosuggestionResult (n) {
return $(`.compose-autosuggest-list-item:nth-child(${n}) button`) return $(`.compose-autosuggest-list-item:nth-child(${n}) button`)
} }
export function getSearchResultByHref (href) {
return $(`.search-result a[href="${href}"]`)
}
export function getNthSearchResult (n) { export function getNthSearchResult (n) {
return $(`.search-result:nth-child(${n}) a`) return $(`.search-result:nth-child(${n}) a`)
} }