feat: Report posts (#2184)

This commit is contained in:
Ashwin Agarwal 2023-06-23 08:24:10 -04:00 committed by GitHub
parent 5ea09d323f
commit 34aca66fef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 462 additions and 70 deletions

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import { toggleFollowAccount, useRelationship } from '~~/composables/masto/relationship'
const { account, command, context, ...props } = defineProps<{
account: mastodon.v1.Account
@ -14,26 +15,6 @@ const enable = $computed(() => !isSelf && currentUser.value)
const relationship = $computed(() => props.relationship || useRelationship(account).value)
const { client } = $(useMasto())
async function toggleFollow() {
if (relationship!.following) {
if (await openConfirmDialog({
title: t('confirm.unfollow.title'),
confirm: t('confirm.unfollow.confirm'),
cancel: t('confirm.unfollow.cancel'),
}) !== 'confirm')
return
}
relationship!.following = !relationship!.following
try {
const newRel = await client.v1.accounts[relationship!.following ? 'follow' : 'unfollow'](account.id)
Object.assign(relationship!, newRel)
}
catch (err) {
console.error(err)
// TODO error handling
relationship!.following = !relationship!.following
}
}
async function unblock() {
relationship!.blocking = false
@ -67,7 +48,7 @@ useCommand({
visible: () => command && enable,
name: () => `${relationship?.following ? t('account.unfollow') : t('account.follow')} ${getShortHandle(account)}`,
icon: 'i-ri:star-line',
onActivate: () => toggleFollow(),
onActivate: () => toggleFollowAccount(relationship!, account),
})
const buttonStyle = $computed(() => {
@ -95,7 +76,7 @@ const buttonStyle = $computed(() => {
rounded-full flex="~ gap2 center" font-500 min-w-30 h-fit px3 py1
:class="buttonStyle"
:hover="!relationship?.blocking && !relationship?.muting && relationship?.following ? 'border-red text-red' : 'bg-base border-primary text-primary'"
@click="relationship?.blocking ? unblock() : relationship?.muting ? unmute() : toggleFollow()"
@click="relationship?.blocking ? unblock() : relationship?.muting ? unmute() : toggleFollowAccount(relationship!, account)"
>
<template v-if="relationship?.blocking">
<span elk-group-hover="hidden">{{ $t('account.blocking') }}</span>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import { toggleBlockAccount, toggleBlockDomain, toggleMuteAccount } from '~~/composables/masto/relationship'
const { account } = defineProps<{
account: mastodon.v1.Account
@ -18,46 +19,6 @@ const { t } = useI18n()
const { client } = $(useMasto())
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
async function toggleMute() {
if (!relationship!.muting && await openConfirmDialog({
title: t('confirm.mute_account.title', [account.acct]),
confirm: t('confirm.mute_account.confirm'),
cancel: t('confirm.mute_account.cancel'),
}) !== 'confirm')
return
relationship!.muting = !relationship!.muting
relationship = relationship!.muting
? await client.v1.accounts.mute(account.id, {
// TODO support more options
})
: await client.v1.accounts.unmute(account.id)
}
async function toggleBlockUser() {
if (!relationship!.blocking && await openConfirmDialog({
title: t('confirm.block_account.title', [account.acct]),
confirm: t('confirm.block_account.confirm'),
cancel: t('confirm.block_account.cancel'),
}) !== 'confirm')
return
relationship!.blocking = !relationship!.blocking
relationship = await client.v1.accounts[relationship!.blocking ? 'block' : 'unblock'](account.id)
}
async function toggleBlockDomain() {
if (!relationship!.domainBlocking && await openConfirmDialog({
title: t('confirm.block_domain.title', [getServerName(account)]),
confirm: t('confirm.block_domain.confirm'),
cancel: t('confirm.block_domain.cancel'),
}) !== 'confirm')
return
relationship!.domainBlocking = !relationship!.domainBlocking
await client.v1.domainBlocks[relationship!.domainBlocking ? 'block' : 'unblock'](getServerName(account))
}
async function toggleReblogs() {
if (!relationship!.showingReblogs && await openConfirmDialog({
title: t('confirm.show_reblogs.title', [account.acct]),
@ -149,16 +110,16 @@ async function removeUserNote() {
<CommonDropdownItem
v-if="!relationship?.muting"
:text="$t('menu.mute_account', [`@${account.acct}`])"
icon="i-ri:volume-up-fill"
icon="i-ri:volume-mute-line"
:command="command"
@click="toggleMute()"
@click="toggleMuteAccount (relationship!, account)"
/>
<CommonDropdownItem
v-else
:text="$t('menu.unmute_account', [`@${account.acct}`])"
icon="i-ri:volume-mute-line"
icon="i-ri:volume-up-fill"
:command="command"
@click="toggleMute()"
@click="toggleMuteAccount (relationship!, account)"
/>
<CommonDropdownItem
@ -166,14 +127,14 @@ async function removeUserNote() {
:text="$t('menu.block_account', [`@${account.acct}`])"
icon="i-ri:forbid-2-line"
:command="command"
@click="toggleBlockUser()"
@click="toggleBlockAccount (relationship!, account)"
/>
<CommonDropdownItem
v-else
:text="$t('menu.unblock_account', [`@${account.acct}`])"
icon="i-ri:checkbox-circle-line"
:command="command"
@click="toggleBlockUser()"
@click="toggleBlockAccount (relationship!, account)"
/>
<template v-if="getServerName(account) !== currentServer">
@ -182,16 +143,23 @@ async function removeUserNote() {
:text="$t('menu.block_domain', [getServerName(account)])"
icon="i-ri:shut-down-line"
:command="command"
@click="toggleBlockDomain()"
@click="toggleBlockDomain(relationship!, account)"
/>
<CommonDropdownItem
v-else
:text="$t('menu.unblock_domain', [getServerName(account)])"
icon="i-ri:restart-line"
:command="command"
@click="toggleBlockDomain()"
@click="toggleBlockDomain(relationship!, account)"
/>
</template>
<CommonDropdownItem
:text="$t('menu.report_account', [`@${account.acct}`])"
icon="i-ri:flag-2-line"
:command="command"
@click="openReportDialog(account)"
/>
</template>
<template v-else>

View file

@ -11,6 +11,7 @@ import {
isMediaPreviewOpen,
isPreviewHelpOpen,
isPublishDialogOpen,
isReportDialogOpen,
isSigninDialogOpen,
} from '~/composables/dialog'
@ -102,5 +103,8 @@ function handleFavouritedBoostedByClose() {
<ModalDialog v-model="isKeyboardShortcutsDialogOpen" max-w-full sm:max-w-140 md:max-w-170 lg:max-w-220 md:min-w-160>
<MagickeysKeyboardShortcuts @close="closeKeyboardShortcuts()" />
</ModalDialog>
<ModalDialog v-model="isReportDialogOpen" keep-alive max-w-175>
<ReportModal v-if="reportAccount" :account="reportAccount" :status="reportStatus" @close="closeReportDialog()" />
</ModalDialog>
</template>
</template>

View file

@ -0,0 +1,266 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import { toggleBlockAccount, toggleFollowAccount, toggleMuteAccount, useRelationship } from '~~/composables/masto/relationship'
const { account, status } = defineProps<{
account: mastodon.v1.Account
status?: mastodon.v1.Status
}>()
const emit = defineEmits<{
(event: 'close'): void
}>()
const { client } = useMasto()
const step = ref('selectCategory')
const serverRules = ref((await client.value.v2.instance.fetch()).rules || [])
const reportReason = ref('')
const selectedRuleIds = ref([])
const availableStatuses = ref(status ? [status] : [])
const selectedStatusIds = ref(status ? [status.id] : [])
const additionalComments = ref('')
const forwardReport = ref(false)
const dismissButton = ref<HTMLDivElement>()
loadStatuses() // Load statuses asynchronously ahead of time
function categoryChosen() {
step.value = reportReason.value === 'dontlike' ? 'furtherActions' : 'selectStatuses'
resetModal()
}
async function loadStatuses() {
if (status) {
// Load the 5 statuses before and after the reported status
const prevStatuses = await client.value.v1.accounts.listStatuses(account.id, {
maxId: status.id,
limit: 5,
})
const nextStatuses = await client.value.v1.accounts.listStatuses(account.id, {
minId: status.id,
limit: 5,
})
availableStatuses.value = availableStatuses.value.concat(prevStatuses)
availableStatuses.value = availableStatuses.value.concat(nextStatuses)
}
else {
// Reporting an account directly
// Load the 10 most recent statuses
const mostRecentStatuses = await client.value.v1.accounts.listStatuses(account.id, {
limit: 10,
})
availableStatuses.value = mostRecentStatuses
}
availableStatuses.value.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
}
async function submitReport() {
await client.value.v1.reports.create({
accountId: account.id,
statusIds: selectedStatusIds.value,
comment: additionalComments.value,
forward: forwardReport.value,
category: reportReason.value === 'spam' ? 'spam' : reportReason.value === 'violation' ? 'violation' : 'other',
ruleIds: reportReason.value === 'violation' ? selectedRuleIds.value : null,
})
step.value = 'furtherActions'
resetModal()
}
function unfollow() {
emit('close')
toggleFollowAccount(useRelationship(account).value!, account)
}
function mute() {
emit('close')
toggleMuteAccount(useRelationship(account).value!, account)
}
function block() {
emit('close')
toggleBlockAccount(useRelationship(account).value!, account)
}
function resetModal() {
// TODO: extract this scroll/reset logic into ModalDialog element
dismissButton.value?.scrollIntoView() // scroll to top
}
</script>
<template>
<div my-8 px-3 sm:px-8 flex="~ col gap-4" relative>
<h2 mxa text-xl>
<i18n-t :keypath="reportReason === 'dontlike' ? 'report.limiting' : 'report.reporting'">
<b text-primary>@{{ account.acct }}</b>
</i18n-t>
</h2>
<button ref="dismissButton" btn-action-icon absolute top--8 right-0 m1 aria-label="Close" @click="emit('close')">
<div i-ri:close-line />
</button>
<template v-if="step === 'selectCategory'">
<h1 mxa text-4xl mb4>
{{ status ? $t('report.whats_wrong_post') : $t('report.whats_wrong_account') }}
</h1>
<p text-xl>
{{ $t('report.select_one') }}
</p>
<div>
<input id="dontlike" v-model="reportReason" type="radio" value="dontlike">
<label pl-2 for="dontlike" font-bold>{{ $t('report.dontlike') }}</label>
<p pl-6>
{{ $t('report.dontlike_desc') }}
</p>
</div>
<div>
<input id="spam" v-model="reportReason" type="radio" value="spam">
<label pl-2 for="spam" font-bold>{{ $t('report.spam') }}</label>
<p pl-6>
{{ $t('report.spam_desc') }}
</p>
</div>
<div v-if="serverRules.length > 0">
<input id="violation" v-model="reportReason" type="radio" value="violation">
<label pl-2 for="violation" font-bold>{{ $t('report.violation') }}</label>
<p v-if="reportReason === 'violation'" pl-6 pt-2 text-primary font-bold>
{{ $t('report.select_many') }}
</p>
<ul pl-6>
<li v-for="rule in serverRules" :key="rule.id" pt-2>
<input
:id="rule.id"
v-model="selectedRuleIds"
type="checkbox"
:value="rule.id"
:disabled="reportReason !== 'violation'"
>
<label pl-2 :for="rule.id">{{ rule.text }}</label>
</li>
</ul>
</div>
<div>
<input id="other" v-model="reportReason" type="radio" value="other">
<label pl-2 for="other" font-bold>{{ $t('report.other') }}</label>
<p pl-6>
{{ $t('report.other_desc') }}
</p>
</div>
<div v-if="reportReason && reportReason !== 'dontlike'">
<h3 mt-8 mb-4 font-bold>
{{ $t('report.anything_else') }}
</h3>
<textarea v-model="additionalComments" w-full h-20 p-3 border :placeholder="$t('report.additional_comments')" />
<div v-if="getServerName(account) && getServerName(account) !== currentServer">
<h3 mt-8 mb-2 font-bold>
{{ $t('report.another_server') }}
</h3>
<p pb-1>
{{ $t('report.forward_question') }}
</p>
<input id="forward" v-model="forwardReport" type="checkbox" value="rule.id">
<label pl-2 for="forward"><b>{{ $t('report.forward', [getServerName(account)]) }}</b></label>
</div>
</div>
<button
btn-solid mxa mt-10
:disabled="!reportReason || (reportReason === 'violation' && selectedRuleIds.length < 1)"
@click="categoryChosen()"
>
{{ $t('action.next') }}
</button>
</template>
<template v-else-if="step === 'selectStatuses'">
<h1 mxa text-4xl mb4>
{{ status ? $t('report.select_posts_other') : $t('report.select_posts') }}
</h1>
<p text-primary font-bold>
{{ $t('report.select_many') }}
</p>
<table>
<tr v-for="availableStatus in availableStatuses" :key="availableStatus.id">
<td>
<input
:id="availableStatus.id"
v-model="selectedStatusIds"
type="checkbox"
:value="availableStatus.id"
>
</td>
<td>
<label :for="availableStatus.id">
<StatusCard :status="availableStatus" :actions="false" pointer-events-none />
</label>
</td>
</tr>
</table>
<button
btn-solid mxa mt-5
@click="submitReport()"
>
{{ $t('report.submit') }}
</button>
</template>
<template v-else-if="step === 'furtherActions'">
<h1 mxa text-4xl mb4>
{{ reportReason === 'dontlike' ? $t('report.further_actions.limit.title') : $t('report.further_actions.report.title') }}
</h1>
<p text-xl>
{{ reportReason === 'dontlike' ? $t('report.further_actions.limit.description') : $t('report.further_actions.report.description') }}
</p>
<div v-if="useRelationship(account).value?.following">
<button btn-outline mxa mt-4 mb-2 @click="unfollow()">
<i18n-t keypath="menu.unfollow_account">
<b>@{{ account.acct }}</b>
</i18n-t>
</button><br>
{{ $t('report.unfollow_desc') }}
</div>
<div v-if="!useRelationship(account).value?.muting">
<button btn-outline mxa mt-4 mb-2 @click="mute()">
<i18n-t keypath="menu.mute_account">
<b>@{{ account.acct }}</b>
</i18n-t>
</button><br>
{{ $t('report.mute_desc') }}
</div>
<div v-if="!useRelationship(account).value?.blocking">
<button btn-outline mxa mt-4 mb-2 @click="block()">
<i18n-t keypath="menu.block_account">
<b>@{{ account.acct }}</b>
</i18n-t>
</button><br>
{{ $t('report.block_desc') }}
</div>
<button btn-solid mxa mt-10 @click="emit('close')">
{{ $t('action.done') }}
</button>
</template>
</div>
</template>
<style>
tr {
border-bottom-width: 1px;
}
tr:last-child {
border: none;
}
td {
padding-top: 10px;
padding-bottom: 10px;
}
</style>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import { toggleBlockAccount, toggleMuteAccount, useRelationship } from '~~/composables/masto/relationship'
const props = defineProps<{
status: mastodon.v1.Status
@ -260,6 +261,60 @@ function showFavoritedAndBoostedBy() {
:command="command"
@click="mentionUser(status.account)"
/>
<CommonDropdownItem
v-if="!useRelationship(status.account).value?.muting"
:text="$t('menu.mute_account', [`@${status.account.acct}`])"
icon="i-ri:volume-mute-line"
:command="command"
@click="toggleMuteAccount(useRelationship(status.account).value!, status.account)"
/>
<CommonDropdownItem
v-else
:text="$t('menu.unmute_account', [`@${status.account.acct}`])"
icon="i-ri:volume-up-fill"
:command="command"
@click="toggleMuteAccount(useRelationship(status.account).value!, status.account)"
/>
<CommonDropdownItem
v-if="!useRelationship(status.account).value?.blocking"
:text="$t('menu.block_account', [`@${status.account.acct}`])"
icon="i-ri:forbid-2-line"
:command="command"
@click="toggleBlockAccount(useRelationship(status.account).value!, status.account)"
/>
<CommonDropdownItem
v-else
:text="$t('menu.unblock_account', [`@${status.account.acct}`])"
icon="i-ri:checkbox-circle-line"
:command="command"
@click="toggleBlockAccount(useRelationship(status.account).value!, status.account)"
/>
<template v-if="getServerName(status.account) && getServerName(status.account) !== currentServer">
<CommonDropdownItem
v-if="!useRelationship(status.account).value?.domainBlocking"
:text="$t('menu.block_domain', [getServerName(status.account)])"
icon="i-ri:shut-down-line"
:command="command"
@click="toggleBlockDomain(useRelationship(status.account).value!, status.account)"
/>
<CommonDropdownItem
v-else
:text="$t('menu.unblock_domain', [getServerName(status.account)])"
icon="i-ri:restart-line"
:command="command"
@click="toggleBlockDomain(useRelationship(status.account).value!, status.account)"
/>
</template>
<CommonDropdownItem
:text="$t('menu.report_account', [`@${status.account.acct}`])"
icon="i-ri:flag-2-line"
:command="command"
@click="openReportDialog(status.account, status)"
/>
</template>
</template>
</div>

View file

@ -12,6 +12,9 @@ export const mediaPreviewIndex = ref(0)
export const statusEdit = ref<mastodon.v1.StatusEdit>()
export const dialogDraftKey = ref<string>()
export const reportAccount = ref<mastodon.v1.Account>()
export const reportStatus = ref<mastodon.v1.Status>()
export const commandPanelInput = ref('')
export const isFirstVisit = useLocalStorage(STORAGE_KEY_FIRST_VISIT, !process.mock)
@ -26,6 +29,7 @@ export const isCommandPanelOpen = ref(false)
export const isConfirmDialogOpen = ref(false)
export const isErrorDialogOpen = ref(false)
export const isFavouritedBoostedByDialogOpen = ref(false)
export const isReportDialogOpen = ref(false)
export const lastPublishDialogStatus = ref<mastodon.v1.Status | null>(null)
@ -148,3 +152,13 @@ export function toggleKeyboardShortcuts() {
export function closeKeyboardShortcuts() {
isKeyboardShortcutsDialogOpen.value = false
}
export function openReportDialog(account: mastodon.v1.Account, status?: mastodon.v1.Status) {
reportAccount.value = account
reportStatus.value = status
isReportDialogOpen.value = true
}
export function closeReportDialog() {
isReportDialogOpen.value = false
}

View file

@ -31,3 +31,68 @@ async function fetchRelationships() {
for (let i = 0; i < requested.length; i++)
requested[i][1].value = relationships[i]
}
export async function toggleFollowAccount(relationship: mastodon.v1.Relationship, account: mastodon.v1.Account) {
const { client } = $(useMasto())
const i18n = useNuxtApp().$i18n
if (relationship!.following) {
if (await openConfirmDialog({
title: i18n.t('confirm.unfollow.title'),
confirm: i18n.t('confirm.unfollow.confirm'),
cancel: i18n.t('confirm.unfollow.cancel'),
}) !== 'confirm')
return
}
relationship!.following = !relationship!.following
relationship = await client.v1.accounts[relationship!.following ? 'follow' : 'unfollow'](account.id)
}
export async function toggleMuteAccount(relationship: mastodon.v1.Relationship, account: mastodon.v1.Account) {
const { client } = $(useMasto())
const i18n = useNuxtApp().$i18n
if (!relationship!.muting && await openConfirmDialog({
title: i18n.t('confirm.mute_account.title', [account.acct]),
confirm: i18n.t('confirm.mute_account.confirm'),
cancel: i18n.t('confirm.mute_account.cancel'),
}) !== 'confirm')
return
relationship!.muting = !relationship!.muting
relationship = relationship!.muting
? await client.v1.accounts.mute(account.id, {
// TODO support more options
})
: await client.v1.accounts.unmute(account.id)
}
export async function toggleBlockAccount(relationship: mastodon.v1.Relationship, account: mastodon.v1.Account) {
const { client } = $(useMasto())
const i18n = useNuxtApp().$i18n
if (!relationship!.blocking && await openConfirmDialog({
title: i18n.t('confirm.block_account.title', [account.acct]),
confirm: i18n.t('confirm.block_account.confirm'),
cancel: i18n.t('confirm.block_account.cancel'),
}) !== 'confirm')
return
relationship!.blocking = !relationship!.blocking
relationship = await client.v1.accounts[relationship!.blocking ? 'block' : 'unblock'](account.id)
}
export async function toggleBlockDomain(relationship: mastodon.v1.Relationship, account: mastodon.v1.Account) {
const { client } = $(useMasto())
const i18n = useNuxtApp().$i18n
if (!relationship!.domainBlocking && await openConfirmDialog({
title: i18n.t('confirm.block_domain.title', [getServerName(account)]),
confirm: i18n.t('confirm.block_domain.confirm'),
cancel: i18n.t('confirm.block_domain.cancel'),
}) !== 'confirm')
return
relationship!.domainBlocking = !relationship!.domainBlocking
await client.v1.domainBlocks[relationship!.domainBlocking ? 'block' : 'unblock'](getServerName(account))
}

View file

@ -55,6 +55,7 @@
"close": "Close",
"compose": "Compose",
"confirm": "Confirm",
"done": "Done",
"edit": "Edit",
"enter_app": "Enter App",
"favourite": "Favorite",
@ -243,6 +244,7 @@
"open_in_original_site": "Open in original site",
"pin_on_profile": "Pin on profile",
"remove_personal_note": "Remove personal note from {0}",
"report_account": "Report {0}",
"share_post": "Share this post",
"show_favourited_and_boosted_by": "Show who favorited and boosted",
"show_reblogs": "Show boosts from {0}",
@ -254,6 +256,7 @@
"translate_post": "Translate post",
"unblock_account": "Unblock {0}",
"unblock_domain": "Unblock domain {0}",
"unfollow_account": "Unfollow {0}",
"unmute_account": "Unmute {0}",
"unmute_conversation": "Unmute this post",
"unpin_on_profile": "Unpin on profile"
@ -353,6 +356,42 @@
}
}
},
"report": {
"additional_comments": "Additional comments",
"another_server": "The user you're reporting is from another server",
"anything_else": "Is there anything else you think we should know?",
"block_desc": "You will no longer see any posts from this user. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.",
"dontlike": "I don't like it",
"dontlike_desc": "It is not something you want to see",
"forward": "Yes, forward this report to {0}",
"forward_question": "Do you want to send an anonymized copy of this report to that server as well?",
"further_actions": {
"limit": {
"description": "Here are your options for controlling what you see:",
"title": "Don't want to see this?"
},
"report": {
"description": "While we review this, here are the actions you can take:",
"title": "Thanks for reporting, we'll look into this."
}
},
"limiting": "Limiting {0}",
"mute_desc": "You will no longer see any posts from this user. They can still follow you and see your posts. They will not know that they are muted.",
"other": "It's something else",
"other_desc": "The issue does not fit into other categories",
"reporting": "Reporting {0}",
"select_many": "Select all that apply:",
"select_one": "Choose the best match:",
"select_posts": "Are there any posts that back up this report?",
"select_posts_other": "Are there any other posts that back up this report?",
"spam": "It's spam",
"spam_desc": "Malicious links, fake engagement, or repetitive replies",
"submit": "Submit Report",
"unfollow_desc": "You will no longer see posts from this user in your home feed. You may still see posts from them elsewhere.",
"violation": "It violates one or more of the server rules",
"whats_wrong_account": "Tell us what's wrong with this account",
"whats_wrong_post": "Tell us what's wrong with this post"
},
"search": {
"search_desc": "Search for people & hashtags",
"search_empty": "Could not find anything for these search terms"