feat: report an account or toots (#1016)

fixes #736
This commit is contained in:
Nolan Lawson 2019-02-18 19:55:44 -08:00 committed by GitHub
parent d665134d66
commit a63e85bf30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 345 additions and 9 deletions

View file

@ -44,5 +44,6 @@ module.exports = [
{ id: 'fa-angle-right', src: 'src/thirdparty/font-awesome-svg-png/white/svg/angle-right.svg' }, { id: 'fa-angle-right', src: 'src/thirdparty/font-awesome-svg-png/white/svg/angle-right.svg' },
{ id: 'fa-search-minus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/search-minus.svg' }, { id: 'fa-search-minus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/search-minus.svg' },
{ id: 'fa-search-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/search-plus.svg' }, { id: 'fa-search-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/search-plus.svg' },
{ id: 'fa-share-square-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/share-square-o.svg' } { id: 'fa-share-square-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/share-square-o.svg' },
{ id: 'fa-flag', src: 'src/thirdparty/font-awesome-svg-png/white/svg/flag.svg' }
] ]

View file

@ -0,0 +1,7 @@
import { store } from '../_store/store'
import { getTimeline } from '../_api/timelines'
export async function getRecentStatusesForAccount (accountId) {
const { currentInstance, accessToken } = store.get()
return getTimeline(currentInstance, accessToken, `account/${accountId}`, null, null, 20)
}

View file

@ -0,0 +1,6 @@
import { importShowReportDialog } from '../_components/dialog/asyncDialogs'
export async function reportStatusOrAccount ({ status, account }) {
let showReportDialog = await importShowReportDialog()
showReportDialog({ status, account })
}

View file

@ -0,0 +1,13 @@
import { store } from '../_store/store'
import { toast } from '../_components/toast/toast'
import { report } from '../_api/report'
export async function reportStatuses (account, statusIds, comment, forward) {
let { currentInstance, accessToken } = store.get()
try {
await report(currentInstance, accessToken, account.id, statusIds, comment, forward)
toast.say('Submitted report')
} catch (e) {
toast.say('Failed to report: ' + (e.message || ''))
}
}

12
src/routes/_api/report.js Normal file
View file

@ -0,0 +1,12 @@
import { auth, basename } from './utils'
import { post } from '../_utils/ajax'
export async function report (instanceName, accessToken, accountId, statusIds, comment, forward) {
let url = `${basename(instanceName)}/api/v1/reports`
return post(url, {
account_id: accountId,
status_ids: statusIds,
comment,
forward
}, auth(accessToken))
}

View file

