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 9f0ff4d3..986cd356 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -148,6 +148,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => { copyInstanceOption('showWiderShortcuts') copyInstanceOption('showNavShortcuts') copyInstanceOption('showPanelNavShortcuts') + copyInstanceOption('stopGifs') copyInstanceOption('logo') store.dispatch('setInstanceOption', { @@ -396,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/attachment/attachment.scss b/src/components/attachment/attachment.scss index 2ef5cf4d..484ca0c4 100644 --- a/src/components/attachment/attachment.scss +++ b/src/components/attachment/attachment.scss @@ -26,6 +26,7 @@ display: flex; padding-top: 0.5em; z-index: 1; + max-height: 50%; p { flex: 1; @@ -36,7 +37,7 @@ white-space: pre-line; word-break: break-word; text-overflow: ellipsis; - overflow: hidden; + overflow: scroll; } &.-static { diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js index 9ba5abc4..f4900c38 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 { @@ -98,6 +100,9 @@ export default { privateMode () { return this.$store.state.instance.private }, shouldConfirmLogout () { return this.$store.getters.mergedConfig.modalOnLogout + }, + showBubbleTimeline () { + return this.$store.state.instance.localBubbleInstances.length > 0 } }, methods: { @@ -109,6 +114,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..92d3fa5b 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -55,7 +55,7 @@ /> @@ -151,6 +151,18 @@ :title="$t('nav.preferences')" /> + 0 || suggestion) { const chosenSuggestion = suggestion || this.suggestions[this.highlighted] const replacement = chosenSuggestion.replacement diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index 7d95ab7e..078253c2 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -42,7 +42,7 @@ :class="{ highlighted: index === highlighted }" @click.stop.prevent="onClick($event, suggestion)" > - + ({ displayText: tag, detailText: '$[' + tag + ' ]', replacement: '$[' + tag + ' ]', mfm: true })) + /** * suggest - generates a suggestor function to be used by emoji-input * data: object providing source information for specific types of suggestions: @@ -21,6 +24,10 @@ export default data => { if (firstChar === '@' && usersCurry) { return usersCurry(input) } + if (firstChar === '$') { + return MFM_TAGS + .filter(({ replacement }) => replacement.toLowerCase().indexOf(input) !== -1) + } return [] } } diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index b5cc1c63..ac7b8b5d 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -1,5 +1,25 @@ @import '../../_variables.scss'; +.Notification { + .emoji-picker { + min-width: 160%; + width: 150%; + overflow: hidden; + left: -70%; + max-width: 100%; + @media (min-width: 800px) and (max-width: 1300px) { + left: -50%; + min-width: 50%; + max-width: 130%; + } + + @media (max-width: 800px) { + left: -10%; + min-width: 50%; + max-width: 130%; + } + } +} .emoji-picker { display: flex; flex-direction: column; diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue index 6d9713ff..d9c568f6 100644 --- a/src/components/emoji_reactions/emoji_reactions.vue +++ b/src/components/emoji_reactions/emoji_reactions.vue @@ -92,7 +92,7 @@ } } -.picked-reaction { +.button-default.picked-reaction { border: 1px solid var(--accent, $fallback--link); margin-left: -1px; // offset the border, can't use inset shadows either margin-right: calc(0.5em - 1px); diff --git a/src/components/mod_modal/mod_modal.js b/src/components/mod_modal/mod_modal.js new file mode 100644 index 00000000..fb11ef87 --- /dev/null +++ b/src/components/mod_modal/mod_modal.js @@ -0,0 +1,58 @@ +import Modal from 'src/components/modal/modal.vue' +import PanelLoading from 'src/components/panel_loading/panel_loading.vue' +import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue' +import getResettableAsyncComponent from 'src/services/resettable_async_component.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faTimes, + faChevronDown +} from '@fortawesome/free-solid-svg-icons' +import { + faWindowMinimize +} from '@fortawesome/free-regular-svg-icons' + +library.add( + faTimes, + faWindowMinimize, + faChevronDown +) + +const ModModal = { + components: { + Modal, + ModModalContent: getResettableAsyncComponent( + () => 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 @@ + + + + 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..e77afa79 --- /dev/null +++ b/src/components/mod_modal/tabs/reports_tab/report_card.vue @@ -0,0 +1,202 @@ +