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:
npx standard --fix
npx standard --fix --plugin html 'routes/**/*.html'
npm run lint-fix
## Testing

View file

@ -4,6 +4,7 @@
"version": "0.2.3",
"scripts": {
"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",
"serve-dev": "run-p --race build-sass-watch serve",
"serve": "node server.js",

View file

@ -2,6 +2,7 @@ import { store } from '../_store/store'
import { blockAccount, unblockAccount } from '../_api/block'
import { toast } from '../_utils/toast'
import { updateProfileAndRelationship } from './accounts'
import { emit } from '../_utils/eventBus'
export async function setAccountBlocked (accountId, block, toastOnSuccess) {
let { currentInstance, accessToken } = store.get()
@ -19,6 +20,7 @@ export async function setAccountBlocked (accountId, block, toastOnSuccess) {
toast.say('Unblocked account')
}
}
emit('refreshAccountsList')
} catch (e) {
console.error(e)
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 { toast } from '../_utils/toast'
import { updateProfileAndRelationship } from './accounts'
import { emit } from '../_utils/eventBus'
export async function setAccountMuted (accountId, mute, toastOnSuccess) {
let { currentInstance, accessToken } = store.get()
@ -19,6 +20,7 @@ export async function setAccountMuted (accountId, mute, toastOnSuccess) {
toast.say('Unmuted account')
}
}
emit('refreshAccountsList')
} catch (e) {
console.error(e)
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}}
<ul class="accounts-results">
{{#each accounts as account}}
<AccountSearchResult :account />
<AccountSearchResult
:account
actions={{accountActions}}
on:click="onClickAction(event)"
/>
{{/each}}
</ul>
{{/if}}
@ -31,19 +35,19 @@
import LoadingPage from '../_components/LoadingPage.html'
import AccountSearchResult from '../_components/search/AccountSearchResult.html'
import { toast } from '../_utils/toast'
import { on } from '../_utils/eventBus'
// TODO: paginate
export default {
async oncreate () {
let { accountsFetcher } = this.get()
try {
// TODO: paginate
let accounts = await accountsFetcher()
this.set({ accounts: accounts })
await this.refreshAccounts()
} catch (e) {
toast.say('Error: ' + (e.name || '') + ' ' + (e.message || ''))
} finally {
this.set({loading: false})
}
on('refreshAccountsList', this, () => this.refreshAccounts())
},
data: () => ({
loading: true,
@ -53,6 +57,17 @@
components: {
LoadingPage,
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>

View file

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

View file

@ -7,16 +7,28 @@
<div class="search-result-account-username">
{{'@' + account.acct}}
</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>
</SearchResult>
<style>
.search-result-account {
display: grid;
grid-template-areas:
"avatar name"
"avatar username";
"avatar name buttons"
"avatar username buttons";
grid-column-gap: 20px;
grid-template-columns: max-content 1fr;
grid-template-columns: max-content 1fr max-content;
align-items: center;
}
:global(.search-result-account-avatar) {
@ -36,19 +48,45 @@
text-overflow: ellipsis;
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) {
.search-result-account {
grid-column-gap: 10px;
}
:global(.search-result-account-buttons .icon-button) {
margin-right: 10px;
}
}
</style>
<script>
import Avatar from '../Avatar.html'
import SearchResult from './SearchResult.html'
import IconButton from '../IconButton.html'
export default {
components: {
Avatar,
SearchResult
SearchResult,
IconButton
},
methods: {
onButtonClick (event, action, accountId) {
event.preventDefault()
event.stopPropagation()
this.fire('click', {
action,
accountId
})
}
}
}
</script>

View file

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

View file

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

View file

@ -1,15 +1,29 @@
<DynamicPageBanner title="Follow requests" icon="#fa-user-plus" />
<AccountsListPage :accountsFetcher />
<AccountsListPage :accountsFetcher :accountActions />
<script>
import AccountsListPage from '.././_components/AccountsListPage.html'
import { store } from '.././_store/store'
import { getFollowRequests } from '../_actions/followRequests'
import DynamicPageBanner from '.././_components/DynamicPageBanner.html'
import { setFollowRequestApprovedOrRejected } from '../_actions/requests'
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: {
statusId: params => params.statusId,
accountsFetcher: ($currentInstance, $accessToken, statusId) => () => getFollowRequests($currentInstance, $accessToken, statusId)
accountsFetcher: ($currentInstance, $accessToken) => () => getFollowRequests($currentInstance, $accessToken)
},
store: () => store,
components: {

View file

@ -4,6 +4,7 @@ import {
authorizeInput, emailInput, getUrl, instanceInput, mastodonLogInButton,
passwordInput
} from './utils'
import { users } from './users'
function login (t, username, password) {
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 => {
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 { deleteStatus } from '../routes/_api/delete'
import { authorizeFollowRequest, getFollowRequests } from '../routes/_actions/followRequests'
import { followAccount, unfollowAccount } from '../routes/_api/follow'
global.fetch = fetch
global.File = FileApi.File
@ -37,3 +38,11 @@ export async function getFollowRequestsAs (username) {
export async function authorizeFollowRequestAs (username, 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 = {
admin: {
username: 'admin',
email: 'admin@localhost:3000',
password: 'mastodonadmin',
accessToken: 'f954c8de1fcc0080ff706fa2516d05b60de0d8f5b536255a85ef85a6c32e4afb',
id: 1
},
foobar: {
username: 'foobar',
email: 'foobar@localhost:3000',
password: 'foobarfoobar',
accessToken: 'b48d72074a467e77a18eafc0d52e373dcf2492bcb3fefadc302a81300ec69002',
id: 2
},
quux: {
username: 'quux',
email: 'quux@localhost:3000',
password: 'quuxquuxquux',
accessToken: '894d3583dbf7d0f4f4784a06db86bdadb6ef0d99453d15afbc03e0c103bd78af',
id: 3
},
ExternalLinks: {
username: 'ExternalLinks',
email: 'ExternalLinks@localhost:3000',
password: 'ExternalLinksExternalLink',
accessToken: 'e9a463ba1729ae0049a97a312af702cb3d08d84de1cc8d6da3fad90af068117b',
id: 4
},
baz: {
username: 'baz',
email: 'baz@localhost:3000',
password: 'bazbazbaz',
accessToken: '0639238783efdfde849304bc89ec0c4b60b5ef5f261f60859fcd597de081cfdc',
id: 5
},
LockedAccount: {
username: 'LockedAccount',
email: 'LockedAccount@localhost:3000',
password: 'LockedAccountLockedAccount',
accessToken: '39ed9aeffa4b25eda4940f22f29fea66e625c6282c2a8bf0430203c9779e9e98',
id: 6

View file

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