diff --git a/src/App.js b/src/App.js index 3690b944..d4b3b41a 100644 --- a/src/App.js +++ b/src/App.js @@ -5,6 +5,7 @@ import FeaturesPanel from './components/features_panel/features_panel.vue' import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import SettingsModal from './components/settings_modal/settings_modal.vue' import MediaModal from './components/media_modal/media_modal.vue' +import ModModal from './components/mod_modal/mod_modal.vue' import SideDrawer from './components/side_drawer/side_drawer.vue' import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue' import MobileNav from './components/mobile_nav/mobile_nav.vue' @@ -33,6 +34,7 @@ export default { MobileNav, DesktopNav, SettingsModal, + ModModal, UserReportingModal, PostStatusModal, EditStatusModal, diff --git a/src/App.vue b/src/App.vue index ca114c89..80ebb525 100644 --- a/src/App.vue +++ b/src/App.vue @@ -61,6 +61,7 @@ + diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 41c7b73e..986cd356 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -397,6 +397,7 @@ const afterStoreSetup = async ({ store, i18n }) => { // Start fetching things that don't need to block the UI store.dispatch('fetchMutes') store.dispatch('startFetchingAnnouncements') + store.dispatch('startFetchingReports') getTOS({ store }) getStickers({ store }) diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js index 9ba5abc4..78e93f0e 100644 --- a/src/components/desktop_nav/desktop_nav.js +++ b/src/components/desktop_nav/desktop_nav.js @@ -16,7 +16,8 @@ import { faUsers, faCommentMedical, faBookmark, - faInfoCircle + faInfoCircle, + faUserTie } from '@fortawesome/free-solid-svg-icons' library.add( @@ -34,7 +35,8 @@ library.add( faUsers, faCommentMedical, faBookmark, - faInfoCircle + faInfoCircle, + faUserTie ) export default { @@ -109,6 +111,9 @@ export default { }, openSettingsModal () { this.$store.dispatch('openSettingsModal') + }, + openModModal () { + this.$store.dispatch('openModModal') } } } diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue index 9dc02e68..0c592326 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -151,6 +151,18 @@ :title="$t('nav.preferences')" /> + + + import('./mod_modal_content.vue'), + { + loadingComponent: PanelLoading, + errorComponent: AsyncComponentError, + delay: 0 + } + ) + }, + methods: { + closeModal () { + this.$store.dispatch('closeModModal') + }, + peekModal () { + this.$store.dispatch('togglePeekModModal') + } + }, + computed: { + moderator () { + return this.$store.state.users.currentUser && + (this.$store.state.users.currentUser.role === 'admin' || + this.$store.state.users.currentUser.role === 'moderator') + }, + modalActivated () { + return this.$store.state.interface.modModalState !== 'hidden' + }, + modalOpenedOnce () { + return this.$store.state.interface.modModalLoaded + }, + modalPeeked () { + return this.$store.state.interface.modModalState === 'minimized' + } + } +} + +export default ModModal diff --git a/src/components/mod_modal/mod_modal.scss b/src/components/mod_modal/mod_modal.scss new file mode 100644 index 00000000..4821df74 --- /dev/null +++ b/src/components/mod_modal/mod_modal.scss @@ -0,0 +1,44 @@ +@import 'src/_variables.scss'; +.mod-modal { + overflow: hidden; + + &.peek { + .mod-modal-panel { + /* Explanation: + * Modal is positioned vertically centered. + * 100vh - 100% = Distance between modal's top+bottom boundaries and screen + * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen + * + 100% - we move modal completely off-screen, it's top boundary touches + * bottom of the screen + * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible + */ + transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px)); + + @media all and (max-width: 800px) { + /* For mobile, the modal takes 100% of the available screen. + This ensures the minimized modal is always 50px above the browser bottom bar regardless of whether or not it is visible. + */ + transform: translateY(calc(100% - 50px)); + } + } + } + + .mod-modal-panel { + overflow: hidden; + transition: transform; + transition-timing-function: ease-in-out; + transition-duration: 300ms; + width: 1000px; + max-width: 90vw; + height: 90vh; + + @media all and (max-width: 800px) { + max-width: 100vw; + height: 100%; + } + + .panel-body { + height: inherit; + } + } +} diff --git a/src/components/mod_modal/mod_modal.vue b/src/components/mod_modal/mod_modal.vue new file mode 100644 index 00000000..64bbf021 --- /dev/null +++ b/src/components/mod_modal/mod_modal.vue @@ -0,0 +1,43 @@ + + + + + + {{ $t('moderation.moderation') }} + + + + + + + + + + + + + + + + + diff --git a/src/components/mod_modal/mod_modal_content.js b/src/components/mod_modal/mod_modal_content.js new file mode 100644 index 00000000..e0ba6259 --- /dev/null +++ b/src/components/mod_modal/mod_modal_content.js @@ -0,0 +1,63 @@ +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' + +import ReportsTab from './tabs/reports_tab/reports_tab.vue' +// import StatusesTab from './tabs/statuses_tab.vue' +// import UsersTab from './tabs/users_tab.vue' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faFlag, + faMessage, + faUsers +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faFlag, + faMessage, + faUsers +) + +const ModModalContent = { + components: { + TabSwitcher, + + ReportsTab + // StatusesTab, + // UsersTab + }, + computed: { + open () { + return this.$store.state.interface.modModalState !== 'hidden' + }, + bodyLock () { + return this.$store.state.interface.modModalState === 'visible' + } + }, + methods: { + onOpen () { + const targetTab = this.$store.state.interface.modModalTargetTab + // We're being told to open in specific tab + if (targetTab) { + const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => { + return elm.props && elm.props['data-tab-name'] === targetTab + }) + if (tabIndex >= 0) { + this.$refs.tabSwitcher.setTab(tabIndex) + } + } + // Clear the state of target tab, so that next time moderation is opened + // it doesn't force it. + this.$store.dispatch('clearModModalTargetTab') + } + }, + mounted () { + this.onOpen() + }, + watch: { + open: function (value) { + if (value) this.onOpen() + } + } +} + +export default ModModalContent diff --git a/src/components/mod_modal/mod_modal_content.scss b/src/components/mod_modal/mod_modal_content.scss new file mode 100644 index 00000000..b1aeba38 --- /dev/null +++ b/src/components/mod_modal/mod_modal_content.scss @@ -0,0 +1,21 @@ +@import 'src/_variables.scss'; +.mod_tab-switcher { + height: 100%; + + .content { + margin: 1em 1em 1.4em; + + > div { + margin-bottom: .5em; + &:last-child { + margin-bottom: 0; + } + } + + textarea { + width: 100%; + max-width: 100%; + height: 100px; + } + } +} diff --git a/src/components/mod_modal/mod_modal_content.vue b/src/components/mod_modal/mod_modal_content.vue new file mode 100644 index 00000000..6fa32be1 --- /dev/null +++ b/src/components/mod_modal/mod_modal_content.vue @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/src/components/mod_modal/tabs/reports_tab/report_card.js b/src/components/mod_modal/tabs/reports_tab/report_card.js new file mode 100644 index 00000000..6e6bfdae --- /dev/null +++ b/src/components/mod_modal/tabs/reports_tab/report_card.js @@ -0,0 +1,124 @@ +import Popover from 'src/components/popover/popover.vue' +import Status from 'src/components/status/status.vue' +import UserAvatar from 'src/components/user_avatar/user_avatar.vue' +import ReportNote from './report_note.vue' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faChevronDown, + faChevronUp +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faChevronDown, + faChevronUp +) + +const FORCE_NSFW = 'mrf_tag:media-force-nsfw' +const STRIP_MEDIA = 'mrf_tag:media-strip' +const FORCE_UNLISTED = 'mrf_tag:force-unlisted' +const SANDBOX = 'mrf_tag:sandbox' + +const ReportCard = { + data () { + return { + hidden: true, + statusesHidden: true, + notesHidden: true, + note: null, + tags: { + FORCE_NSFW, + STRIP_MEDIA, + FORCE_UNLISTED, + SANDBOX + } + } + }, + props: [ + 'account', + 'actor', + 'content', + 'id', + 'notes', + 'state', + 'statuses' + ], + components: { + ReportNote, + Popover, + Status, + UserAvatar + }, + created () { + this.$store.dispatch('fetchUser', this.account.id) + }, + computed: { + isOpen () { + return this.state === 'open' + }, + tagPolicyEnabled () { + return this.$store.state.instance.federationPolicy.mrf_policies.includes('TagPolicy') + }, + user () { + return this.$store.getters.findUser(this.account.id) + } + }, + methods: { + toggleHidden () { + this.hidden = !this.hidden + }, + decode (content) { + content = content.replaceAll('', '\n') + const textarea = document.createElement('textarea') + textarea.innerHTML = content + return textarea.value + }, + updateReportState (state) { + this.$store.dispatch('updateReportStates', { reports: [{ id: this.id, state }] }) + }, + toggleNotes () { + this.notesHidden = !this.notesHidden + }, + addNoteToReport () { + if (this.note.length > 0) { + this.$store.dispatch('addNoteToReport', { id: this.id, note: this.note }) + this.note = null + } + }, + toggleStatuses () { + this.statusesHidden = !this.statusesHidden + }, + hasTag (tag) { + return this.user.tags.includes(tag) + }, + toggleTag (tag) { + if (this.hasTag(tag)) { + this.$store.state.api.backendInteractor.untagUser({ user: this.user, tag }).then(response => { + if (!response.ok) { return } + this.$store.commit('untagUser', { user: this.user, tag }) + }) + } else { + this.$store.state.api.backendInteractor.tagUser({ user: this.user, tag }).then(response => { + if (!response.ok) { return } + this.$store.commit('tagUser', { user: this.user, tag }) + }) + } + }, + toggleActivationStatus () { + this.$store.dispatch('toggleActivationStatus', { user: this.user }) + }, + deleteUser () { + this.$store.state.backendInteractor.deleteUser({ user: this.user }) + .then(e => { + this.$store.dispatch('markStatusesAsDeleted', status => this.user.id === status.user.id) + const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile' + const isTargetUser = this.$route.params.name === this.user.name || this.$route.params.id === this.user.id + if (isProfile && isTargetUser) { + window.history.back() + } + }) + } + } +} + +export default ReportCard diff --git a/src/components/mod_modal/tabs/reports_tab/report_card.vue b/src/components/mod_modal/tabs/reports_tab/report_card.vue new file mode 100644 index 00000000..6cc034b1 --- /dev/null +++ b/src/components/mod_modal/tabs/reports_tab/report_card.vue @@ -0,0 +1,202 @@ + + + + {{ $t('moderation.reports.report') + ' ' + this.account.screen_name }} + + {{ $t('moderation.reports.close') }} + + + {{ $t('moderation.reports.resolve') }} + + + {{ $t('moderation.reports.reopen') }} + + + + + + {{ decode(content) }} + + + {{ $t('moderation.reports.no_content') }} + + + + {{ this.actor.screen_name }} + + + + + {{ this.statuses.length + ' ' + $t('moderation.reports.statuses') }} + + + + + + + + + {{ this.notes.length + ' ' + $t('moderation.reports.notes') }} + + + + + + + + + + {{ $t('moderation.reports.add_note') }} + + + + + + + + diff --git a/src/components/mod_modal/tabs/reports_tab/report_note.js b/src/components/mod_modal/tabs/reports_tab/report_note.js new file mode 100644 index 00000000..efcc8664 --- /dev/null +++ b/src/components/mod_modal/tabs/reports_tab/report_note.js @@ -0,0 +1,37 @@ +import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue' +import Timeago from 'src/components/timeago/timeago.vue' +import UserAvatar from 'src/components/user_avatar/user_avatar.vue' + +const ReportNote = { + data () { + return { + showingDeleteDialog: false + } + }, + props: [ + 'content', + 'created_at', + 'user', + 'report_id', + 'id' + ], + components: { + ConfirmModal, + Timeago, + UserAvatar + }, + methods: { + deleteNoteFromReport () { + this.$store.dispatch('deleteNoteFromReport', { id: this.report_id, note: this.id }) + this.showingDeleteDialog = false + }, + showDeleteDialog () { + this.showingDeleteDialog = true + }, + hideDeleteDialog () { + this.showingDeleteDialog = false + } + } +} + +export default ReportNote diff --git a/src/components/mod_modal/tabs/reports_tab/report_note.vue b/src/components/mod_modal/tabs/reports_tab/report_note.vue new file mode 100644 index 00000000..49b3bd14 --- /dev/null +++ b/src/components/mod_modal/tabs/reports_tab/report_note.vue @@ -0,0 +1,43 @@ + + + + + + {{ this.user.screen_name }} + + + + + {{ $t('moderation.reports.delete_note') }} + + + + + {{ content }} + + + {{ $t('moderation.reports.delete_note_confirm') }} + + + + + diff --git a/src/components/mod_modal/tabs/reports_tab/reports_tab.js b/src/components/mod_modal/tabs/reports_tab/reports_tab.js new file mode 100644 index 00000000..1babc7ca --- /dev/null +++ b/src/components/mod_modal/tabs/reports_tab/reports_tab.js @@ -0,0 +1,26 @@ +import { filter } from 'lodash' + +import ReportCard from './report_card.vue' +import Checkbox from 'src/components/checkbox/checkbox.vue' + +const ReportsTab = { + data () { + return { + showClosed: false + } + }, + components: { + Checkbox, + ReportCard + }, + computed: { + reports () { + return this.$store.state.reports.reports + }, + openReports () { + return filter(this.reports, { state: 'open' }) + } + } +} + +export default ReportsTab diff --git a/src/components/mod_modal/tabs/reports_tab/reports_tab.scss b/src/components/mod_modal/tabs/reports_tab/reports_tab.scss new file mode 100644 index 00000000..607f6726 --- /dev/null +++ b/src/components/mod_modal/tabs/reports_tab/reports_tab.scss @@ -0,0 +1,83 @@ +@import '../../../../_variables.scss'; +.report-card { + .report-body { + & > * { + padding: 1em; + } + + & > :not(:last-child) { + border-bottom: 1px solid; + border-bottom-color: var(--border, #222); + } + + .report-content { + white-space: pre-wrap; + } + + .report-author { + padding-top: 0.5em; + } + .small-avatar { + height: 25px; + width: 25px; + padding-right: 0.4em; + vertical-align: middle; + } + + .dropdown { + display: flex; + flex-direction: column; + padding: 0; + + .dropdown-header { + padding: 1em; + color: var(--link, $fallback--link); + + &:hover { + background-color: var(--selectedMenu, $fallback--lightBg); + color: var(--selectedMenuText, $fallback--link); + } + } + } + + .report-note { + padding: 1em; + + .note-header { + display: flex; + justify-content: space-between; + padding-bottom: 0.5em; + } + + button { + margin-left: 0.5em; + } + } + + .report-add-note { + textarea { + resize: none; + } + + button { + min-height: 2em; + min-width: 10em; + padding: 0 2em; + margin-top: 0.5em; + } + } + } + + .panel-footer { + display: flex; + & > * { + margin-right: 0.5em; + } + } +} + +.reports-header { + display: flex; + align-items: center; + justify-content: space-between; +} diff --git a/src/components/mod_modal/tabs/reports_tab/reports_tab.vue b/src/components/mod_modal/tabs/reports_tab/reports_tab.vue new file mode 100644 index 00000000..aecda2e9 --- /dev/null +++ b/src/components/mod_modal/tabs/reports_tab/reports_tab.vue @@ -0,0 +1,20 @@ + + + + + {{ $t('moderation.reports.reports') }} + + {{ $t('moderation.reports.show_closed') }} + + + + + + + + + diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 1c8d5865..4e6e9edd 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -15,7 +15,8 @@ import { faTachometerAlt, faCog, faInfoCircle, - faList + faList, + faUserTie } from '@fortawesome/free-solid-svg-icons' library.add( @@ -30,7 +31,8 @@ library.add( faTachometerAlt, faCog, faInfoCircle, - faList + faList, + faUserTie ) const SideDrawer = { @@ -102,6 +104,9 @@ const SideDrawer = { }, openSettingsModal () { this.$store.dispatch('openSettingsModal') + }, + openModModal () { + this.$store.dispatch('openModModal') } } } diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index 6d78fa85..86943e27 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -143,6 +143,21 @@ /> {{ $t("nav.about") }} + + + {{ $t("nav.moderation") }} + + state.interface.settingsModalState + settingsModalState: state => state.interface.settingsModalState, + modModalState: state => state.interface.modModalState }) }, beforeUpdate () { diff --git a/src/i18n/en.json b/src/i18n/en.json index e920cf11..9cb5e3c7 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -266,6 +266,31 @@ "next": "Next", "previous": "Previous" }, + "moderation": { + "moderation": "Moderation", + "reports": { + "add_note": "Add note", + "close": "Close", + "delete_note": "Delete", + "delete_note_accept": "Yes, delete it", + "delete_note_cancel": "No, keep it", + "delete_note_confirm": "Are you sure you want to delete this note?", + "delete_note_title": "Confirm deletion", + "no_content": "No description given", + "note_placeholder": "Leave a note...", + "notes": "notes", + "reopen": "Reopen", + "report": "Report on", + "reports": "Reports", + "resolve": "Resolve", + "show_closed": "Show closed", + "statuses": "statuses", + "tag_policy_notice": "Enable the TagPolicy MRF to set post restrictions", + "tags": "Set post restrictions" + }, + "statuses": "Statuses", + "users": "Users" + }, "nav": { "about": "About", "administration": "Administration", @@ -282,6 +307,7 @@ "interactions": "Interactions", "lists": "Lists", "mentions": "Mentions", + "moderation": "Moderation", "preferences": "Preferences", "public_timeline_description": "Public posts from this instance", "public_tl": "Public timeline", diff --git a/src/modules/api.js b/src/modules/api.js index e2b3b37b..c54aa4fb 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -163,6 +163,7 @@ const api = { dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingNotifications') dispatch('startFetchingAnnouncements') + dispatch('startFetchingReports') dispatch('pushGlobalNotice', { level: 'error', messageKey: 'timeline.socket_broke', @@ -280,6 +281,19 @@ const api = { if (!fetcher) return store.commit('removeFetcher', { fetcherName: 'announcements', fetcher }) }, + + // Reports + startFetchingReports (store) { + if (store.state.fetchers['reports']) return + const fetcher = store.state.backendInteractor.startFetchingReports({ store }) + store.commit('addFetcher', { fetcherName: 'reports', fetcher }) + }, + stopFetchingReports (store) { + const fetcher = store.state.fetchers.reports + if (!fetcher) return + store.commit('removeFetcher', { fetcherName: 'reports', fetcher }) + }, + getSupportedTranslationlanguages (store) { store.state.backendInteractor.getSupportedTranslationlanguages({ store }) .then((data) => { diff --git a/src/modules/interface.js b/src/modules/interface.js index a86193ea..ae1a31c3 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -2,6 +2,9 @@ const defaultState = { settingsModalState: 'hidden', settingsModalLoaded: false, settingsModalTargetTab: null, + modModalState: 'hidden', + modModalLoaded: false, + modModalTargetTab: null, settings: { currentSaveStateNotice: null, noticeClearTimeout: null, @@ -63,6 +66,30 @@ const interfaceMod = { setSettingsModalTargetTab (state, value) { state.settingsModalTargetTab = value }, + closeModModal (state) { + state.modModalState = 'hidden' + }, + togglePeekModModal (state) { + switch (state.modModalState) { + case 'minimized': + state.modModalState = 'visible' + return + case 'visible': + state.modModalState = 'minimized' + return + default: + throw new Error('Illegal minimization state of mod modal') + } + }, + openModModal (state) { + state.modModalState = 'visible' + if (!state.modModalLoaded) { + state.modModalLoaded = true + } + }, + setModModalTargetTab (state, value) { + state.modModalTargetTab = value + }, pushGlobalNotice (state, notice) { state.globalNotices.push(notice) }, @@ -105,6 +132,18 @@ const interfaceMod = { commit('setSettingsModalTargetTab', value) commit('openSettingsModal') }, + closeModModal ({ commit }) { + commit('closeModModal') + }, + openModModal ({ commit }) { + commit('openModModal') + }, + togglePeekModModal ({ commit }) { + commit('togglePeekModModal') + }, + clearModModalTargetTab ({ commit }) { + commit('setModModalTargetTab', null) + }, pushGlobalNotice ( { commit, dispatch, state }, { diff --git a/src/modules/reports.js b/src/modules/reports.js index fea83e5f..5130b989 100644 --- a/src/modules/reports.js +++ b/src/modules/reports.js @@ -1,11 +1,17 @@ -import filter from 'lodash/filter' +import { filter, find, forEach, remove } from 'lodash' + +const getReport = (state, id) => find(state.reports, { id }) +const updateReport = (state, { report, param, value }) => { + getReport(state, report.id)[param] = value +} const reports = { state: { userId: null, statuses: [], preTickedIds: [], - modalActivated: false + modalActivated: false, + reports: [] }, mutations: { openUserReportingModal (state, { userId, statuses, preTickedIds }) { @@ -16,6 +22,38 @@ const reports = { }, closeUserReportingModal (state) { state.modalActivated = false + }, + setReport (state, { report }) { + let existing = getReport(state, report.id) + if (existing) { + existing = report + } else { + state.reports.push(report) + } + }, + updateReportStates (state, { reports }) { + forEach(reports, (report) => { + updateReport(state, { report, param: 'state', value: report.state }) + }) + }, + addNoteToReport (state, { id, note, user }) { + // akkoma doesn't return the note from this API endpoint, and there's no + // good way to get it. the note data is spoofed in the frontend until + // reload. + // definitely worth adding this to the backend at some point + const report = getReport(state, id) + const date = new Date() + + report.notes.push({ + content: note, + user, + created_at: date.toISOString(), + id: date.getTime() + }) + }, + deleteNoteFromReport (state, { id, note }) { + const report = getReport(state, id) + remove(report.notes, { id: note }) } }, actions: { @@ -31,6 +69,22 @@ const reports = { }, closeUserReportingModal ({ commit }) { commit('closeUserReportingModal') + }, + updateReportStates ({ rootState, commit }, { reports }) { + commit('updateReportStates', { reports }) + return rootState.api.backendInteractor.updateReportStates({ reports }) + }, + getReport ({ rootState, commit }, { id }) { + return rootState.api.backendInteractor.getReport({ id }) + .then(report => commit('setReport', { report })) + }, + addNoteToReport ({ rootState, commit }, { id, note }) { + commit('addNoteToReport', { id, note, user: rootState.users.currentUser }) + return rootState.api.backendInteractor.addNoteToReport({ id, note }) + }, + deleteNoteFromReport ({ rootState, commit }, { id, note }) { + commit('deleteNoteFromReport', { id, note }) + return rootState.api.backendInteractor.deleteNoteFromReport({ id, note }) } } } diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 0cacf251..4e3e1ced 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -1,5 +1,5 @@ import { each, map, concat, last, get } from 'lodash' -import { parseStatus, parseSource, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseSource, parseUser, parseNotification, parseReport, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' import { RegistrationError, StatusCodeError } from '../errors/errors' /* eslint-env browser */ @@ -19,6 +19,9 @@ const ADMIN_USERS_URL = '/api/pleroma/admin/users' const SUGGESTIONS_URL = '/api/v1/suggestions' const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings' const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read' +const ADMIN_REPORTS_URL = '/api/v1/pleroma/admin/reports' +const ADMIN_REPORT_NOTES_URL = id => `/api/v1/pleroma/admin/reports/${id}/notes` +const ADMIN_REPORT_NOTE_URL = (report, note) => `/api/v1/pleroma/admin/reports/${report}/notes/${note}` const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa' const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes' @@ -342,7 +345,7 @@ const fetchUserRelationship = ({ id, credentials }) => { return new Promise((resolve, reject) => response.json() .then((json) => { if (!response.ok) { - return reject(new StatusCodeError(response.status, json, { url }, response)) + return reject(new StatusCodeError(400, json, { url }, response)) } return resolve(json) })) @@ -635,6 +638,57 @@ const deleteUser = ({ credentials, user }) => { }) } +const getReports = ({ state, limit, page, pageSize, credentials }) => { + let url = ADMIN_REPORTS_URL + const args = [ + state && `state=${state}`, + limit && `limit=${limit}`, + page && `page=${page}`, + pageSize && `page_size=${pageSize}` + ].filter(_ => _).join('&') + + url = url + (args ? '?' + args : '') + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) + .then((data) => data.reports.map(parseReport)) +} + +const updateReportStates = ({ credentials, reports }) => { + // reports syntax: [{ id: int, state: string }...] + const updates = { + reports: reports.map(report => { + return { + id: report.id.toString(), + state: report.state + } + }) + } + + return promisedRequest({ + url: ADMIN_REPORTS_URL, + method: 'PATCH', + payload: updates, + credentials + }) +} + +const addNoteToReport = ({ id, note, credentials }) => { + return promisedRequest({ + url: ADMIN_REPORT_NOTES_URL(id), + method: 'POST', + payload: { content: note }, + credentials + }) +} + +const deleteNoteFromReport = ({ report, note, credentials }) => { + return promisedRequest({ + url: ADMIN_REPORT_NOTE_URL(report, note), + method: 'DELETE', + credentials + }) +} + const fetchTimeline = ({ timeline, credentials, @@ -1726,7 +1780,11 @@ const apiService = { getSettingsProfile, saveSettingsProfile, listSettingsProfiles, - deleteSettingsProfile + deleteSettingsProfile, + getReports, + updateReportStates, + addNoteToReport, + deleteNoteFromReport } export default apiService diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 596151d8..4d6f80c2 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -5,6 +5,7 @@ import followRequestFetcher from '../../services/follow_request_fetcher/follow_r import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js' import announcementsFetcher from '../../services/announcements_fetcher/announcements_fetcher.service.js' import configFetcher from '../config_fetcher/config_fetcher.service.js' +import reportsFetcher from '../reports_fetcher/reports_fetcher.service.js' const backendInteractorService = credentials => ({ startFetchingTimeline ({ timeline, store, userId = false, listId = false, tag }) { @@ -39,6 +40,10 @@ const backendInteractorService = credentials => ({ return announcementsFetcher.startFetching({ store, credentials }) }, + startFetchingReports ({ store, state, limit, page, pageSize }) { + return reportsFetcher.startFetching({ store, credentials, state, limit, page, pageSize }) + }, + startUserSocket ({ store }) { const serv = store.rootState.instance.server.replace('http', 'ws') const url = serv + getMastodonSocketURI({ credentials, stream: 'user' }) diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index b1aded33..a2fa741f 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -429,6 +429,24 @@ export const parseNotification = (data) => { return output } +export const parseReport = (data) => { + const report = {} + + report.account = parseUser(data.account) + report.actor = parseUser(data.actor) + report.statuses = data.statuses.map(parseStatus) + report.notes = data.notes.map(note => { + note.user = parseUser(note.user) + return note + }) + report.state = data.state + report.content = data.content + report.created_at = data.created_at + report.id = data.id + + return report +} + const isNsfw = (status) => { const nsfwRegex = /#nsfw/i return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex) diff --git a/src/services/reports_fetcher/reports_fetcher.service.js b/src/services/reports_fetcher/reports_fetcher.service.js new file mode 100644 index 00000000..f0bb9dcf --- /dev/null +++ b/src/services/reports_fetcher/reports_fetcher.service.js @@ -0,0 +1,20 @@ +import apiService from '../api/api.service.js' +import { promiseInterval } from '../promise_interval/promise_interval.js' +import { forEach } from 'lodash' + +const fetchAndUpdate = ({ store, credentials, state, limit, page, pageSize }) => { + return apiService.getReports({ credentials, state, limit, page, pageSize }) + .then(reports => forEach(reports, report => store.commit('setReport', { report }))) +} + +const startFetching = ({ store, credentials, state, limit, page, pageSize }) => { + const boundFetchAndUpdate = () => fetchAndUpdate({ store, credentials, state, limit, page, pageSize }) + boundFetchAndUpdate() + return promiseInterval(boundFetchAndUpdate, 60000) +} + +const reportsFetcher = { + startFetching +} + +export default reportsFetcher diff --git a/yarn.lock b/yarn.lock index 86ae6b85..b8642176 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1092,12 +1092,12 @@ "@fortawesome/fontawesome-common-types@^0.3.0": version "0.3.0" - resolved "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.3.0.tgz" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.3.0.tgz#949995a05c0d8801be7e0a594f775f1dbaa0d893" integrity sha512-CA3MAZBTxVsF6SkfkHXDerkhcQs0QPofy43eFdbWJJkZiq3SfiaH1msOkac59rQaqto5EqWnASboY1dBuKen5w== "@fortawesome/fontawesome-svg-core@1.3.0": version "1.3.0" - resolved "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.3.0.tgz" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.3.0.tgz#343fac91fa87daa630d26420bfedfba560f85885" integrity sha512-UIL6crBWhjTNQcONt96ExjUnKt1D68foe3xjEensLDclqQ6YagwCRYVQdrp/hW0ALRp/5Fv/VKw+MqTUWYYvPg== dependencies: "@fortawesome/fontawesome-common-types" "^0.3.0" @@ -1141,9 +1141,9 @@ integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== "@intlify/bundle-utils@next": - version "3.1.2" - resolved "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-3.1.2.tgz" - integrity sha512-amgSo0NN5OKWYdcgFmfJqo2tcUcZ6C66Bxm5ALQnB0m3MUQtS9aJzKoIo+EU9XQiOVmlBFxRtNoZm+psHa5FNA== + version "3.2.1" + resolved "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-3.2.1.tgz" + integrity sha512-rf4cLBOnbqmpXVcCdcYHilZpMt1m82syh3WLBJlZvGxN2KkH9HeHVH4+bnibF/SDXCHNh6lM6wTpS/qw+PkcMg== dependencies: "@intlify/message-compiler" next "@intlify/shared" next @@ -1168,7 +1168,7 @@ dependencies: "@intlify/shared" "9.2.2" -"@intlify/message-compiler@9.2.2": +"@intlify/message-compiler@9.2.2", "@intlify/message-compiler@next": version "9.2.2" resolved "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.2.2.tgz" integrity sha512-IUrQW7byAKN2fMBe8z6sK6riG1pue95e5jfokn8hA5Q3Bqy4MBJ5lJAofUsawQJYHeoPJ7svMDyBaVJ4d0GTtA== @@ -1176,24 +1176,11 @@ "@intlify/shared" "9.2.2" source-map "0.6.1" -"@intlify/message-compiler@next": - version "9.3.0-beta.3" - resolved "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.3.0-beta.3.tgz" - integrity sha512-j8OwToBQgs01RBMX4GCDNQfcnmw3AiDG3moKIONTrfXcf+1yt/rWznLTYH/DXbKcFMAFijFpCzMYjUmH1jVFYA== - dependencies: - "@intlify/shared" "9.3.0-beta.3" - source-map "0.6.1" - "@intlify/shared@9.2.2", "@intlify/shared@next": version "9.2.2" resolved "https://registry.npmjs.org/@intlify/shared/-/shared-9.2.2.tgz" integrity sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q== -"@intlify/shared@9.3.0-beta.3": - version "9.3.0-beta.3" - resolved "https://registry.npmjs.org/@intlify/shared/-/shared-9.3.0-beta.3.tgz" - integrity sha512-Z/0TU4GhFKRxKh+0RbwJExik9zz57gXYgxSYaPn7YQdkQ/pabSioCY/SXnYxQHL6HzULF5tmqarFm6glbGqKhw== - "@intlify/vue-devtools@9.2.2": version "9.2.2" resolved "https://registry.npmjs.org/@intlify/vue-devtools/-/vue-devtools-9.2.2.tgz" @@ -8095,9 +8082,9 @@ mute-stream@0.0.7: integrity sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ== nan@^2.12.1: - version "2.16.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.16.0.tgz#664f43e45460fb98faf00edca0bb0d7b8dce7916" - integrity sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA== + version "2.17.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" + integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== nanoid@^3.3.4: version "3.3.4"