@ -39,3 +39,7 @@ export const importShowMediaDialog = () => import(
export const importShowMuteDialog = () => import( export const importShowMuteDialog = () => import(
/* webpackChunkName: 'showMuteDialog' */ './creators/showMuteDialog' /* webpackChunkName: 'showMuteDialog' */ './creators/showMuteDialog'
).then(getDefault) ).then(getDefault)
export const importShowReportDialog = () => import(
/* webpackChunkName: 'showReportDialog' */ './creators/showReportDialog'
).then(getDefault)

View file

@ -21,6 +21,7 @@ import { setDomainBlocked } from '../../../_actions/setDomainBlocked'
import { copyText } from '../../../_actions/copyText' import { copyText } from '../../../_actions/copyText'
import { composeNewStatusMentioning } from '../../../_actions/mention' import { composeNewStatusMentioning } from '../../../_actions/mention'
import { toggleMute } from '../../../_actions/toggleMute' import { toggleMute } from '../../../_actions/toggleMute'
import { reportStatusOrAccount } from '../../../_actions/report'
export default { export default {
oncreate, oncreate,
@ -75,11 +76,12 @@ export default {
? `Unhide ${domain}` ? `Unhide ${domain}`
: `Hide ${domain}` : `Hide ${domain}`
), ),
reportLabel: ({ acct }) => `Report @${acct}`,
items: ({ items: ({
blockLabel, blocking, blockIcon, muteLabel, muteIcon, blockLabel, blocking, blockIcon, muteLabel, muteIcon,
followLabel, followIcon, following, followRequested, followLabel, followIcon, following, followRequested,
accountId, verifyCredentialsId, acct, isUser, showReblogsLabel, accountId, verifyCredentialsId, acct, isUser, showReblogsLabel,
domain, blockDomainLabel domain, blockDomainLabel, reportLabel
}) => ([ }) => ([
!isUser && { !isUser && {
key: 'mention', key: 'mention',
@ -111,6 +113,11 @@ export default {
label: blockDomainLabel, label: blockDomainLabel,
icon: '#fa-ban' icon: '#fa-ban'
}, },
!isUser && {
key: 'report',
label: reportLabel,
icon: '#fa-flag'
},
{ {
key: 'copy', key: 'copy',
label: 'Copy link to account', label: 'Copy link to account',
@ -137,12 +144,14 @@ export default {
return this.onBlockDomainClicked() return this.onBlockDomainClicked()
case 'copy': case 'copy':
return this.onCopyClicked() return this.onCopyClicked()
case 'report':
return this.onReport()
} }
}, },
async onMentionClicked () { async onMentionClicked () {
let { account } = this.get() let { account } = this.get()
await composeNewStatusMentioning(account)
this.close() this.close()
await composeNewStatusMentioning(account)
}, },
async onFollowClicked () { async onFollowClicked () {
let { accountId, following } = this.get() let { accountId, following } = this.get()
@ -174,6 +183,11 @@ export default {
let { url } = account let { url } = account
this.close() this.close()
await copyText(url) await copyText(url)
},
async onReport () {
let { account } = this.get()
this.close()
await reportStatusOrAccount({ account })
} }
}, },
components: { components: {

View file

@ -2,6 +2,7 @@
{id} {id}
{label} {label}
{title} {title}
{className}
background="var(--main-bg)" background="var(--main-bg)"
> >
<form class="confirmation-dialog-form"> <form class="confirmation-dialog-form">

View file

@ -81,4 +81,10 @@
margin-left: 10px; margin-left: 10px;
} }
} }
</style>
@media (max-width: 320px) {
.generic-dialog-list-button {
padding: 10px 10px;
}
}
</style>

View file

@ -9,13 +9,13 @@
<p> <p>
Mute @{account.acct} ? Mute @{account.acct} ?
</p> </p>
<form class="mute-dialog-form"> <div class="mute-dialog-form">
<input type="checkbox" <input type="checkbox"
id="mute-notifications" id="mute-notifications"
name="mute-notifications" name="mute-notifications"
bind:checked="muteNotifications"> bind:checked="muteNotifications">
<label for="mute-notifications">Mute notifications as well</label> <label for="mute-notifications">Mute notifications as well</label>
</form> </div>
</div> </div>
</GenericConfirmationDialog> </GenericConfirmationDialog>
<style> <style>

View file

@ -0,0 +1,213 @@
<GenericConfirmationDialog
{id}
{label}
{title}
className="report-dialog-contents"
{positiveText}
on:positive="doReport()">
<div class="report-dialog">
<div class="report-flex">
<div class="recent-statuses">
{#if loading}
<div class="loading-spinner-container">
<LoadingSpinner />
</div>
{:else}
<ul>
{#each displayStatuses as status (status.id)}
<li>
<input type="checkbox"
id="status-report-{status.id}"
name="status-report-{status.id}"
checked={status.report}
on:change="onChange(status.id, event)"
>
<label for="status-report-{status.id}">
{status.text}
</label>
</li>
{/each}
</ul>
{/if}
</div>
<div class="report-info">
<p>You are reporting @{account.acct} to the moderators of {$currentInstance}.</p>
<label class="sr-only" id="comments-label">Additional comments</label>
<textarea bind:value="comments"
placeholder="Additional comments"
aria-labelledby="comments-label"
maxlength="1000"></textarea>
{#if remoteInstance}
<p>Forward to the moderators of {remoteInstance} as well?</p>
<input type="checkbox"
id="report-forward"
name="report-forward"
bind:checked="forward">
<label for="report-forward">
Forward to {remoteInstance}
</label>
{/if}
</div>
</div>
</div>
</GenericConfirmationDialog>
<style>
:global(.report-dialog-contents .confirmation-dialog-form) {
max-width: 80vw;
}
.report-dialog {
padding: 20px 40px;
overflow-y: auto;
}
.loading-spinner-container {
width: 100%;
display: flex;
justify-content: center;
}
ul {
list-style: none;
max-height: 30vh;
overflow-y: auto;
overflow-x: hidden;
}
li {
padding: 10px 0;
}
textarea {
width: 100%;
overflow-y: auto;
max-height: 40vh;
font-size: 1.3em;
min-height: 100px;
}
p {
margin: 20px 0;
}
.recent-statuses li {
display: flex;
flex-direction: row;
}
.recent-statuses label {
width: 0;
flex: 1;
word-wrap: break-word;
overflow: hidden;
white-space: pre-wrap;
text-overflow: ellipsis;
}
.report-flex {
display: flex;
flex-direction: row;
}
.report-flex > * {
flex: 1;
}
.report-info {
margin-left: 20px;
}
@media (max-width: 767px) {
.report-dialog {
padding: 20px;
overflow-x: hidden;
max-height: 65vh;
}
.report-flex {
flex-direction: column;
}
.report-info {
margin-left: 0;
}
textarea {
max-height: 20vh;
}
p, label {
word-wrap: break-word;
}
:global(.report-dialog-contents .confirmation-dialog-form) {
max-width: calc(100vw - 20px);
}
}
</style>
<script>
import GenericConfirmationDialog from './GenericConfirmationDialog.html'
import LoadingSpinner from '../../LoadingSpinner.html'
import { show } from '../helpers/showDialog'
import { close } from '../helpers/closeDialog'
import { oncreate as onCreateDialog } from '../helpers/onCreateDialog'
import { getRecentStatusesForAccount } from '../../../_actions/getRecentStatusesForAccount'
import { statusHtmlToPlainText } from '../../../_utils/statusHtmlToPlainText'
import { toast } from '../../toast/toast'
import { store } from '../../../_store/store'
import { reportStatuses } from '../../../_actions/reportStatuses'
export default {
async oncreate () {
onCreateDialog.apply(this)
let { account, status, reportMap } = this.get()
if (status) {
reportMap[status.id] = true
}
try {
let recentStatuses = await getRecentStatusesForAccount(account.id)
console.log('recentStatuses', recentStatuses)
this.set({ recentStatuses })
} catch (err) {
console.error(err)
toast.say('Unable to load recent statuses: ' + (err.message || ''))
} finally {
this.set({ loading: false })
}
},
store: () => store,
data: () => ({
account: void 0,
status: void 0,
positiveText: 'Report',
reportMap: {},
recentStatuses: [],
loading: true,
forward: false,
comments: ''
}),
computed: {
displayStatuses: ({ statuses, reportMap }) => (
statuses.map(status => ({
id: status.id,
text: statusHtmlToPlainText(status.content, status.mentions) || '(No content)',
report: reportMap[status.id]
}))
),
statuses: ({ status, recentStatuses }) => (
[status].concat((recentStatuses || []).filter(({ id }) => (!status || id !== status.id))).filter(Boolean)
),
remoteInstance: ({ account }) => account.acct.split('@')[1]
},
methods: {
show,
close,
onChange (statusId, event) {
let report = event.target.checked
let { reportMap } = this.get()
reportMap[statusId] = report
},
async doReport () {
let { displayStatuses, account, comment, forward, reportMap } = this.get()
let statusIds = displayStatuses.map(({ id }) => id).filter(id => reportMap[id])
if (!statusIds.length) {
toast.say(`No toots to report.`)
} else {
await reportStatuses(account, statusIds, comment, forward)
}
}
},
components: {
GenericConfirmationDialog,
LoadingSpinner
}
}
</script>

View file

@ -22,6 +22,7 @@ import { copyText } from '../../../_actions/copyText'
import { deleteAndRedraft } from '../../../_actions/deleteAndRedraft' import { deleteAndRedraft } from '../../../_actions/deleteAndRedraft'
import { shareStatus } from '../../../_actions/share' import { shareStatus } from '../../../_actions/share'
import { toggleMute } from '../../../_actions/toggleMute' import { toggleMute } from '../../../_actions/toggleMute'
import { reportStatusOrAccount } from '../../../_actions/report'
export default { export default {
oncreate, oncreate,
@ -120,6 +121,11 @@ export default {
label: 'Delete and redraft', label: 'Delete and redraft',
icon: '#fa-pencil' icon: '#fa-pencil'
}, },
!isUser && {
key: 'report',
label: 'Report toot',
icon: '#fa-flag'
},
isPublicOrUnlisted && supportsWebShare && { isPublicOrUnlisted && supportsWebShare && {
key: 'share', key: 'share',
label: 'Share toot', label: 'Share toot',
@ -160,6 +166,8 @@ export default {
return this.onRedraft() return this.onRedraft()
case 'share': case 'share':
return this.onShare() return this.onShare()
case 'report':
return this.onReport()
} }
}, },
async onDeleteClicked () { async onDeleteClicked () {
@ -195,18 +203,23 @@ export default {
async onCopyClicked () { async onCopyClicked () {
let { status } = this.get() let { status } = this.get()
let { url } = status let { url } = status
await copyText(url)
this.close() this.close()
await copyText(url)
}, },
async onRedraft () { async onRedraft () {
let { status } = this.get() let { status } = this.get()
await deleteAndRedraft(status)
this.close() this.close()
await deleteAndRedraft(status)
}, },
async onShare () { async onShare () {
let { status } = this.get() let { status } = this.get()
await shareStatus(status)
this.close() this.close()
await shareStatus(status)
},
async onReport () {
let { status, account } = this.get()
this.close()
await reportStatusOrAccount(({ status, account }))
} }
} }
} }

View file

@ -0,0 +1,11 @@
import ReportDialog from '../components/ReportDialog.html'
import { showDialog } from './showDialog'
export default function showReportDialog ({ account, status }) {
return showDialog(ReportDialog, {
label: 'Report dialog',
title: `Report @${account.acct}`,
account,
status
})
}

View file

@ -0,0 +1,34 @@
import {
accountProfileMoreOptionsButton,
confirmationDialogCancelButton,
getNthStatusOptionsButton,
modalDialog
} from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'
fixture`028-report-ui.js`
.page`http://localhost:4002`
test('Can open a report UI from a status', async t => {
await loginAsFoobar(t)
await t
.click(getNthStatusOptionsButton(0))
.click($('.modal-dialog button').withText('Report'))
.expect(modalDialog.innerText).contains('You are reporting @quux')
.expect(modalDialog.find('.recent-statuses').innerText).contains('pinned toot 2')
.click(confirmationDialogCancelButton)
.expect(modalDialog.exists).notOk()
})
test('Can open a report UI from an account', async t => {
await loginAsFoobar(t)
await t
.navigateTo('/accounts/3')
.click(accountProfileMoreOptionsButton)
.click($('.modal-dialog button').withText('Report'))
.expect(modalDialog.innerText).contains('You are reporting @quux')
.expect(modalDialog.find('.recent-statuses').innerText).contains('pinned toot 2')
.click(confirmationDialogCancelButton)
.expect(modalDialog.exists).notOk()
})

View file

@ -48,6 +48,7 @@ export const removeEmojiFromDisplayNamesInput = $('#choice-omit-emoji-in-display
export const dialogOptionsOption = $(`.modal-dialog button`) export const dialogOptionsOption = $(`.modal-dialog button`)
export const emojiSearchInput = $('.emoji-mart-search input') export const emojiSearchInput = $('.emoji-mart-search input')
export const confirmationDialogOKButton = $('.confirmation-dialog-form-flex button:nth-child(1)') export const confirmationDialogOKButton = $('.confirmation-dialog-form-flex button:nth-child(1)')
export const confirmationDialogCancelButton = $('.confirmation-dialog-form-flex button:nth-child(2)')
export const composeModalInput = $('.modal-dialog .compose-box-input') export const composeModalInput = $('.modal-dialog .compose-box-input')
export const composeModalComposeButton = $('.modal-dialog .compose-box-button') export const composeModalComposeButton = $('.modal-dialog .compose-box-button')