Page MenuHomePhorge

No OneTemporary

Size
401 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/biome.json b/biome.json
index 6a464a0e51..5ef6f79eeb 100644
--- a/biome.json
+++ b/biome.json
@@ -1,149 +1,150 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist", "!!tools/emojis.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"domains": {
"vue": "recommended"
},
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error"
},
"correctness": {
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
+ "noUnusedImports": "error",
"useIsNan": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded"
},
"globals": []
},
"overrides": [
{
"includes": ["**/*.spec.js", "test/fixtures/*.js"],
"javascript": {
"globals": [
"vi",
"describe",
"it",
"test",
"expect",
"before",
"beforeEach",
"after",
"afterEach"
]
}
},
{
"includes": ["**/*.vue"],
"linter": {
"rules": {
"style": {
"useConst": "off",
"useImportType": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off"
}
}
}
}
],
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
[":NODE:", ":PACKAGE:", "!src/**", "!@fortawesome/**"],
":BLANK_LINE:",
[":PATH:", "src/components/**"],
":BLANK_LINE:",
[":PATH:", "src/stores/**"],
":BLANK_LINE:",
[":PATH:", "src/**", "src/stores/**", "src/components/**"],
":BLANK_LINE:",
"@fortawesome/fontawesome-svg-core",
"@fortawesome/*"
]
}
}
}
}
}
}
diff --git a/build/sw_plugin.js b/build/sw_plugin.js
index f278d55f62..2f0a4819d8 100644
--- a/build/sw_plugin.js
+++ b/build/sw_plugin.js
@@ -1,152 +1,151 @@
-import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { exactRegex } from '@rolldown/pluginutils'
import { build } from 'vite'
import {
generateServiceWorkerMessages,
i18nFiles,
} from './service_worker_messages.js'
const getSWMessagesAsText = async () => {
const messages = await generateServiceWorkerMessages()
return `export default ${JSON.stringify(messages, undefined, 2)}`
}
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)))
// Idea taken from
// https://github.com/vite-pwa/vite-plugin-pwa/blob/main/src/plugins/build.ts
// rollup does not support compiling to iife if we want to code-split;
// however, we must compile the service worker to iife because of browser support.
// Run another vite build just for the service worker targeting iife at
// the end of the build.
export const buildSwPlugin = ({ swSrc, swDest }) => {
const swFullSrc = resolve(projectRoot, swSrc)
const swEnvName = 'virtual:pleroma-fe/service_worker_env'
const swEnvNameResolved = '\0' + swEnvName
let config
return {
name: 'build-sw-plugin',
enforce: 'post',
configResolved(resolvedConfig) {
resolvedConfig
config = {
define: resolvedConfig.define,
resolve: resolvedConfig.resolve,
plugins: [swMessagesPlugin()],
publicDir: false,
build: {
...resolvedConfig.build,
emptyOutDir: false,
rolldownOptions: {
input: {
main: swSrc,
},
context: 'self',
output: {
entryFileNames: swDest,
codeSplitting: false,
format: 'iife',
},
},
},
configFile: false,
}
},
generateBundle: {
order: 'post',
sequential: true,
async handler(_, bundle) {
const assets = Object.keys(bundle)
.filter((name) => !/\.map$/.test(name))
.map((name) => '/' + name)
config.plugins.push({
name: 'build-sw-env-plugin',
mode: 'production',
resolveId: {
filter: { id: exactRegex(swEnvName) },
handler: () => swEnvNameResolved,
},
load: {
filter: { id: exactRegex(swEnvNameResolved) },
handler() {
return `self.serviceWorkerOption = { assets: ${JSON.stringify(assets)} };`
},
},
})
},
},
resolveId: {
filter: { id: new RegExp(swDest) },
handler() {
return swFullSrc
},
},
load: {
filter: { id: new RegExp(swFullSrc) },
async handler() {
config.plugins.push({
name: 'dummy-sw-env',
mode: 'development',
resolveId: {
filter: { id: exactRegex(swEnvName) },
handler: () => swEnvNameResolved,
},
load: {
filter: { id: exactRegex(swEnvNameResolved) },
handler: () => 'self.serviceWorkerOption = { assets: [] }',
},
})
try {
const swBundle = await build(config)
return swBundle.output[0]
} catch (e) {
console.error('Error building ServiceWorker:', e)
}
},
},
closeBundle: {
order: 'post',
sequential: true,
async handler() {
if (process.env.VITEST) return
console.info('Building service worker for production')
try {
await build(config)
} catch (e) {
console.error('Error building ServiceWorker:', e)
}
},
},
}
}
const swMessagesName = 'virtual:pleroma-fe/service_worker_messages'
const swMessagesNameResolved = '\0' + swMessagesName
export const swMessagesPlugin = () => {
return {
name: 'sw-messages-plugin',
resolveId(id) {
if (id === swMessagesName) {
Object.values(i18nFiles).forEach((f) => {
this.addWatchFile(f)
})
return swMessagesNameResolved
} else {
return null
}
},
async load(id) {
if (id === swMessagesNameResolved) {
return await getSWMessagesAsText()
}
return null
},
}
}
diff --git a/src/App.js b/src/App.js
index ea2271a754..7de7a06963 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,307 +1,304 @@
import { throttle } from 'lodash'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import DesktopNav from 'src/components/desktop_nav/desktop_nav.vue'
import FeaturesPanel from 'src/components/features_panel/features_panel.vue'
import GlobalNoticeList from 'src/components/global_notice_list/global_notice_list.vue'
import InstanceSpecificPanel from 'src/components/instance_specific_panel/instance_specific_panel.vue'
import MobileNav from 'src/components/mobile_nav/mobile_nav.vue'
import MobilePostStatusButton from 'src/components/mobile_post_status_button/mobile_post_status_button.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import UserPanel from 'src/components/user_panel/user_panel.vue'
import { getOrCreateServiceWorker } from './services/sw/sw'
import { windowHeight, windowWidth } from './services/window_utils/window_utils'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useShoutStore } from 'src/stores/shout.js'
-import messages from 'src/i18n/messages'
-import localeService from 'src/services/locale/locale.service.js'
-
// Helper to unwrap reactive proxies
window.toValue = (x) => JSON.parse(JSON.stringify(x))
export default {
name: 'app',
components: {
UserPanel,
NavPanel,
Notifications: defineAsyncComponent(
() => import('src/components/notifications/notifications.vue'),
),
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel: defineAsyncComponent(
() =>
import('src/components/who_to_follow_panel/who_to_follow_panel.vue'),
),
ShoutPanel: defineAsyncComponent(
() => import('src/components/shout_panel/shout_panel.vue'),
),
MediaModal: defineAsyncComponent(
() => import('src/components/media_modal/media_modal.vue'),
),
MobilePostStatusButton,
MobileNav,
DesktopNav,
SettingsModal: defineAsyncComponent(
() => import('src/components/settings_modal/settings_modal.vue'),
),
UpdateNotification: defineAsyncComponent(
() =>
import('src/components/update_notification/update_notification.vue'),
),
PostStatusModal: defineAsyncComponent(
() => import('src/components/post_status_modal/post_status_modal.vue'),
),
UserReportingModal: defineAsyncComponent(
() =>
import('src/components/user_reporting_modal/user_reporting_modal.vue'),
),
EditStatusModal: defineAsyncComponent(
() => import('src/components/edit_status_modal/edit_status_modal.vue'),
),
StatusHistoryModal: defineAsyncComponent(
() =>
import('src/components/status_history_modal/status_history_modal.vue'),
),
GlobalNoticeList,
},
data: () => ({
mobileActivePanel: 'timeline',
}),
provide() {
return {
allowNonSquareEmoji: useMergedConfigStore().mergedConfig.nonSquareEmoji,
}
},
watch: {
themeApplied() {
this.removeSplash()
},
currentTheme() {
this.setThemeBodyClass()
},
layoutType() {
document.getElementById('modal').classList = ['-' + this.layoutType]
},
},
created() {
// Load the locale from the storage
const value = useMergedConfigStore().mergedConfig.interfaceLanguage
useI18nStore().setLanguage(value)
useEmojiStore().loadUnicodeEmojiData(value)
document.getElementById('modal').classList = ['-' + this.layoutType]
// Create bound handlers
this.updateScrollState = throttle(this.scrollHandler, 200)
this.updateMobileState = throttle(this.resizeHandler, 200)
},
mounted() {
window.addEventListener('resize', this.updateMobileState)
this.scrollParent.addEventListener('scroll', this.updateScrollState)
if (this.themeApplied) {
this.setThemeBodyClass()
this.removeSplash()
}
getOrCreateServiceWorker()
},
unmounted() {
window.removeEventListener('resize', this.updateMobileState)
this.scrollParent.removeEventListener('scroll', this.updateScrollState)
},
computed: {
currentTheme() {
if (this.styleDataUsed) {
const styleMeta = this.styleDataUsed.find(
(x) => x.component === '@meta',
)
if (styleMeta !== undefined) {
return styleMeta.directives.name.replaceAll(' ', '-').toLowerCase()
}
}
return 'stock'
},
layoutModalClass() {
return '-' + this.layoutType
},
classes() {
return [
{
'-reverse': this.reverseLayout,
'-no-sticky-headers': this.noSticky,
'-has-new-post-button': this.newPostButtonShown,
},
'-' + this.layoutType,
]
},
navClasses() {
const { navbarColumnStretch } = useMergedConfigStore().mergedConfig
return [
'-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : []),
]
},
currentUser() {
return this.$store.state.users.currentUser
},
userBackground() {
return this.currentUser.background_image
},
foreignProfileBackground() {
return (
useMergedConfigStore().mergedConfig.allowForeignUserBackground &&
useInterfaceStore().foreignProfileBackground
)
},
instanceBackground() {
return useMergedConfigStore().mergedConfig.hideInstanceWallpaper
? null
: this.instanceBackgroundUrl
},
background() {
return (
this.foreignProfileBackground ||
this.userBackground ||
this.instanceBackground
)
},
bgStyle() {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`,
}
}
},
shoutJoined() {
return useShoutStore().joined
},
isChats() {
return this.$route.name === 'chat' || this.$route.name === 'chats'
},
isListEdit() {
return this.$route.name === 'lists-edit'
},
newPostButtonShown() {
if (this.isChats) return false
if (this.isListEdit) return false
return (
useMergedConfigStore().mergedConfig.alwaysShowNewPostButton ||
this.layoutType === 'mobile'
)
},
shoutboxPosition() {
return (
useMergedConfigStore().mergedConfig.alwaysShowNewPostButton || false
)
},
hideShoutbox() {
return useMergedConfigStore().mergedConfig.hideShoutbox
},
reverseLayout() {
const { thirdColumnMode, sidebarRight: reverseSetting } =
useMergedConfigStore().mergedConfig
if (this.layoutType !== 'wide') {
return reverseSetting
} else {
return thirdColumnMode === 'notifications'
? reverseSetting
: !reverseSetting
}
},
noSticky() {
return useMergedConfigStore().mergedConfig.disableStickyHeaders
},
showScrollbars() {
return useMergedConfigStore().mergedConfig.showScrollbars
},
scrollParent() {
return window /* this.$refs.appContentRef */
},
showInstanceSpecificPanel() {
return (
this.instanceSpecificPanelPresent &&
!useMergedConfigStore().mergedConfig.hideISP
)
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, [
'themeApplied',
'styleDataUsed',
'layoutType',
]),
...mapState(useInstanceStore, ['styleDataUsed']),
...mapState(useInstanceCapabilitiesStore, [
'suggestionsEnabled',
'editingAvailable',
]),
...mapState(useInstanceStore, {
instanceBackgroundUrl: (store) => store.instanceIdentity.background,
showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel,
instanceSpecificPanelPresent: (store) =>
store.instanceIdentity.showInstanceSpecificPanel &&
store.instanceIdentity.instanceSpecificPanelContent,
}),
},
methods: {
resizeHandler() {
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
},
scrollHandler() {
const scrollPosition =
this.scrollParent === window
? window.scrollY
: this.scrollParent.scrollTop
if (scrollPosition != 0) {
this.$refs.appContentRef.classList.add(['-scrolled'])
} else {
this.$refs.appContentRef.classList.remove(['-scrolled'])
}
},
setThemeBodyClass() {
const themeName = this.currentTheme
const classList = Array.from(document.body.classList)
const oldTheme = classList.filter((c) => c.startsWith('theme-'))
if (themeName !== null && themeName !== '') {
const newTheme = `theme-${themeName.toLowerCase()}`
// remove old theme reference if there are any
if (oldTheme.length) {
document.body.classList.replace(oldTheme[0], newTheme)
} else {
document.body.classList.add(newTheme)
}
} else {
// remove theme reference if non-V3 theme is used
document.body.classList.remove(...oldTheme)
}
},
removeSplash() {
document.querySelector('#status').textContent = this.$t(
'splash.fun_' + Math.ceil(Math.random() * 4),
)
const splashscreenRoot = document.querySelector('#splash')
splashscreenRoot.addEventListener('transitionend', () => {
splashscreenRoot.remove()
})
setTimeout(() => {
splashscreenRoot.remove() // forcibly remove it, should fix my plasma browser widget t. HJ
}, 600)
splashscreenRoot.classList.add('hidden')
document.querySelector('#app').classList.remove('hidden')
},
},
}
diff --git a/src/api/admin.js b/src/api/admin.js
index 975a7386e6..79eac9bd69 100644
--- a/src/api/admin.js
+++ b/src/api/admin.js
@@ -1,509 +1,507 @@
import { promisedRequest } from './helpers.js'
-import { RegistrationError, StatusCodeError } from 'src/services/errors/errors'
-
const REPORTS = '/api/v1/pleroma/admin/reports'
const CONFIG_URL = '/api/v1/pleroma/admin/config'
const DESCRIPTIONS_URL = '/api/v1/pleroma/admin/config/descriptions'
const ANNOUNCEMENTS_URL = (id = '') =>
`/api/v1/pleroma/admin/announcements/${id}`
const FRONTENDS_URL = '/api/v1/pleroma/admin/frontends'
const FRONTENDS_INSTALL_URL = '/api/v1/pleroma/admin/frontends/install'
const USERS_URL = (nickname = '') => `/api/v1/pleroma/admin/users/${nickname}`
const USERS_URL_LIST = ({
page,
pageSize,
filters = {},
query = '',
name = '',
email = '',
}) => {
const {
local = false,
external = false,
active = false,
needApproval = false,
unconfirmed = false,
deactivated = false,
isAdmin = true,
isModerator = true,
} = filters
const filters_str = [
local && 'local',
external && 'external',
active && 'active',
needApproval && 'need_approval',
unconfirmed && 'unconfirmed',
deactivated && 'deactivated',
isAdmin && 'is_admin',
isModerator && 'is_moderator',
]
.filter((x) => x)
.join(',')
return `/api/v1/pleroma/admin/users?page=${page}&page_size=${pageSize}&filters=${filters_str}&query=${query}&name=${name}&email=${email}`
}
const TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PERMISSION_GROUP_URL = (right) =>
`/api/pleroma/admin/users/permission_group/${right}`
const ACTIVATE_USERS_URL = '/api/pleroma/admin/users/activate'
const DEACTIVATE_USERS_URL = '/api/pleroma/admin/users/deactivate'
const SUGGEST_USERS_URL = '/api/pleroma/admin/users/suggest'
const UNSUGGEST_USERS_URL = '/api/pleroma/admin/users/unsuggest'
const APPROVE_USERS_URL = '/api/v1/pleroma/admin/users/approve'
const CONFIRM_USERS_URL = '/api/v1/pleroma/admin/users/confirm_email'
const RESEND_CONFIRMATION_EMAIL_URL =
'/api/v1/pleroma/admin/users/resend_confirmation_email'
const LIST_STATUSES_URL = ({ id, page, pageSize, godmode, withReblogs }) =>
`/api/v1/pleroma/admin/users/${id}/statuses?page_size=${pageSize}&page=${page}&godmode=${godmode}&with_reblogs=${withReblogs}`
const CHANGE_STATUS_SCOPE_URL = (id) => `/api/v1/pleroma/admin/statuses/${id}`
const REQUIRE_PASSWORD_CHANGE_URL =
'/api/v1/pleroma/admin/users/force_password_reset'
const DISABLE_MFA_URL = '/api/v1/pleroma/admin/users/disable_mfa'
const EMOJI_RELOAD_URL = '/api/pleroma/admin/reload_emoji'
const EMOJI_IMPORT_FS_URL = '/api/pleroma/emoji/packs/import'
const EMOJI_PACK_URL = (name) => `/api/v1/pleroma/emoji/pack?name=${name}`
const EMOJI_PACKS_DL_REMOTE_URL = '/api/v1/pleroma/emoji/packs/download'
const EMOJI_PACKS_DL_REMOTE_ZIP_URL = '/api/v1/pleroma/emoji/packs/download_zip'
const EMOJI_PACKS_LS_REMOTE_URL = (url, page, pageSize) =>
`/api/v1/pleroma/emoji/packs/remote?url=${url}&page=${page}&page_size=${pageSize}`
const EMOJI_UPDATE_FILE_URL = (name) =>
`/api/v1/pleroma/emoji/packs/files?name=${name}`
//
export const setUsersTags = ({
tags,
credentials,
value,
screen_names: nicknames,
}) =>
promisedRequest({
url: TAG_USER_URL,
method: value ? 'PUT' : 'DELETE',
credentials,
payload: {
nicknames,
tags,
},
})
export const setUsersRight = ({
right,
credentials,
value,
screen_names: nicknames,
}) =>
promisedRequest({
url: PERMISSION_GROUP_URL(right),
method: value ? 'POST' : 'DELETE',
credentials,
payload: {
nicknames,
},
})
export const setUsersActivationStatus = ({
credentials,
screen_names: nicknames,
value,
}) =>
promisedRequest({
url: value ? ACTIVATE_USERS_URL : DEACTIVATE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersApprovalStatus = ({
credentials,
screen_names: nicknames,
}) =>
promisedRequest({
url: APPROVE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersConfirmationStatus = ({
credentials,
screen_names: nicknames,
}) =>
promisedRequest({
url: CONFIRM_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersSuggestionStatus = ({
credentials,
screen_names: nicknames,
value,
}) =>
promisedRequest({
url: value ? SUGGEST_USERS_URL : UNSUGGEST_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const getUserData = ({ credentials, screen_name: nickname }) =>
promisedRequest({
url: USERS_URL(nickname),
method: 'GET',
credentials,
})
export const deleteAccounts = ({ credentials, screen_names: nicknames }) =>
promisedRequest({
url: USERS_URL(),
method: 'DELETE',
credentials,
payload: {
nicknames,
},
})
export const getAnnouncements = ({ id, credentials }) =>
promisedRequest({ url: ANNOUNCEMENTS_URL(id), credentials })
// the reported list is hardly useful because standards are for dating i guess,
// so make sure to fetchIfMissing right afterward using this call
export const listUsers = ({ opts, credentials }) =>
promisedRequest({
url: USERS_URL_LIST(opts),
credentials,
method: 'GET',
})
export const resendConfirmationEmail = ({
screen_names: nicknames,
credentials,
}) =>
promisedRequest({
url: RESEND_CONFIRMATION_EMAIL_URL,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
export const requirePasswordChange = ({
screen_names: nicknames,
credentials,
}) =>
promisedRequest({
url: REQUIRE_PASSWORD_CHANGE_URL,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
export const disableMFA = ({ screen_name: nickname, credentials }) =>
promisedRequest({
url: DISABLE_MFA_URL,
credentials,
method: 'PUT',
payload: {
nickname,
},
})
export const listStatuses = ({ opts, credentials }) =>
promisedRequest({
url: LIST_STATUSES_URL(opts),
credentials,
method: 'GET',
})
export const changeStatusScope = ({
opts: { id, sensitive, visibility },
credentials,
}) => {
var payload = {}
if (typeof sensitive !== 'undefined') {
payload['sensitive'] = sensitive
}
if (typeof visibility !== 'undefined') {
payload['visibility'] = visibility
}
return promisedRequest({
url: CHANGE_STATUS_SCOPE_URL(id),
credentials,
method: 'PUT',
payload,
})
}
export const announcementToPayload = ({
content,
startsAt,
endsAt,
allDay,
}) => {
const payload = { content }
if (typeof startsAt !== 'undefined') {
payload.starts_at = startsAt ? new Date(startsAt).toISOString() : null
}
if (typeof endsAt !== 'undefined') {
payload.ends_at = endsAt ? new Date(endsAt).toISOString() : null
}
if (typeof allDay !== 'undefined') {
payload.all_day = allDay
}
return payload
}
export const postAnnouncement = ({
credentials,
content,
startsAt,
endsAt,
allDay,
}) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(),
credentials,
method: 'POST',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
export const editAnnouncement = ({
id,
credentials,
content,
startsAt,
endsAt,
allDay,
}) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(id),
credentials,
method: 'PATCH',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
export const deleteAnnouncement = ({ id, credentials }) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(id),
credentials,
method: 'DELETE',
})
export const setReportState = ({ id, state, credentials }) => {
// TODO: Can't use promisedRequest because on OK this does not return json
// See https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1322
return promisedRequest({
url: REPORTS,
credentials,
method: 'PATCH',
payload: {
reports: [
{
id,
state,
},
],
},
})
.then((data) => {
if (data.status >= 500) {
throw Error(data.statusText)
} else if (data.status >= 400) {
return data.json()
}
return data
})
.then((data) => {
if (data.errors) {
throw Error(data.errors[0].message)
}
})
}
export const getInstanceDBConfig = ({ credentials }) =>
promisedRequest({
url: CONFIG_URL,
credentials,
})
export const getInstanceConfigDescriptions = ({ credentials }) =>
promisedRequest({
url: DESCRIPTIONS_URL,
credentials,
})
export const getAvailableFrontends = ({ credentials }) =>
promisedRequest({
url: FRONTENDS_URL,
credentials,
})
export const pushInstanceDBConfig = ({ credentials, payload }) =>
promisedRequest({
url: CONFIG_URL,
method: 'POST',
credentials,
payload,
})
export const installFrontend = ({ credentials, payload }) =>
promisedRequest({
url: FRONTENDS_INSTALL_URL,
credentials,
method: 'POST',
payload,
})
// Emoji packs
export const deleteEmojiPack = ({ name }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
method: 'DELETE',
})
export const reloadEmoji = ({ credentials }) =>
promisedRequest({
url: EMOJI_RELOAD_URL,
method: 'POST',
credentials,
})
export const importEmojiFromFS = ({ credentials }) =>
promisedRequest({
url: EMOJI_IMPORT_FS_URL,
credentials,
})
export const createEmojiPack = ({ name, credentials }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
method: 'POST',
credentials,
})
export const listRemoteEmojiPacks = ({
instance,
page,
pageSize,
credentials,
}) => {
if (!instance.startsWith('http')) {
instance = 'https://' + instance
}
return promisedRequest({
url: EMOJI_PACKS_LS_REMOTE_URL(instance, page, pageSize),
credentials,
})
}
export const downloadRemoteEmojiPack = ({
instance,
packName,
as,
credentials,
}) =>
promisedRequest({
url: EMOJI_PACKS_DL_REMOTE_URL,
credentials,
method: 'POST',
payload: {
url: instance,
name: packName,
as,
},
})
export const downloadRemoteEmojiPackZIP = ({
url,
packName,
file,
credentials,
}) => {
const data = new FormData()
if (file) data.set('file', file)
if (url) data.set('url', url)
data.set('name', packName)
return promisedRequest({
url: EMOJI_PACKS_DL_REMOTE_ZIP_URL,
method: 'POST',
payload: data,
})
}
export const saveEmojiPackMetadata = ({ name, newData, credentials }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
credentials,
method: 'PATCH',
payload: { metadata: newData },
})
export const addNewEmojiFile = ({ packName, file, shortcode, filename }) => {
const data = new FormData()
if (filename.trim() !== '') {
data.set('filename', filename)
}
if (shortcode.trim() !== '') {
data.set('shortcode', shortcode)
}
data.set('file', file)
return promisedRequest({
url: EMOJI_UPDATE_FILE_URL(packName),
method: 'POST',
payload: data,
})
}
export const updateEmojiFile = ({
packName,
shortcode,
newShortcode,
newFilename,
credentials,
force,
}) =>
promisedRequest({
url: EMOJI_UPDATE_FILE_URL(packName),
credentials,
method: 'PATCH',
payload: {
shortcode,
new_shortcode: newShortcode,
new_filename: newFilename,
force,
},
})
export const deleteEmojiFile = ({ packName, shortcode }) =>
promisedRequest({
url: `${EMOJI_UPDATE_FILE_URL(packName)}&shortcode=${shortcode}`,
method: 'DELETE',
})
diff --git a/src/api/helpers.js b/src/api/helpers.js
index 5fb11a1839..40b8792326 100644
--- a/src/api/helpers.js
+++ b/src/api/helpers.js
@@ -1,135 +1,135 @@
import { snakeCase } from 'lodash'
-import { RegistrationError, StatusCodeError } from 'src/services/errors/errors'
+import { StatusCodeError } from 'src/services/errors/errors'
export const paramsString = (params = {}) => {
if (params == null || params === undefined) return ''
if (typeof params !== 'object' || Array.isArray(params)) {
throw new Error('Params are not an object!')
}
const entries = (() => {
if (params instanceof Map) {
return params.entries()
} else {
return Object.entries(params)
}
})()
if (entries.length === 0) return ''
const arrays = []
const nonArrays = []
entries.forEach(([k, v]) => {
if (v == null) return // Drop nulls
if (
(typeof v === 'object' && !Array.isArray(v)) ||
typeof v === 'function'
) {
throw new Error('Param cannot be non-primitive!')
}
if (Array.isArray(v)) {
arrays.push([k, v])
} else {
nonArrays.push([k, v])
}
})
arrays.forEach(([k, array]) => {
array.forEach((v) => {
if (
typeof v === 'object' ||
typeof v === 'function' ||
typeof v === 'undefined'
)
throw new Error('Array param cannot contain non-primitives!')
})
})
return (
'?' +
[
...nonArrays.map(([k, v]) => [snakeCase(k), v]),
// turning [a,[1,2,3]] into [[a[],1],[a[],2],[a[],3]]
...arrays.reduce(
(acc, [k, arrayValue]) => [
...acc,
...arrayValue.map((v) => [snakeCase(k) + '[]', v]),
],
[],
),
]
.map(([k, v]) => `${k}=${window.encodeURIComponent(v)}`)
.join('&')
)
}
export const promisedRequest = async ({
method,
url,
payload,
formData,
credentials,
headers = {},
}) => {
const options = {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...headers,
},
}
if (!formData) {
options.headers['Content-Type'] = 'application/json'
}
if (formData || payload) {
options.body = formData || JSON.stringify(payload)
}
if (credentials) {
options.headers = {
...options.headers,
...authHeaders(credentials),
}
}
const response = await fetch(url, options)
const data = await (async () => {
const [contentType] = response.headers
.get('content-type')
.split(';')
.map((x) => x.toLowerCase().trim())
const contentLength = parseInt(response.headers.get('content-length'))
if (contentLength === 0) return null
switch (contentType) {
case 'text/plain':
return await response.text()
case 'application/json':
return await response.json()
default:
return await response.bytes()
}
})()
const { ok, status } = response
if (ok) {
return { response, status, data }
} else {
throw new StatusCodeError(response.status, data, { url, options }, response)
}
}
const authHeaders = (accessToken) => {
if (accessToken) {
return { Authorization: `Bearer ${accessToken}` }
} else {
return {}
}
}
diff --git a/src/api/oauth.js b/src/api/oauth.js
index b774b3f6c1..f0fd2950d0 100644
--- a/src/api/oauth.js
+++ b/src/api/oauth.js
@@ -1,148 +1,146 @@
-import { reduce } from 'lodash'
-
import { paramsString, promisedRequest } from './helpers.js'
const REDIRECT_URI = `${window.location.origin}/oauth-callback`
export const MASTODON_APP_VERIFY_URL = '/api/v1/apps/verify_credentials'
export const MASTODON_APP_URL = '/api/v1/apps'
export const OAUTH_TOKEN_URL = '/oauth/token'
export const OAUTH_MFA_CHALLENGE_URL = '/oauth/mfa/challenge'
export const OAUTH_REVOKE_URL = '/oauth/revoke'
export const createApp = () => {
const formData = new window.FormData()
formData.append('client_name', 'PleromaFE')
formData.append('website', 'https://pleroma.social')
formData.append('redirect_uris', REDIRECT_URI)
formData.append('scopes', 'read write follow push admin')
return promisedRequest({
method: 'POST',
url: MASTODON_APP_URL,
formData,
}).then(({ data, ...rest }) => ({
...rest,
data: {
...data,
clientId: data.client_id,
clientSecret: data.client_secret,
},
}))
}
export const verifyAppToken = ({ credentials }) =>
promisedRequest({
url: MASTODON_APP_VERIFY_URL,
credentials,
})
export const getLoginUrl = ({ instance, clientId }) => {
const data = {
responseType: 'code',
clientId,
redirectUri: REDIRECT_URI,
scope: 'read write follow push admin',
}
return `${instance}/oauth/authorize${paramsString(data)}`
}
export const getTokenWithCredentials = ({
clientId,
clientSecret,
username,
password,
}) => {
const formData = new window.FormData()
formData.append('client_id', clientId)
formData.append('client_secret', clientSecret)
formData.append('grant_type', 'password')
formData.append('username', username)
formData.append('password', password)
return promisedRequest({
url: OAUTH_TOKEN_URL,
method: 'POST',
formData,
})
}
export const getToken = ({ clientId, clientSecret, code }) => {
const formData = new window.FormData()
formData.append('client_id', clientId)
formData.append('client_secret', clientSecret)
formData.append('grant_type', 'authorization_code')
formData.append('code', code)
formData.append('redirect_uri', `${window.location.origin}/oauth-callback`)
return promisedRequest({
url: OAUTH_TOKEN_URL,
method: 'POST',
formData,
})
}
export const getClientToken = ({ clientId, clientSecret }) => {
const formData = new window.FormData()
formData.append('client_id', clientId)
formData.append('client_secret', clientSecret)
formData.append('grant_type', 'client_credentials')
formData.append('redirect_uri', `${window.location.origin}/oauth-callback`)
return promisedRequest({
url: OAUTH_TOKEN_URL,
method: 'POST',
formData,
})
}
export const verifyOTPCode = ({ app, mfaToken, code }) => {
const formData = new window.FormData()
formData.append('client_id', app.client_id)
formData.append('client_secret', app.client_secret)
formData.append('mfa_token', mfaToken)
formData.append('code', code)
formData.append('challenge_type', 'totp')
return promisedRequest({
url: OAUTH_MFA_CHALLENGE_URL,
method: 'POST',
formData,
})
}
export const verifyRecoveryCode = ({ app, mfaToken, code }) => {
const formData = new window.FormData()
formData.append('client_id', app.client_id)
formData.append('client_secret', app.client_secret)
formData.append('mfa_token', mfaToken)
formData.append('code', code)
formData.append('challenge_type', 'recovery')
return promisedRequest({
url: OAUTH_MFA_CHALLENGE_URL,
method: 'POST',
formData,
})
}
export const revokeToken = ({ app, token }) => {
const formData = new window.FormData()
formData.append('client_id', app.clientId)
formData.append('client_secret', app.clientSecret)
formData.append('token', token)
return promisedRequest({
url: OAUTH_REVOKE_URL,
method: 'POST',
formData,
})
}
diff --git a/src/api/public.js b/src/api/public.js
index cf8d082e24..7ce683dde3 100644
--- a/src/api/public.js
+++ b/src/api/public.js
@@ -1,296 +1,295 @@
import { paramsString, promisedRequest } from './helpers.js'
import { MASTODON_USER_TIMELINE_URL } from './timelines.js'
import {
parseSource,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
-import { RegistrationError, StatusCodeError } from 'src/services/errors/errors'
const MASTODON_SUGGESTIONS_URL = '/api/v1/suggestions'
const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
const MASTODON_REGISTRATION_URL = '/api/v1/accounts'
const MASTODON_PASSWORD_RESET_URL = ({ email }) =>
`/auth/password${paramsString({ email })}`
const MASTODON_FOLLOWING_URL = (
id,
{ minId, maxId, sinceId, limit, withRelationships },
) =>
`/api/v1/accounts/${id}/following${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
const MASTODON_FOLLOWERS_URL = (
id,
{ minId, maxId, sinceId, limit, withRelationships },
) =>
`/api/v1/accounts/${id}/followers${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
export const MASTODON_STATUS_URL = (id) => `/api/v1/statuses/${id}`
const MASTODON_STATUS_CONTEXT_URL = (id) => `/api/v1/statuses/${id}/context`
const MASTODON_STATUS_SOURCE_URL = (id) => `/api/v1/statuses/${id}/source`
const MASTODON_STATUS_HISTORY_URL = (id) => `/api/v1/statuses/${id}/history`
const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_LOOKUP_URL = ({ acct }) =>
`/api/v1/accounts/lookup${paramsString({ acct })}`
const MASTODON_POLL_URL = (id = '') => `/api/v1/polls/${id}`
const MASTODON_STATUS_FAVORITEDBY_URL = (id) =>
`/api/v1/statuses/${id}/favourited_by`
const MASTODON_STATUS_REBLOGGEDBY_URL = (id) =>
`/api/v1/statuses/${id}/reblogged_by`
const MASTODON_SEARCH_2 = ({
q,
resolve,
limit,
offset,
following,
type,
withRelationships,
accountId,
excludeUnreviewed,
}) =>
`/api/v2/search${paramsString({ q, resolve, limit, offset, following, type, withRelationships, accountId, excludeUnreviewed })}`
const MASTODON_USER_SEARCH_URL = ({ q, resolve }) =>
`/api/v1/accounts/search${paramsString({ q, resolve })}`
const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const PLEROMA_EMOJI_REACTIONS_URL = (id) =>
`/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_SCROBBLES_URL = (id, { maxId, sinceId, minId, limit, offset }) =>
`/api/v1/pleroma/accounts/${id}/scrobbles${paramsString({ maxId, sinceId, minId, limit, offset })}`
const EMOJI_PACKS_URL = (page, pageSize) =>
`/api/v1/pleroma/emoji/packs${paramsString({ page, pageSize })}`
// Params needed:
// nickname
// email
// fullname
// password
// password_confirm
//
// Optional
// bio
// homepage
// location
// token
// language
export const register = ({ params, credentials }) => {
const { nickname, ...rest } = params
return promisedRequest({
url: MASTODON_REGISTRATION_URL,
method: 'POST',
credentials,
payload: {
nickname,
locale: 'en_US',
agreement: true,
...rest,
},
})
}
export const getCaptcha = () =>
promisedRequest({
url: '/api/pleroma/captcha',
})
export const fetchUser = ({ id, credentials }) =>
promisedRequest({
url: `${MASTODON_USER_URL}/${id}`,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const fetchUserByName = ({ name, credentials }) =>
promisedRequest({
url: MASTODON_USER_LOOKUP_URL({ acct: name }),
credentials,
})
.then(({ data }) => data.id)
.catch((error) => {
if (error && error.statusCode === 404) {
// Either the backend does not support lookup endpoint,
// or there is no user with such name. Fallback and treat name as id.
return name
} else {
throw error
}
})
.then((id) => fetchUser({ id, credentials }))
export const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) =>
promisedRequest({
url: MASTODON_FOLLOWING_URL(id, { maxId, sinceId, limit }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchFollowers = ({
id,
maxId,
sinceId,
limit = 20,
credentials,
}) =>
promisedRequest({
url: MASTODON_FOLLOWERS_URL(id, {
maxId,
sinceId,
limit,
withRelationships: true,
}),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchConversation = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_CONTEXT_URL(id),
credentials,
})
.then((result) => ({
...result,
data: {
...result.data,
ancestors: result.data.ancestors.map(parseStatus),
descendants: result.data.descendants.map(parseStatus),
},
}))
.catch((error) => {
throw new Error('Error fetching timeline', error)
})
export const fetchStatus = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_URL(id),
credentials,
})
.then(({ data, ...rest }) => ({ ...rest, data: parseStatus(data) }))
.catch((error) => {
throw new Error('Error fetching timeline', error)
})
export const fetchStatusSource = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_SOURCE_URL(id),
credentials,
})
.then(({ data, ...rest }) => ({ ...rest, data: parseSource(data) }))
.catch((error) => {
throw new Error('Error fetching timeline', error)
})
export const fetchStatusHistory = ({ status, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_HISTORY_URL(status.id),
credentials,
}).then(({ data, ...rest }) => {
return [...data].reverse().map((item) => {
item.originalStatus = status
return { ...rest, data: parseStatus(item) }
})
})
export const listEmojiPacks = ({ page, pageSize, credentials }) =>
promisedRequest({
url: EMOJI_PACKS_URL(page, pageSize),
})
export const fetchPinnedStatuses = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_USER_TIMELINE_URL(id, { pinned: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseStatus) }))
export const verifyCredentials = ({ credentials }) =>
promisedRequest({
url: MASTODON_LOGIN_URL,
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: parseUser(data) }))
export const resetPassword = ({ email }) => {
return promisedRequest({
url: MASTODON_PASSWORD_RESET_URL({ email }),
method: 'POST',
})
}
export const suggestions = ({ credentials }) =>
promisedRequest({
url: MASTODON_SUGGESTIONS_URL,
credentials,
})
export const fetchPoll = ({ pollId, credentials }) =>
promisedRequest({
url: MASTODON_POLL_URL(encodeURIComponent(pollId)),
method: 'GET',
credentials,
})
export const fetchFavoritedByUsers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_FAVORITEDBY_URL(id),
method: 'GET',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchRebloggedByUsers = ({ id, credentials }) =>
promisedRequest({
url: MASTODON_STATUS_REBLOGGEDBY_URL(id),
method: 'GET',
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const fetchEmojiReactions = ({ id, credentials }) =>
promisedRequest({
url: PLEROMA_EMOJI_REACTIONS_URL(id),
credentials,
}).then(({ data, ...rest }) => ({
...rest,
data: data.map((r) => {
r.accounts = r.accounts.map(parseUser)
return r
}),
}))
export const searchUsers = ({ credentials, query }) =>
promisedRequest({
url: MASTODON_USER_SEARCH_URL({ q: query, resolve: true }),
credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) }))
export const search2 = ({
credentials,
q,
resolve,
limit,
offset,
following,
type,
}) => {
return promisedRequest({
url: MASTODON_SEARCH_2({
q,
resolve,
limit,
offset,
following,
type,
withRelationships: true,
}),
credentials,
})
.then(({ data, ...rest }) => {
data.accounts = data.accounts.slice(0, limit).map((u) => parseUser(u))
data.statuses = data.statuses.slice(0, limit).map((s) => parseStatus(s))
return { ...rest, data }
})
.catch((error) => {
throw new Error('Error fetching timeline', error)
})
}
export const fetchKnownDomains = ({ credentials }) =>
promisedRequest({ url: MASTODON_KNOWN_DOMAIN_LIST_URL, credentials })
export const fetchScrobbles = ({ accountId, limit = 1 }) =>
promisedRequest({
url: PLEROMA_SCROBBLES_URL(accountId, { limit }),
})
diff --git a/src/api/websocket.js b/src/api/websocket.js
index 44f3d391a0..d952372e51 100644
--- a/src/api/websocket.js
+++ b/src/api/websocket.js
@@ -1,176 +1,176 @@
-import { paramsString, promisedRequest } from './helpers.js'
+import { paramsString } from './helpers.js'
import {
parseChat,
parseNotification,
parseStatus,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MASTODON_STREAMING = ({ accessToken, stream }) =>
`/api/v1/streaming${paramsString({ accessToken, stream })}`
export const getMastodonSocketURI = ({ credentials, stream }) => {
return MASTODON_STREAMING({ accessToken: credentials, stream })
}
const MASTODON_STREAMING_EVENTS = new Set([
'update',
'notification',
'delete',
'filters_changed',
'status.update',
])
const PLEROMA_STREAMING_EVENTS = new Set([
'pleroma:chat_update',
'pleroma:respond',
])
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a CustomEvent to proxy events
export const ProcessedWS = ({
url,
preprocessor = handleMastoWS,
id = 'Unknown',
credentials,
}) => {
const eventTarget = new EventTarget()
const socket = new WebSocket(url)
if (!socket) throw new Error(`Failed to create socket ${id}`)
const proxy = (original, eventName, processor = (a) => a) => {
original.addEventListener(eventName, (eventData) => {
eventTarget.dispatchEvent(
new CustomEvent(eventName, { detail: processor(eventData) }),
)
})
}
socket.addEventListener('open', (wsEvent) => {
console.debug(`[WS][${id}] Socket connected`, wsEvent)
if (credentials) {
socket.send(
JSON.stringify({
type: 'pleroma:authenticate',
token: credentials,
}),
)
}
})
socket.addEventListener('error', (wsEvent) => {
console.debug(`[WS][${id}] Socket errored`, wsEvent)
})
socket.addEventListener('close', (wsEvent) => {
console.debug(
`[WS][${id}] Socket disconnected with code ${wsEvent.code}`,
wsEvent,
)
})
// Commented code reason: very spammy, uncomment to enable message debug logging
/*
socket.addEventListener('message', (wsEvent) => {
console.debug(
`[WS][${id}] Message received`,
wsEvent
)
})
/**/
const onAuthenticated = () => {
eventTarget.dispatchEvent(new CustomEvent('pleroma:authenticated'))
}
proxy(socket, 'open')
proxy(socket, 'close')
proxy(socket, 'message', (event) => preprocessor(event, { onAuthenticated }))
proxy(socket, 'error')
// 1000 = Normal Closure
eventTarget.close = () => {
socket.close(1000, 'Shutting down socket')
}
eventTarget.getState = () => socket.readyState
eventTarget.subscribe = (stream, args = {}) => {
console.debug(`[WS][${id}] Subscribing to stream ${stream} with args`, args)
socket.send(
JSON.stringify({
type: 'subscribe',
stream,
...args,
}),
)
}
eventTarget.unsubscribe = (stream, args = {}) => {
console.debug(
`[WS][${id}] Unsubscribing from stream ${stream} with args`,
args,
)
socket.send(
JSON.stringify({
type: 'unsubscribe',
stream,
...args,
}),
)
}
return eventTarget
}
export const handleMastoWS = (
wsEvent,
{
onAuthenticated = () => {
/* no-op */
},
} = {},
) => {
const { data } = wsEvent
if (!data) return
const parsedEvent = JSON.parse(data)
const { event, payload } = parsedEvent
if (
MASTODON_STREAMING_EVENTS.has(event) ||
PLEROMA_STREAMING_EVENTS.has(event)
) {
// MastoBE and PleromaBE both send payload for delete as a PLAIN string
if (event === 'delete') {
return { event, id: payload }
}
const data = payload ? JSON.parse(payload) : null
if (event === 'pleroma:respond') {
if (data.type === 'pleroma:authenticate') {
if (data.result === 'success') {
console.debug('[WS] Successfully authenticated')
onAuthenticated()
} else {
if (data.error === 'already_authenticated') {
onAuthenticated()
} else {
console.error('[WS] Unable to authenticate:', data.error)
wsEvent.target.close()
}
}
}
return null
} else if (event === 'update') {
return { event, status: parseStatus(data) }
} else if (event === 'status.update') {
return { event, status: parseStatus(data) }
} else if (event === 'notification') {
return { event, notification: parseNotification(data) }
} else if (event === 'pleroma:chat_update') {
return { event, chatUpdate: parseChat(data) }
}
} else {
console.warn('Unknown event', wsEvent)
return null
}
}
export const WSConnectionStatus = Object.freeze({
JOINED: 1,
CLOSED: 2,
ERROR: 3,
DISABLED: 4,
STARTING: 5,
STARTING_INITIAL: 6,
})
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 95fb3e9eaf..0b4825ca08 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -1,613 +1,612 @@
/* global process */
import vClickOutside from 'click-outside-vue3'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import Status from 'src/components/status/status.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import { config } from '@fortawesome/fontawesome-svg-core'
import {
FontAwesomeIcon,
FontAwesomeLayers,
} from '@fortawesome/vue-fontawesome'
config.autoAddCss = false
import App from '../App.vue'
import FaviconService from '../services/favicon_service/favicon_service.js'
import { applyStyleConfig } from '../services/style_setter/style_setter.js'
import { initServiceWorker, updateFocus } from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import routes from './routes'
-import { useAnnouncementsStore } from 'src/stores/announcements'
import { useAuthFlowStore } from 'src/stores/auth_flow'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import {
INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
INSTANCE_IDENTIY_EXTERNAL,
} from 'src/modules/default_config_state.js'
let staticInitialResults = null
const parsedInitialResults = () => {
if (!document.getElementById('initial-results')) {
return null
}
if (!staticInitialResults) {
staticInitialResults = JSON.parse(
document.getElementById('initial-results').textContent,
)
}
return staticInitialResults
}
const decodeUTF8Base64 = (data) => {
const rawData = atob(data)
const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
const text = new TextDecoder().decode(array)
return text
}
const preloadFetch = async (request) => {
const data = parsedInitialResults()
if (!data || !data[request]) {
return window.fetch(request)
}
const decoded = decodeUTF8Base64(data[request])
const requestData = JSON.parse(decoded)
return {
ok: true,
json: () => requestData,
text: () => requestData,
}
}
const getInstanceConfig = async ({ store }) => {
try {
const res = await preloadFetch('/api/v1/instance')
if (res.ok) {
const data = await res.json()
const textLimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
useInstanceCapabilitiesStore().set(
'pleromaExtensionsAvailable',
data.pleroma,
)
useInstanceStore().set({
path: 'limits.textLimit',
value: textLimit,
})
useInstanceStore().set({
path: 'accountApprovalRequired',
value: data.approval_required,
})
useInstanceStore().set({
path: 'birthdayRequired',
value: !!data.pleroma?.metadata.birthday_required,
})
useInstanceStore().set({
path: 'birthdayMinAge',
value: data.pleroma?.metadata.birthday_min_age || 0,
})
if (vapidPublicKey) {
useInstanceStore().set({
path: 'vapidPublicKey',
value: vapidPublicKey,
})
}
} else {
throw res
}
} catch (error) {
console.error('Could not load instance config, potentially fatal')
console.error(error)
}
// We should check for scrobbles support here but it requires userId
// so instead we check for it where it's fetched (statuses.js)
}
const getBackendProvidedConfig = async () => {
try {
const res = await window.fetch('/api/pleroma/frontend_configurations')
if (res.ok) {
const data = await res.json()
return data.pleroma_fe
} else {
throw res
}
} catch (error) {
console.error(
'Could not load backend-provided frontend config, potentially fatal',
)
console.error(error)
}
}
const getStaticConfig = async () => {
try {
const res = await window.fetch('/static/config.json')
if (res.ok) {
return res.json()
} else {
throw res
}
} catch (error) {
console.warn(
'Failed to load static/config.json, continuing without it.',
error,
)
return {}
}
}
const setSettings = async ({ apiConfig, staticConfig, store }) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
Object.keys(INSTANCE_IDENTITY_DEFAULT_DEFINITIONS).forEach((source) => {
if (source === 'name') return
if (INSTANCE_IDENTIY_EXTERNAL.has(source)) return
useInstanceStore().set({
value:
config[source] ?? INSTANCE_IDENTITY_DEFAULT_DEFINITIONS[source].default,
path: `instanceIdentity.${source}`,
})
})
Object.keys(INSTANCE_DEFAULT_CONFIG_DEFINITIONS).forEach((source) =>
useInstanceStore().set({
value:
config[source] ?? INSTANCE_DEFAULT_CONFIG_DEFINITIONS[source].default,
path: `prefsStorage.${source}`,
}),
)
useAuthFlowStore().setInitialStrategy(config.loginMethod)
}
const getTOS = async ({ store }) => {
try {
const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) {
const html = await res.text()
useInstanceStore().set({ path: 'instanceIdentity.tos', value: html })
} else {
throw res
}
} catch (e) {
console.warn("Can't load TOS\n", e)
}
}
const getInstancePanel = async ({ store }) => {
try {
const res = await preloadFetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
useInstanceStore().set({
path: 'instanceIdentity.instanceSpecificPanelContent',
value: html,
})
} else {
throw res
}
} catch (e) {
console.warn("Can't load instance panel\n", e)
}
}
const getStickers = async ({ store }) => {
try {
const res = await window.fetch('/static/stickers.json')
if (res.ok) {
const values = await res.json()
const stickers = (
await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
let meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta,
}
}),
)
).sort((a, b) => {
return a.meta.title.localeCompare(b.meta.title)
})
useEmojiStore().setStickers(stickers)
} else {
throw res
}
} catch (e) {
console.warn("Can't load stickers\n", e)
}
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map((uri) => uri.split('/').pop())
useInstanceStore().set({
path: 'staffAccounts',
value: nicknames,
})
}
const getNodeInfo = async ({ store }) => {
try {
let res = await preloadFetch('/nodeinfo/2.1.json')
if (!res.ok) res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
useInstanceStore().set({
path: 'instanceIdentity.name',
value: metadata.nodeName,
})
useInstanceStore().set({
path: 'registrationOpen',
value: data.openRegistrations,
})
useInstanceCapabilitiesStore().set(
'mediaProxyAvailable',
features.includes('media_proxy'),
)
useInstanceCapabilitiesStore().set(
'safeDM',
features.includes('safe_dm_mentions'),
)
useInstanceCapabilitiesStore().set(
'shoutAvailable',
features.includes('chat'),
)
useInstanceCapabilitiesStore().set(
'pleromaChatMessagesAvailable',
features.includes('pleroma_chat_messages'),
)
useInstanceCapabilitiesStore().set(
'pleromaCustomEmojiReactionsAvailable',
features.includes('pleroma_custom_emoji_reactions') ||
features.includes('custom_emoji_reactions'),
)
useInstanceCapabilitiesStore().set(
'pleromaBookmarkFoldersAvailable',
features.includes('pleroma:bookmark_folders'),
)
useInstanceCapabilitiesStore().set(
'gopherAvailable',
features.includes('gopher'),
)
useInstanceCapabilitiesStore().set(
'pollsAvailable',
features.includes('polls'),
)
useInstanceCapabilitiesStore().set(
'editingAvailable',
features.includes('editing'),
)
useInstanceCapabilitiesStore().set(
'mailerEnabled',
metadata.mailerEnabled,
)
useInstanceCapabilitiesStore().set(
'quotingAvailable',
features.includes('quote_posting'),
)
useInstanceCapabilitiesStore().set(
'groupActorAvailable',
features.includes('pleroma:group_actors'),
)
useInstanceCapabilitiesStore().set(
'blockExpiration',
features.includes('pleroma:block_expiration'),
)
useInstanceStore().set({
path: 'localBubbleInstances',
value: metadata.localBubbleInstances ?? [],
})
useInstanceCapabilitiesStore().set(
'localBubble',
(metadata.localBubbleInstances ?? []).length > 0,
)
useInstanceStore().set({
path: 'limits.pollLimits',
value: metadata.pollLimits,
})
const uploadLimits = metadata.uploadLimits
useInstanceStore().set({
path: 'limits.uploadlimit',
value: parseInt(uploadLimits.general),
})
useInstanceStore().set({
path: 'limits.avatarlimit',
value: parseInt(uploadLimits.avatar),
})
useInstanceStore().set({
path: 'limits.backgroundlimit',
value: parseInt(uploadLimits.background),
})
useInstanceStore().set({
path: 'limits.bannerlimit',
value: parseInt(uploadLimits.banner),
})
useInstanceStore().set({
path: 'limits.fieldsLimits',
value: metadata.fieldsLimits,
})
useInstanceStore().set({
path: 'restrictedNicknames',
value: metadata.restrictedNicknames,
})
useInstanceCapabilitiesStore().set('postFormats', metadata.postFormats)
const suggestions = metadata.suggestions
useInstanceCapabilitiesStore().set(
'suggestionsEnabled',
suggestions.enabled,
)
// this is unused, why?
useInstanceCapabilitiesStore().set('suggestionsWeb', suggestions.web)
const software = data.software
useInstanceStore().set({
path: 'backendVersion',
value: software.version,
})
useInstanceStore().set({
path: 'backendRepository',
value: software.repository,
})
const priv = metadata.private
useInstanceStore().set({ path: 'privateMode', value: priv })
const frontendVersion = window.___pleromafe_commit_hash
useInstanceStore().set({
path: 'frontendVersion',
value: frontendVersion,
})
const federation = metadata.federation
useInstanceCapabilitiesStore().set(
'tagPolicyAvailable',
typeof federation.mrf_policies === 'undefined'
? false
: metadata.federation.mrf_policies.includes('TagPolicy'),
)
useInstanceStore().set({
path: 'federationPolicy',
value: federation,
})
useInstanceStore().set({
path: 'federating',
value:
typeof federation.enabled === 'undefined' ? true : federation.enabled,
})
const accountActivationRequired = metadata.accountActivationRequired
useInstanceStore().set({
path: 'accountActivationRequired',
value: accountActivationRequired,
})
const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts })
} else {
throw res
}
} catch (e) {
console.warn('Could not load nodeinfo', e)
}
}
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([
getBackendProvidedConfig({ store }),
getStaticConfig(),
])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig })
}
const checkOAuthToken = async ({ store }) => {
const oauth = useOAuthStore()
if (oauth.userToken) {
return store.dispatch('loginUser', oauth.userToken)
}
return Promise.resolve()
}
const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
const app = createApp(App)
// Must have app use pinia before we do anything that touches the store
// https://pinia.vuejs.org/core-concepts/plugins.html#Introduction
// "Plugins are only applied to stores created after the plugins themselves, and after pinia is passed to the app, otherwise they won't be applied."
app.use(pinia)
const waitForAllStoresToLoad = async () => {
// the stores that do not persist technically do not need to be awaited here,
// but that involves either hard-coding the stores in some place (prone to errors)
// or writing another vite plugin to analyze which stores needs persisting (++load time)
const allStores = import.meta.glob('../stores/*.js', { eager: true })
if (process.env.NODE_ENV === 'development') {
// do some checks to avoid common errors
if (!Object.keys(allStores).length) {
throw new Error(
'No stores are available. Check the code in src/boot/after_store.js',
)
}
}
await Promise.all(
Object.entries(allStores).map(async ([name, mod]) => {
const isStoreName = (name) => name.startsWith('use')
if (process.env.NODE_ENV === 'development') {
if (Object.keys(mod).filter(isStoreName).length !== 1) {
throw new Error(
'Each store file must export exactly one store as a named export. Check your code in src/stores/',
)
}
}
const storeFuncName = Object.keys(mod).find(isStoreName)
if (storeFuncName && typeof mod[storeFuncName] === 'function') {
const p = mod[storeFuncName]().$persistLoaded
if (!(p instanceof Promise)) {
throw new Error(
`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`,
)
}
await p
} else {
throw new Error(
`Store module ${name} does not export a 'use...' function`,
)
}
}),
)
}
try {
await waitForAllStoresToLoad()
} catch (e) {
console.error('Cannot load stores:', e)
storageError = e
}
if (storageError) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'errors.storage_unavailable',
level: 'error',
})
}
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
window.syncConfig = useSyncConfigStore()
window.mergedConfig = useMergedConfigStore()
window.localConfig = useLocalConfigStore()
window.highlightConfig = useUserHighlightStore()
FaviconService.initFaviconService()
initServiceWorker(store)
window.addEventListener('focus', () => updateFocus())
const overrides = window.___pleromafe_dev_overrides || {}
const server =
typeof overrides.target !== 'undefined'
? overrides.target
: window.location.origin
useInstanceStore().set({ path: 'server', value: server })
await setConfig({ store })
try {
await useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
} catch (e) {
window.splashError(e)
return Promise.reject(e)
}
applyStyleConfig(useMergedConfigStore().mergedConfig, i18n.global)
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([
checkOAuthToken({ store }),
getInstancePanel({ store }),
getNodeInfo({ store }),
getInstanceConfig({ store }),
]).catch((e) => Promise.reject(e))
getTOS({ store })
getStickers({ store })
const router = createRouter({
history: createWebHistory(),
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some((m) => m.meta.dontScroll)) {
return false
}
return savedPosition || { left: 0, top: 0 }
},
})
useI18nStore().setI18n(i18n)
app.use(router)
app.use(store)
app.use(i18n)
// Little thing to get out of invalid theme state
window.resetThemes = () => {
useInterfaceStore().resetThemeV3()
useInterfaceStore().resetThemeV3Palette()
useInterfaceStore().resetThemeV2()
}
app.use(vClickOutside)
app.use(VBodyScrollLock)
app.use(VueVirtualScroller)
app.component('FAIcon', FontAwesomeIcon)
app.component('FALayers', FontAwesomeLayers)
app.component('Status', Status)
app.component('RichContent', RichContent)
app.component('StillImage', StillImage)
// remove after vue 3.3
app.config.unwrapInjectedRef = true
app.mount('#app')
return app
}
export default afterStoreSetup
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index 4b733258f7..d5bce12a71 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -1,218 +1,217 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
-import VideoAttachment from 'src/components/video_attachment/video_attachment.vue'
import nsfwImage from '../../assets/nsfw.png'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMediaViewerStore } from 'src/stores/media_viewer'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAlignRight,
faFile,
faImage,
faMusic,
faPencilAlt,
faPlayCircle,
faSearchPlus,
faStop,
faTimes,
faTrashAlt,
faVideo,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight,
)
const Attachment = {
props: [
'attachment',
'compact',
'description',
'hideDescription',
'nsfw',
'size',
'setMedia',
'remove',
'shiftUp',
'shiftDn',
'edit',
],
data() {
return {
localDescription: this.description || this.attachment.description,
nsfwImage:
useInstanceStore().instanceIdentity.nsfwCensorImage || nsfwImage,
hideNsfwLocal: useMergedConfigStore().mergedConfig.hideNsfw,
preloadImage: useMergedConfigStore().mergedConfig.preloadImage,
loading: false,
img: this.attachment.type === 'image' && document.createElement('img'),
modalOpen: false,
showHidden: false,
flashLoaded: false,
}
},
components: {
Flash: defineAsyncComponent(() => import('src/components/flash/flash.vue')),
VideoAttachment: defineAsyncComponent(
() => import('src/components/video_attachment/video_attachment.vue'),
),
Popover,
},
computed: {
classNames() {
return [
{
'-loading': this.loading,
'-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined,
'-compact': this.compact,
},
'-type-' + this.attachment.type,
this.size && '-size-' + this.size,
`-${this.useContainFit ? 'contain' : 'cover'}-fit`,
]
},
usePlaceholder() {
return this.size === 'hide'
},
useContainFit() {
return this.mergedConfig.useContainFit
},
placeholderName() {
if (this.attachment.description === '' || !this.attachment.description) {
return this.attachment.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass() {
if (this.attachment.type === 'image') return 'image'
if (this.attachment.type === 'video') return 'video'
if (this.attachment.type === 'audio') return 'music'
return 'file'
},
referrerpolicy() {
return useInstanceCapabilitiesStore().mediaProxyAvailable
? ''
: 'no-referrer'
},
hidden() {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty() {
return this.attachment.type === 'html' && !this.attachment.oembed
},
useModal() {
let modalTypes = []
switch (this.size) {
case 'hide':
case 'small':
modalTypes = ['image', 'video', 'audio', 'flash']
break
default:
modalTypes = this.mergedConfig.playVideosInModal
? ['image', 'video', 'flash']
: ['image']
break
}
return modalTypes.includes(this.attachment.type)
},
videoTag() {
return this.useModal ? 'button' : 'span'
},
...mapState(useMergedConfigStore, ['mergedConfig']),
},
watch: {
'attachment.description'(newVal) {
this.localDescription = newVal
},
localDescription(newVal) {
this.onEdit(newVal)
},
},
methods: {
linkClicked({ target }) {
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
openModal() {
if (this.useModal) {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
} else if (this.attachment.type === 'unknown') {
window.open(this.attachment.url)
}
},
openModalForce() {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
},
onEdit(event) {
this.edit && this.edit(this.attachment, event)
},
onRemove() {
this.remove && this.remove(this.attachment)
},
onShiftUp() {
this.shiftUp && this.shiftUp(this.attachment)
},
onShiftDn() {
this.shiftDn && this.shiftDn(this.attachment)
},
stopFlash() {
this.$refs.flash.closePlayer()
},
setFlashLoaded(event) {
this.flashLoaded = event
},
toggleHidden(event) {
if (
this.mergedConfig.useOneClickNsfw &&
!this.showHidden &&
(this.attachment.type !== 'video' ||
this.mergedConfig.playVideosInModal)
) {
this.openModal(event)
return
}
if (this.img && !this.preloadImage) {
if (this.img.onload) {
this.img.onload()
} else {
this.loading = true
this.img.src = this.attachment.url
this.img.onload = () => {
this.loading = false
this.showHidden = !this.showHidden
}
}
} else {
this.showHidden = !this.showHidden
}
},
onImageLoad(image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
},
},
}
export default Attachment
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index ba06172ed0..2066a81942 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -1,117 +1,116 @@
import { mapState as mapPiniaState } from 'pinia'
-import { defineAsyncComponent } from 'vue'
-import { mapGetters, mapState } from 'vuex'
+import { mapState } from 'vuex'
import Attachment from 'src/components/attachment/attachment.vue'
import ChatMessageDate from 'src/components/chat_message_date/chat_message_date.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import LinkPreview from 'src/components/link-preview/link-preview.vue'
import Popover from 'src/components/popover/popover.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEllipsisH, faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH)
const ChatMessage = {
name: 'ChatMessage',
props: [
'author',
'edited',
'noHeading',
'chatViewItem',
'hoveredMessageChain',
],
emits: ['hover'],
components: {
Popover,
Attachment,
StatusContent,
UserAvatar,
Gallery,
LinkPreview,
ChatMessageDate,
UserPopover,
},
computed: {
// Returns HH:MM (hours and minutes) in local time.
createdAt() {
const time = this.chatViewItem.data.created_at
return time.toLocaleTimeString('en', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
},
isCurrentUser() {
return this.message.account_id === this.currentUser.id
},
message() {
return this.chatViewItem.data
},
isMessage() {
return this.chatViewItem.type === 'message'
},
messageForStatusContent() {
return {
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || '',
text: this.message.content || '',
attachments: this.message.attachments,
}
},
hasAttachment() {
return this.message.attachments.length > 0
},
...mapPiniaState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter,
}),
...mapState({
currentUser: (state) => state.users.currentUser,
restrictedNicknames: (state) => useInstanceStore().restrictedNicknames,
}),
popoverMarginStyle() {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
...mapPiniaState(useMergedConfigStore, ['mergedConfig', 'findUser']),
},
data() {
return {
hovered: false,
menuOpened: false,
}
},
methods: {
onHover(bool) {
this.$emit('hover', {
isHovered: bool,
messageChainId: this.chatViewItem.messageChainId,
})
},
async deleteMessage() {
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) {
await this.$store.dispatch('deleteChatMessage', {
messageId: this.chatViewItem.data.id,
chatId: this.chatViewItem.data.chat_id,
})
}
this.hovered = false
this.menuOpened = false
},
},
}
export default ChatMessage
diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js
index ec2656e5cb..0ee4e292b6 100644
--- a/src/components/chat_new/chat_new.js
+++ b/src/components/chat_new/chat_new.js
@@ -1,85 +1,83 @@
import { mapGetters, mapState } from 'vuex'
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import { useOAuthStore } from 'src/stores/oauth.js'
-import { chats } from 'src/api/chats.js'
-
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronLeft, faSearch } from '@fortawesome/free-solid-svg-icons'
library.add(faSearch, faChevronLeft)
const chatNew = {
components: {
BasicUserCard,
UserAvatar,
},
data() {
return {
suggestions: [],
userIds: [],
loading: false,
query: '',
}
},
async created() {
const { chats } = await chats({
credentials: useOAuthStore().token,
})
chats.forEach((chat) => this.suggestions.push(chat.account))
},
computed: {
users() {
return this.userIds.map((userId) => this.findUser(userId))
},
availableUsers() {
if (this.query.length !== 0) {
return this.users
} else {
return this.suggestions
}
},
...mapState({
currentUser: (state) => state.users.currentUser,
}),
...mapGetters(['findUser']),
},
methods: {
goBack() {
this.$emit('cancel')
},
goToChat(user) {
this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
},
onInput() {
this.search(this.query)
},
addUser(user) {
this.selectedUserIds.push(user.id)
this.query = ''
},
removeUser(userId) {
this.selectedUserIds = this.selectedUserIds.filter((id) => id !== userId)
},
search(query) {
if (!query) {
this.loading = false
return
}
this.loading = true
this.userIds = []
this.$store
.dispatch('search', { q: query, resolve: true, type: 'accounts' })
.then((data) => {
this.loading = false
this.userIds = data.accounts.map((a) => a.id)
})
},
},
}
export default chatNew
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
index 52d4445b61..1d12384cc3 100644
--- a/src/components/chat_title/chat_title.js
+++ b/src/components/chat_title/chat_title.js
@@ -1,27 +1,25 @@
-import { defineAsyncComponent } from 'vue'
-
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
export default {
name: 'ChatTitle',
components: {
UserAvatar,
UserPopover,
},
props: ['user', 'withAvatar'],
computed: {
title() {
return this.user ? this.user.screen_name_ui : ''
},
htmlTitle() {
return this.user ? this.user.name_html : ''
},
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
},
}
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
index c9a6abcf1d..fe8e9e105c 100644
--- a/src/components/emoji_input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
@@ -1,151 +1,149 @@
-import { useEmojiStore } from 'src/stores/emoji.js'
-
/**
* suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e.
* (useEmojiStore().standardEmojiList + state.instance.customEmoji)
* data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users
*
* Depending on data present one or both (or none) can be present, so if field
* doesn't support user linking you can just provide only emoji.
*/
export default (data) => {
const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store)
return (input, nameKeywordLocalizer) => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
return emojiCurry(input, nameKeywordLocalizer)
}
if (firstChar === '@' && usersCurry) {
return usersCurry(input)
}
return []
}
}
export const suggestEmoji = (emojis) => (input, nameKeywordLocalizer) => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
.map((emoji) => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
.filter(
(emoji) =>
emoji.names
.concat(emoji.keywords)
.filter((kw) => kw.toLowerCase().match(noPrefix)).length,
)
.map((k) => {
let score = 0
// An exact match always wins
score += Math.max(
...k.names.map((name) => (name.toLowerCase() === noPrefix ? 200 : 0)),
0,
)
// Prioritize custom emoji a lot
score += k.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
score += Math.max(
...k.names.map((kw) =>
kw.toLowerCase().startsWith(noPrefix) ? 10 : 0,
),
0,
)
// Sort by length
score -= k.displayText.length
k.score = score
return k
})
.sort((a, b) => {
// Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
return b.score - a.score + alphabetically
})
}
export const suggestUsers = ({ dispatch, state }) => {
// Keep some persistent values in closure, most importantly for the
// custom debounce to work. Lodash debounce does not return a promise.
let suggestions = []
let previousQuery = ''
let timeout = null
let cancelUserSearch = null
const userSearch = (query) => dispatch('searchUsers', { query })
const debounceUserSearch = (query) => {
cancelUserSearch && cancelUserSearch()
return new Promise((resolve, reject) => {
timeout = setTimeout(() => {
userSearch(query).then(resolve).catch(reject)
}, 300)
cancelUserSearch = () => {
clearTimeout(timeout)
resolve([])
}
})
}
return async (input) => {
const noPrefix = input.toLowerCase().substr(1)
if (previousQuery === noPrefix) return suggestions
suggestions = []
previousQuery = noPrefix
// Fetch more and wait, don't fetch if there's the 2nd @ because
// the backend user search can't deal with it.
// Reference semantics make it so that we get the updated data after
// the await.
if (!noPrefix.includes('@')) {
await debounceUserSearch(noPrefix)
}
const newSuggestions = state.users.users
.filter(
(user) =>
user.screen_name &&
user.name &&
(user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix)),
)
.slice(0, 20)
.sort((a, b) => {
let aScore = 0
let bScore = 0
// Matches on screen name (i.e. user@instance) makes a priority
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
// Matches on name takes second priority
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
const diff = (bScore - aScore) * 10
// Then sort alphabetically
const activity = a.last_status_at < b.last_status_at ? 100 : -100
const nameAlphabetically = a.name > b.name ? 1 : -1
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
return diff + nameAlphabetically + screenNameAlphabetically + activity
})
.map((user) => ({
user,
displayText: user.screen_name_ui,
detailText: user.name,
imageUrl: user.profile_image_url_original,
replacement: '@' + user.screen_name + ' ',
}))
suggestions = newSuggestions || []
return suggestions
}
}
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index bc2bb092bc..6889fb8b05 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,444 +1,443 @@
import { chunk, debounce, trim } from 'lodash'
import { defineAsyncComponent } from 'vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import { useEmojiStore } from 'src/stores/emoji.js'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBasketballBall,
faBoxOpen,
faBus,
faCode,
faFlag,
faIceCream,
faLightbulb,
faPaw,
faSmile,
faSmileBeam,
faStickyNote,
faUser,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faBoxOpen,
faStickyNote,
faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faCode,
faFlag,
)
const UNICODE_EMOJI_GROUP_ICON = {
'smileys-and-emotion': 'smile',
'people-and-body': 'user',
'animals-and-nature': 'paw',
'food-and-drink': 'ice-cream',
'travel-and-places': 'bus',
activities: 'basketball-ball',
objects: 'lightbulb',
symbols: 'code',
flags: 'flag',
}
const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
const res = [emoji.displayText, nameLocalizer(emoji)]
if (emoji.annotations) {
languages.forEach((lang) => {
const keywords = emoji.annotations[lang]?.keywords || []
const name = emoji.annotations[lang]?.name
res.push(...keywords.concat([name]).filter((k) => k))
})
}
return res
}
const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
const orderedEmojiList = []
for (const emoji of list) {
const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
.map((k) => k.toLowerCase().indexOf(keywordLowercase))
.filter((k) => k > -1)
const indexOfKeyword = indices.length ? Math.min(...indices) : -1
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
}
orderedEmojiList[indexOfKeyword].push(emoji)
}
}
return orderedEmojiList.flat()
}
const getOffset = (elem) => {
const style = elem.style.transform
const res = /translateY\((\d+)px\)/.exec(style)
if (!res) {
return 0
}
return res[1]
}
const toHeaderId = (id) => {
return id.replace(/^row-\d+-/, '')
}
const EmojiPicker = {
props: {
enableStickerPicker: {
required: false,
type: Boolean,
default: true,
},
hideCustomEmoji: {
required: false,
type: Boolean,
default: false,
},
},
inject: {
popoversZLayer: {
default: '',
},
},
data() {
return {
keyword: '',
activeGroup: 'custom',
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
customEmojiTimeout: null,
hideCustomEmojiInPicker: false,
// Lazy-load only after the first time `showing` becomes true.
contentLoaded: false,
popoverShown: false,
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: [],
emojiSize: 0,
width: 0,
}
},
components: {
StickerPicker: defineAsyncComponent(
() => import('src/components/sticker_picker/sticker_picker.vue'),
),
Checkbox,
Popover,
},
methods: {
groupScroll(e) {
e.currentTarget.scrollLeft += e.deltaY + e.deltaX
},
updateEmojiSize() {
const css = window.getComputedStyle(this.$refs.popover.$el)
const fontSize = css.getPropertyValue('font-size') || '1rem'
const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem'
const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '').trim()
const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, ''))
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '').trim()
const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
let fontSizeMultiplier
if (fontSizeUnit.endsWith('em')) {
fontSizeMultiplier = fontSizeValue
} else {
fontSizeMultiplier = fontSizeValue / 14
}
let emojiSizeReal
if (emojiSizeUnit.endsWith('em')) {
emojiSizeReal = emojiSizeValue * fontSizeMultiplier * 14
} else {
emojiSizeReal = emojiSizeValue
}
const fullEmojiSize = emojiSizeReal + 2 * 0.2 * fontSizeMultiplier * 14
this.emojiSize = fullEmojiSize
},
togglePicker() {
if (this.popoverShown) {
this.hidePicker()
} else {
this.showPicker()
}
},
showPicker() {
this.$refs.popover.showPopover()
this.$nextTick(() => {
this.onShowing()
})
},
hidePicker() {
this.$refs.popover.hidePopover()
},
setAnchorEl(el) {
this.$refs.popover.setAnchorEl(el)
},
setGroupRef(name) {
return (el) => {
this.groupRefs[name] = el
}
},
onPopoverShown() {
this.popoverShown = true
},
onPopoverClosed() {
this.popoverShown = false
},
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
this.$emit('sticker-upload-failed', e)
},
onEmoji(emoji) {
const value = emoji.imageUrl
? `:${emoji.displayText}:`
: emoji.replacement
if (!this.keepOpen) {
this.$refs.popover.hidePopover()
}
this.$emit('emoji', {
insertion: value,
insertionUrl: emoji.imageUrl,
keepOpen: this.keepOpen,
})
},
onScroll(startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
const target = this.$refs['emoji-groups'].$el
this.scrolledGroup(target, visibleStartIndex, visibleEndIndex)
},
scrolledGroup(target, start, end) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojiItems.slice(start, end + 1).forEach((group) => {
const headerId = toHeaderId(group.id)
const ref = this.groupRefs['group-' + group.id]
if (!ref) {
return
}
const elem = ref.$el.parentElement
if (!elem) {
return
}
if (elem && getOffset(elem) <= top) {
this.activeGroup = headerId
}
})
this.scrollHeader()
})
},
scrollHeader() {
// Scroll the active tab's header into view
const headerRef = this.groupRefs['group-header-' + this.activeGroup]
const left = headerRef.offsetLeft
const right = left + headerRef.offsetWidth
const headerCont = this.$refs.header
const currentScroll = headerCont.scrollLeft
const currentScrollRight = currentScroll + headerCont.clientWidth
const setScroll = (s) => {
headerCont.scrollLeft = s
}
const margin = 7 // .emoji-tabs-item: padding
if (left - margin < currentScroll) {
setScroll(left - margin)
} else if (right + margin > currentScrollRight) {
setScroll(right + margin - headerCont.clientWidth)
}
},
highlight(groupId) {
this.setShowStickers(false)
const indexInList = this.emojiItems.findIndex((k) => k.id === groupId)
this.$refs['emoji-groups'].scrollToItem(indexInList)
},
updateScrolledClass(target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
this.groupsScrolledClass = 'scrolled-bottom'
} else {
this.groupsScrolledClass = 'scrolled-middle'
}
},
toggleStickers() {
this.showingStickers = !this.showingStickers
},
setShowStickers(value) {
this.showingStickers = value
},
filterByKeyword(list, keyword) {
return filterByKeyword(
list,
keyword,
this.languages,
this.maybeLocalizedEmojiName,
)
},
onShowing() {
const oldContentLoaded = this.contentLoaded
this.updateEmojiSize()
this.recalculateItemPerRow()
this.$nextTick(() => {
this.$refs.search.focus()
})
this.contentLoaded = true
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
if (!oldContentLoaded) {
this.$nextTick(() => {
if (this.defaultGroup) {
this.highlight(this.defaultGroup)
}
})
}
},
getFilteredEmojiGroups() {
return this.allEmojiGroups
.map((group) => ({
...group,
emojis: this.filterByKeyword(group.emojis, trim(this.keyword)),
}))
.filter((group) => group.emojis.length > 0)
},
recalculateItemPerRow() {
this.$nextTick(() => {
if (!this.$refs['emoji-groups']) {
return
}
this.width = this.$refs['emoji-groups'].$el.clientWidth
})
},
},
watch: {
keyword() {
this.onScroll()
this.debouncedHandleKeywordChange()
},
allCustomGroups() {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
},
},
computed: {
minItemSize() {
return this.emojiSize
},
// used to watch it
fontSize() {
this.$nextTick(() => {
this.updateEmojiSize()
})
return useMergedConfigStore().mergedConfig.fontSize
},
emojiHeight() {
return this.emojiSize
},
itemPerRow() {
return this.width ? Math.floor(this.width / this.emojiSize) : 6
},
activeGroupView() {
return this.showingStickers ? '' : this.activeGroup
},
stickersAvailable() {
if (useEmojiStore().stickers) {
return useEmojiStore().stickers.length > 0
}
return 0
},
allCustomGroups() {
if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) {
return {}
}
const emojis = useEmojiStore().groupedCustomEmojis
if (emojis.unpacked) {
emojis.unpacked.text = this.$t('emoji.unpacked')
}
return emojis
},
defaultGroup() {
return Object.keys(this.allCustomGroups)[0]
},
unicodeEmojiGroups() {
return useEmojiStore().standardEmojiGroupList.map((group) => ({
id: `standard-${group.id}`,
text: this.$t(`emoji.unicode_groups.${group.id}`),
icon: UNICODE_EMOJI_GROUP_ICON[group.id],
emojis: group.emojis,
}))
},
allEmojiGroups() {
return Object.entries(this.allCustomGroups)
.map(([, v]) => v)
.concat(this.unicodeEmojiGroups)
},
stickerPickerEnabled() {
return (useEmojiStore().stickers || []).length !== 0
},
debouncedHandleKeywordChange() {
return debounce(() => {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}, 500)
},
emojiItems() {
return this.filteredEmojiGroups
.map((group) =>
chunk(group.emojis, this.itemPerRow).map((items, index) => ({
...group,
id: index === 0 ? group.id : `row-${index}-${group.id}`,
emojis: items,
isFirstRow: index === 0,
})),
)
.reduce((a, c) => a.concat(c), [])
},
languages() {
return ensureFinalFallback(
useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiName() {
return (emoji) => {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
}
},
isInModal() {
return this.popoversZLayer === 'modals'
},
},
}
export default EmojiPicker
diff --git a/src/components/lists_menu/lists_menu_content.js b/src/components/lists_menu/lists_menu_content.js
index d96f145994..337ee4d4fe 100644
--- a/src/components/lists_menu/lists_menu_content.js
+++ b/src/components/lists_menu/lists_menu_content.js
@@ -1,25 +1,24 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import { getListEntries } from 'src/components/navigation/filter.js'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useListsStore } from 'src/stores/lists.js'
export const ListsMenuContent = {
props: ['showPin'],
components: {
NavigationEntry,
},
computed: {
...mapPiniaState(useListsStore, {
lists: getListEntries,
}),
...mapState({
currentUser: (state) => state.users.currentUser,
}),
},
}
export default ListsMenuContent
diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js
index a00db03cf9..a333e69706 100644
--- a/src/components/login_form/login_form.js
+++ b/src/components/login_form/login_form.js
@@ -1,92 +1,92 @@
-import { mapActions, mapState as mapPiniaState, mapStores } from 'pinia'
+import { mapActions, mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import { useAuthFlowStore } from 'src/stores/auth_flow.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { getLoginUrl, getTokenWithCredentials } from 'src/api/oauth.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes)
const LoginForm = {
data: () => ({
user: {},
error: false,
}),
computed: {
...mapState({
loggingIn: (state) => state.users.loggingIn,
}),
...mapPiniaState(useOAuthStore, ['clientId', 'clientSecret']),
...mapPiniaState(useInstanceStore, ['server', 'registrationOpen']),
...mapPiniaState(useAuthFlowStore, {
isTokenAuth: (store) => store.requiredToken,
isPasswordAuth: (store) => !store.requiredToken,
}),
},
methods: {
...mapActions(useAuthFlowStore, ['requireMFA', 'login']),
...mapActions(useOAuthStore, ['ensureAppToken']),
submit() {
this.isTokenAuth ? this.submitToken() : this.submitPassword()
},
submitToken() {
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.ensureAppToken().then(() => {
window.location.href = getLoginUrl({
clientId: this.clientId,
instance: this.server,
})
})
},
submitPassword() {
this.error = false
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.ensureAppToken().then(() => {
getTokenWithCredentials({
clientId: this.clientId,
clientSecret: this.clientSecret,
instance: this.server,
username: this.user.username,
password: this.user.password,
})
.then(({ data: result }) => {
this.login(result).then(() => {
this.$router.push({ name: 'friends' })
})
})
.catch((error) => {
if (error.errorData?.error === 'mfa_required') {
this.requireMFA({ settings: error })
} else if (error.identifier === 'password_reset_required') {
this.$router.push({
name: 'password-reset',
params: { passwordResetRequested: true },
})
} else {
this.error = error
this.focusOnPasswordInput()
}
return
})
})
},
clearError() {
this.error = false
},
focusOnPasswordInput() {
const passwordInput = this.$refs.passwordInput
passwordInput.focus()
passwordInput.setSelectionRange(0, passwordInput.value.length)
},
},
}
export default LoginForm
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index ad53b6e31d..fe5994f7cb 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -1,160 +1,159 @@
import { defineAsyncComponent } from 'vue'
import Modal from 'src/components/modal/modal.vue'
-import StillImage from 'src/components/still-image/still-image.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronLeft,
faChevronRight,
faCircleNotch,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(faChevronLeft, faChevronRight, faCircleNotch, faTimes)
const MediaModal = {
components: {
VideoAttachment: defineAsyncComponent(
() => import('src/components/video_attachment/video_attachment.vue'),
),
PinchZoom: defineAsyncComponent(
() => import('src/components/pinch_zoom/pinch_zoom.vue'),
),
SwipeClick: defineAsyncComponent(
() => import('src/components/swipe_click/swipe_click.vue'),
),
Modal,
Flash: defineAsyncComponent(() => import('src/components/flash/flash.vue')),
},
data() {
return {
loading: false,
swipeDirection: GestureService.DIRECTION_LEFT,
swipeThreshold: () => {
const considerableMoveRatio = 1 / 4
return window.innerWidth * considerableMoveRatio
},
pinchZoomMinScale: 1,
pinchZoomScaleResetLimit: 1.2,
}
},
computed: {
showing() {
return useMediaViewerStore().activated
},
media() {
return useMediaViewerStore().media
},
description() {
return this.currentMedia.description
},
currentIndex() {
return useMediaViewerStore().currentIndex
},
currentMedia() {
return this.media[this.currentIndex]
},
canNavigate() {
return this.media.length > 1
},
swipeDisableClickThreshold() {
// If there is only one media, allow more mouse movements to close the modal
// because there is less chance that the user wants to switch to another image
return () => (this.canNavigate ? 1 : 30)
},
},
methods: {
hide() {
// HACK: Closing immediately via a touch will cause the click
// to be processed on the content below the overlay
const transitionTime = 100 // ms
setTimeout(() => {
useMediaViewerStore().closeMediaViewer()
}, transitionTime)
},
hideIfNotSwiped(event) {
// If we have swiped over SwipeClick, do not trigger hide
const comp = this.$refs.swipeClick
if (!comp) {
this.hide()
} else {
comp.$gesture.click(event)
}
},
goPrev() {
if (this.canNavigate) {
const prevIndex =
this.currentIndex === 0
? this.media.length - 1
: this.currentIndex - 1
const newMedia = this.media[prevIndex]
if (newMedia.type === 'image') {
this.loading = true
}
useMediaViewerStore().setCurrentMedia(newMedia)
}
},
goNext() {
if (this.canNavigate) {
const nextIndex =
this.currentIndex === this.media.length - 1
? 0
: this.currentIndex + 1
const newMedia = this.media[nextIndex]
if (newMedia.type === 'image') {
this.loading = true
}
useMediaViewerStore().setCurrentMedia(newMedia)
}
},
onImageLoaded() {
this.loading = false
},
handleSwipePreview(offsets) {
this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 })
},
handleSwipeEnd(sign) {
this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
if (sign > 0) {
this.goNext()
} else if (sign < 0) {
this.goPrev()
}
},
handleKeyupEvent(e) {
if (this.showing && e.keyCode === 27) {
// escape
this.hide()
}
},
handleKeydownEvent(e) {
if (!this.showing) {
return
}
if (e.keyCode === 39) {
// arrow right
this.goNext()
} else if (e.keyCode === 37) {
// arrow left
this.goPrev()
}
},
},
mounted() {
window.addEventListener('popstate', this.hide)
document.addEventListener('keyup', this.handleKeyupEvent)
document.addEventListener('keydown', this.handleKeydownEvent)
},
unmounted() {
window.removeEventListener('popstate', this.hide)
document.removeEventListener('keyup', this.handleKeyupEvent)
document.removeEventListener('keydown', this.handleKeydownEvent)
},
}
export default MediaModal
diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 877e83c121..48f6d1d8ed 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -1,170 +1,169 @@
import { mapState as mapPiniaState } from 'pinia'
-import { defineAsyncComponent } from 'vue'
-import { mapGetters, mapState } from 'vuex'
+import { mapState } from 'vuex'
import UnicodeDomainIndicator from 'src/components/unicode_domain_indicator/unicode_domain_indicator.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faAt } from '@fortawesome/free-solid-svg-icons'
library.add(faAt)
const MentionLink = {
name: 'MentionLink',
components: {
UserAvatar,
UnicodeDomainIndicator,
UserPopover,
},
props: {
url: {
required: true,
type: String,
},
content: {
required: true,
type: String,
},
userId: {
required: false,
type: String,
},
userScreenName: {
required: false,
type: String,
},
},
data() {
return {
hasSelection: false,
}
},
methods: {
onClick() {
if (this.shouldShowTooltip) return
const link = generateProfileLink(
this.userId || this.user.id,
this.userScreenName || this.user.screen_name,
)
this.$router.push(link)
},
handleSelection() {
if (this.$refs.full) {
this.hasSelection = document
.getSelection()
.containsNode(this.$refs.full, true)
}
},
},
mounted() {
document.addEventListener('selectionchange', this.handleSelection)
},
unmounted() {
document.removeEventListener('selectionchange', this.handleSelection)
},
computed: {
user() {
return (
this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
)
},
isYou() {
// FIXME why user !== currentUser???
return this.user && this.user.id === this.currentUser.id
},
userName() {
return this.user && this.userNameFullUi.split('@')[0]
},
serverName() {
// XXX assumed that domain does not contain @
return (
this.user &&
(this.userNameFullUi.split('@')[1] ||
this.$store.getters.instanceDomain)
)
},
userNameFull() {
return this.user && this.user.screen_name
},
userNameFullUi() {
return this.user && this.user.screen_name_ui
},
highlightData() {
return this.highlight[this.user?.screen_name]
},
highlightType() {
return this.highlightData && '-' + this.highlightData.type
},
highlightClass() {
return this.highlightData && highlightClass(this.user)
},
style() {
if (this.highlightData) {
const {
backgroundColor,
backgroundPosition,
backgroundImage,
...rest
} = highlightStyle(this.highlightData)
return rest
}
},
classnames() {
return [
{
'-you': this.isYou && this.shouldBoldenYou,
'-highlighted': !!this.highlightData,
'-has-selection': this.hasSelection,
},
this.highlightType,
]
},
isRemote() {
return this.userName !== this.userNameFull
},
shouldShowFullUserName() {
const conf = this.mergedConfig.mentionLinkDisplay
if (conf === 'short') {
return false
} else if (conf === 'full') {
return true
} else {
// full_for_remote
return this.isRemote
}
},
shouldShowTooltip() {
return this.mergedConfig.mentionLinkShowTooltip
},
shouldShowAvatar() {
return this.mergedConfig.mentionLinkShowAvatar
},
shouldShowYous() {
return this.mergedConfig.mentionLinkShowYous
},
shouldBoldenYou() {
return this.mergedConfig.mentionLinkBoldenYou
},
shouldFadeDomain() {
return this.mergedConfig.mentionLinkFadeDomain
},
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapPiniaState(useUserHighlightStore, ['highlight']),
...mapState({
currentUser: (state) => state.users.currentUser,
}),
},
}
export default MentionLink
diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js
index b819c72aa2..ca4852ab31 100644
--- a/src/components/moderation_tools/moderation_tools.js
+++ b/src/components/moderation_tools/moderation_tools.js
@@ -1,672 +1,671 @@
import { last } from 'lodash'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import Popover from 'src/components/popover/popover.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown)
const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
const STRIP_MEDIA = 'mrf_tag:media-strip'
const FORCE_UNLISTED = 'mrf_tag:force-unlisted'
const DISABLE_REMOTE_SUBSCRIPTION = 'mrf_tag:disable-remote-subscription'
const DISABLE_ANY_SUBSCRIPTION = 'mrf_tag:disable-any-subscription'
const SANDBOX = 'mrf_tag:sandbox'
const QUARANTINE = 'mrf_tag:quarantine'
const TAGS = new Set([
FORCE_NSFW,
STRIP_MEDIA,
FORCE_UNLISTED,
DISABLE_REMOTE_SUBSCRIPTION,
DISABLE_ANY_SUBSCRIPTION,
SANDBOX,
QUARANTINE,
])
const ENTRIES = [
{
check: '!state:activated',
label: 'user_card.admin_menu.activate_account',
},
{
check: 'state:activated',
label: 'user_card.admin_menu.deactivate_account',
},
{
separator: true,
},
{
check: '!state:confirmed',
label: 'user_card.admin_menu.confirm_account',
},
{
check: 'action:resend_confirmation',
conditions: ['!state:confirmed'],
label: 'user_card.admin_menu.resend_confirmation',
},
// No API for revocation
// {
// check: 'state:confirmed',
// label: 'user_card.admin_menu.unconfirm_account',
// },
{
check: '!state:approved',
conditions: ['property:local'],
label: 'user_card.admin_menu.approve_account',
},
// No API for revocation
// {
// check: 'state:approved',
// label: 'user_card.admin_menu.unapprove_account',
// },
{
check: '!state:suggested',
// conditions: ['property:local'], // TODO Should we allow non-local users in suggested?
label: 'user_card.admin_menu.suggest_account',
},
{
check: 'state:suggested',
label: 'user_card.admin_menu.remove_suggested_account',
},
{
separator: true,
},
{
check: 'action:statuses',
label: 'user_card.admin_menu.show_statuses',
conditions: ['count:1'],
},
{
separator: true,
},
{
check: 'action:disable_mfa',
label: 'user_card.admin_menu.disable_mfa',
},
{
check: 'action:require_password_change',
label: 'user_card.admin_menu.require_password_change',
},
{
separator: true,
},
{
check: '!rights:moderator',
label: 'user_card.admin_menu.grant_moderator',
conditions: ['property:local', 'state:activated'],
},
{
check: 'rights:moderator',
label: 'user_card.admin_menu.revoke_moderator',
conditions: ['property:local', 'state:activated'],
},
{
check: '!rights:admin',
label: 'user_card.admin_menu.grant_admin',
conditions: ['property:local', 'state:activated'],
},
{
check: 'rights:admin',
label: 'user_card.admin_menu.revoke_admin',
conditions: ['property:local', 'state:activated'],
},
{
separator: true,
},
{
check: FORCE_NSFW,
label: 'user_card.admin_menu.force_nsfw',
},
{
check: STRIP_MEDIA,
label: 'user_card.admin_menu.strip_media',
},
{
check: FORCE_UNLISTED,
label: 'user_card.admin_menu.force_unlisted',
},
{
check: SANDBOX,
label: 'user_card.admin_menu.sandbox',
},
{
check: DISABLE_ANY_SUBSCRIPTION,
conditions: ['property:local'],
label: 'user_card.admin_menu.disable_any_subscription',
},
{
check: DISABLE_REMOTE_SUBSCRIPTION,
conditions: ['property:local'],
label: 'user_card.admin_menu.disable_remote_subscription',
},
{
check: QUARANTINE,
conditions: ['property:local'],
label: 'user_card.admin_menu.quarantine',
},
{
separator: true,
},
{
check: 'action:delete',
label: 'user_card.admin_menu.delete_account',
},
]
const ModerationTools = {
props: {
users: {
type: Array,
required: true,
},
},
created() {
if (this.users.length !== 1) return
useAdminSettingsStore().getUserData({ user: this.users[0] })
},
data() {
return {
open: false,
confirmDialogShow: false,
confirmDialogTitle: null,
confirmDialogContent: null,
confirmDialogConfirm: null,
confirmDialogAction: null,
confirmDialogGroup: null,
confirmDialogName: null,
}
},
components: {
ConfirmModal,
Popover,
},
computed: {
ready() {
return this.users.every((u) => u.adminData)
},
entries() {
return ENTRIES.map(({ check, label, separator, conditions }) => {
if (separator) return 'separator'
const [, negateToken, group, name] =
/^([!~]?)([a-z-_]+):([a-z-_]+)$/.exec(check)
const hasTag = this.tagsSet.has(`${group}:${name}`)
const noTag = this.tagsSet.has(`!${group}:${name}`)
const maybeTag = this.tagsSet.has(`~${group}:${name}`)
// We are checking for condition to show element, i.e. only show "activate" if user is "deactivated"
const checkNegated = negateToken === '!' || negateToken === '~'
// Naturally, new value should also be the same
const value = checkNegated
const action = (() => {
switch (group) {
case 'rights':
return () => this.setRight(name, value)
case 'state':
return () => this.setStatus(name, value)
case 'mrf_tag':
return () => this.setTag(`${group}:${name}`, noTag)
case 'action': {
switch (name) {
case 'delete':
return () => this.deleteUsers()
case 'resend_confirmation':
return () => this.resendConfirmationEmail()
case 'disable_mfa':
return () => this.disableMFA()
case 'statuses':
return () =>
this.$router.push(`/users/\$${this.users[0].id}/admin_view`)
case 'require_password_change':
return () => this.requirePasswordChange()
default:
throw new Error(`Unknown action group: ${name}`)
}
}
default:
throw new Error(`Unknown moderation group: ${group}`)
}
})()
let checkboxClass = ''
if (maybeTag) {
checkboxClass = 'menu-checkbox-indeterminate'
} else if (hasTag) {
checkboxClass = 'menu-checkbox-checked'
}
return {
check,
negateToken,
checkbox: group === 'mrf_tag',
checkboxClass,
conditions,
group,
name,
action,
label,
value,
}
})
.filter((entry) => {
if (entry === 'separator') return true
const { group, name, value, conditions } = entry
if (conditions) {
// Checking that all items match positive criteria
const positive = conditions.every((condition) =>
this.totalSet.has(condition),
)
// Checking that there are no items that don't match criteria
const negative = conditions.some((condition) =>
this.totalSet.has('!' + condition),
)
if (!(positive && !negative)) return false
}
switch (group) {
case 'action':
if (name === 'statuses') return this.privileged('users_read')
else return true
case 'rights':
return this.canGrantRole(name, value)
case 'state':
return this.canChangeState(name, value)
case 'mrf_tag':
return this.canUseTagPolicy
default: {
throw new Error(`Unknown moderation group: ${group}`)
}
}
})
.reduce((acc, entry, index) => {
// Removing any double separators as well
// as separators at very end and bery beginning
if (entry === 'separator') {
if (
acc.length === 0 ||
last(acc) === 'separator' ||
index === ENTRIES.length - 1
) {
return acc
}
}
return [...acc, entry]
}, [])
},
rightsSet() {
return this.users.reduce((acc, user) => {
if (user.rights.admin) {
acc.add('rights:admin')
} else {
acc.add('!rights:admin')
}
if (user.rights.moderator) {
acc.add('rights:moderator')
} else {
acc.add('!rights:moderator')
}
return acc
}, new Set())
},
stateSet() {
return this.users.reduce((acc, user) => {
if (!user.deactivated) {
acc.add('state:activated')
} else {
acc.add('!state:activated')
}
if (user.adminData?.is_confirmed) {
acc.add('state:confirmed')
} else {
acc.add('!state:confirmed')
}
if (user.adminData?.is_approved) {
acc.add('state:approved')
} else {
acc.add('!state:approved')
}
if (user.adminData?.is_suggested) {
acc.add('state:suggested')
} else {
acc.add('!state:suggested')
}
return acc
}, new Set())
},
tagsSet() {
const present = new Set()
const missing = new Set()
this.users.forEach((user) => {
TAGS.forEach((tag) => {
if (user.tags.has(tag)) {
present.add(tag)
} else {
missing.add(tag)
}
})
})
const result = new Set()
// Each tag can have three states for given group of users
TAGS.forEach((tag) => {
if (present.has(tag) && missing.has(tag)) {
// Some users have tag, some don't: "~tag"
result.add(`~${tag}`)
} else if (missing.has(tag)) {
// No users have tag: "!tag"
result.add(`!${tag}`)
} else {
// All users have tag: "tag"
result.add(tag)
}
})
return result
},
propertySet() {
return this.users.reduce((acc, user) => {
if (user.is_local) {
acc.add('property:local')
} else {
acc.add('!property:local')
}
return acc
}, new Set())
},
disabled() {
return !this.ready || this.users.length === 0
},
totalSet() {
return new Set([
...this.rightsSet,
...this.stateSet,
...this.tagsSet,
...this.propertySet,
`count:${this.users.length}`,
])
},
canDeleteAccount() {
return this.privileged('users_delete')
},
canUseTagPolicy() {
return (
useInstanceCapabilitiesStore().tagPolicyAvailable &&
this.privileged('users_manage_tags')
)
},
isAdmin() {
this.$store.state.users.currentUser.role === 'admin'
},
},
methods: {
canGrantRole(name, value) {
const setEntry = `${value ? '!' : ''}rights:${name}`
return this.isAdmin && this.totalSet.has(setEntry)
},
canChangeState(name, value) {
const setEntry = `${value ? '!' : ''}state:${name}`
const privilege = (() => {
switch (name) {
case 'activated':
return 'users_manage_activation_state'
case 'approved':
return 'users_manage_invites'
case 'confirmed':
return 'users_manage_credentials'
default:
return null
}
})()
return this.privileged(privilege) && this.totalSet.has(setEntry)
},
doConfirmDialogAction() {
if (typeof this.confirmDialogAction !== 'function') {
console.error('Confirm Dialog action is not a function!!')
}
this.confirmDialogAction()
this.clearConfirmDialog()
},
clearConfirmDialog() {
this.confirmDialogShow = false
this.confirmDialogTitle = null
this.confirmDialogContent = null
this.confirmDialogContent2 = null
this.confirmDialogDanger = false
this.confirmDialogConfirm = null
this.confirmDialogAction = null
this.confirmDialogGroup = null
this.confirmDialogName = null
},
privileged(privilege) {
if (this.isAdmin) return true
return this.$store.state.users.currentUser.privileges.has(privilege)
},
setTag(tag, value) {
useAdminSettingsStore().setUsersTags({
users: this.users,
value,
tags: [tag],
})
},
setRight(right, value) {
useAdminSettingsStore().setUsersRight({ users: this.users, value, right })
},
setStatus(name, value) {
const noun = (() => {
switch (name) {
case 'activated':
return 'Activation'
case 'confirmed':
return 'Confirmation'
case 'approved':
return 'Approval'
case 'suggested':
return 'Suggestion'
}
})()
useAdminSettingsStore()[`setUsers${noun}Status`]({
users: this.users,
value,
})
},
resendConfirmationEmail() {
useAdminSettingsStore().resendConfirmationEmail({ users: this.users })
},
requirePasswordChange() {
useAdminSettingsStore().requirePasswordChange({ users: this.users })
},
disableMFA() {
this.users.forEach((user) => {
useAdminSettingsStore().disableMFA({ user })
})
},
deleteUsers() {
const { id, name } = this.users[0]
useAdminSettingsStore()
.deleteUsers({ users: this.users })
.then((userIds) => {
if (userIds.length > 1) return
const isProfile =
this.$route.name === 'external-user-profile' ||
this.$route.name === 'user-profile'
const isTargetUser =
this.$route.params.name === name || this.$route.params.id === id
if (isProfile && isTargetUser) {
window.history.back()
}
})
},
setOpen(value) {
this.open = value
},
maybeShowConfirm(close, { group, name, action, value }) {
close()
this.confirmDialogName = name
this.confirmDialogGroup = group
this.confirmDialogAction = () => action()
switch (group) {
case 'action': {
switch (name) {
case 'delete': {
this.confirmDialogShow = true
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.delete_title',
)
this.confirmDialogDanger = true
this.confirmDialogContent =
'user_card.admin_menu.confirm_modal.delete_content'
this.confirmDialogContent2 =
'user_card.admin_menu.confirm_modal.delete_content_2'
this.confirmDialogConfirm = this.$t(
'user_card.admin_menu.confirm_modal.delete',
)
break
}
case 'resend_confirmation': {
this.confirmDialogShow = true
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.resend_confirmation_title',
)
this.confirmDialogContent =
'user_card.admin_menu.confirm_modal.resend_confirmation_content'
this.confirmDialogConfirm = this.$t(
'user_card.admin_menu.confirm_modal.send',
)
break
}
case 'disable_mfa': {
this.confirmDialogShow = true
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.disable_mfa_title',
)
this.confirmDialogContent =
'user_card.admin_menu.confirm_modal.disable_mfa_content'
this.confirmDialogConfirm = this.$t('settings.confirm')
break
}
case 'require_password_change': {
this.confirmDialogShow = true
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.require_password_change_title',
)
this.confirmDialogContent =
'user_card.admin_menu.confirm_modal.require_password_change_content'
this.confirmDialogConfirm = this.$t('settings.confirm')
break
}
}
break
}
case 'state': {
switch (name) {
case 'activated': {
this.confirmDialogShow = true
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.activate_title',
)
this.confirmDialogContent = value
? 'user_card.admin_menu.confirm_modal.activate_content'
: 'user_card.admin_menu.confirm_modal.deactivate_content'
this.confirmDialogConfirm = value
? this.$t('user_card.admin_menu.confirm_modal.activate')
: this.$t('user_card.admin_menu.confirm_modal.deactivate')
break
}
// Confirmation and Approval statuses cannot be revokedn(no API)
case 'confirmed': {
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.confirm_title',
)
this.confirmDialogContent = //value
/*?*/ 'user_card.admin_menu.confirm_modal.confirm_content'
//: 'user_card.admin_menu.confirm_modal.confirm_revoke_content'
this.confirmDialogConfirm = value
/*?*/ this.$t('user_card.admin_menu.confirm_modal.confirm')
//: this.$t('user_card.admin_menu.confirm_modal.revoke')
break
}
case 'approved': {
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.approval_title',
)
this.confirmDialogContent = //value
/*?*/ 'user_card.admin_menu.confirm_modal.approval_content'
//: 'user_card.admin_menu.confirm_modal.approval_revoke_content'
this.confirmDialogConfirm = value
/*?*/ this.$t('user_card.admin_menu.confirm_modal.approve')
//: this.$t('user_card.admin_menu.confirm_modal.revoke')
break
}
case 'suggested': {
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.suggest_title',
)
this.confirmDialogContent = value
? 'user_card.admin_menu.confirm_modal.add_suggest_content'
: 'user_card.admin_menu.confirm_modal.remove_suggest_content'
this.confirmDialogConfirm = value
? this.$t('user_card.admin_menu.confirm_modal.add')
: this.$t('user_card.admin_menu.confirm_modal.remove')
break
}
}
break
}
case 'rights': {
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.user_rights_title',
)
this.confirmDialogContent = value
? 'user_card.admin_menu.confirm_modal.grant_role_content'
: 'user_card.admin_menu.confirm_modal.revoke_role_content'
this.confirmDialogConfirm = value
? this.$t('user_card.admin_menu.confirm_modal.grant')
: this.$t('user_card.admin_menu.confirm_modal.revoke')
break
}
case 'mrf_tag': {
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.user_tag_title',
)
this.confirmDialogContent = value
? 'user_card.admin_menu.confirm_modal.assign_tag_content'
: 'user_card.admin_menu.confirm_modal.unassign_tag_content'
this.confirmDialogConfirm = value
? this.$t('user_card.admin_menu.confirm_modal.assign')
: this.$t('user_card.admin_menu.confirm_modal.unassign')
break
}
}
if (this.users.length > 1) {
this.confirmDialogShow = true
}
if (!this.confirmDialogShow) {
this.doConfirmDialogAction()
}
},
},
}
export default ModerationTools
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index c564355f38..4d9c345461 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,995 +1,994 @@
import { debounce, map, reject, uniqBy } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
-import { mapGetters } from 'vuex'
import Attachment from 'src/components/attachment/attachment.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DraftCloser from 'src/components/draft_closer/draft_closer.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Gallery from 'src/components/gallery/gallery.vue'
import MediaUpload from 'src/components/media_upload/media_upload.vue'
import Popover from 'src/components/popover/popover.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import Select from 'src/components/select/select.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import statusPoster from '../../services/status_poster/status_poster.service.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { pollFormToMasto } from 'src/services/poll/poll.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBan,
faChevronDown,
faChevronLeft,
faChevronRight,
faCircleNotch,
faPollH,
faQuoteRight,
faSmileBeam,
faTimes,
faUpload,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSmileBeam,
faPollH,
faUpload,
faQuoteRight,
faBan,
faTimes,
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight,
)
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
let allAttentions = [...attentions]
allAttentions.unshift(user)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: currentUser.id })
const mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
// Converts a string with px to a number like '2px' -> 2
const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const typeAndRefId = ({ replyTo, profileMention, statusId }) => {
if (replyTo) {
return ['reply', replyTo]
} else if (profileMention) {
return ['mention', profileMention]
} else if (statusId) {
return ['edit', statusId]
} else {
return ['new', '']
}
}
const PostStatusForm = {
props: [
'statusId',
'statusText',
'statusIsSensitive',
'statusPoll',
'statusFiles',
'statusMediaDescriptions',
'statusScope',
'statusContentType',
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
'subject',
'disableSubject',
'disableScopeSelector',
'disableVisibilitySelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
'disableQuotes',
'disableSensitivityCheckbox',
'disableSubmit',
'disablePreview',
'disableDraft',
'hideDraft',
'closeable',
'placeholder',
'maxHeight',
'postHandler',
'preserveFocus',
'autoFocus',
'fileLimit',
'submitOnEnter',
'emojiPickerPlacement',
'optimisticPosting',
'profileMention',
'draftId',
],
emits: [
'posted',
'draft-done',
'resize',
'mediaplay',
'mediapause',
'can-close',
'update',
],
components: {
MediaUpload,
EmojiInput,
PollForm: defineAsyncComponent(
() => import('src/components/poll/poll_form.vue'),
),
QuoteForm: defineAsyncComponent(
() => import('src/components/quote/quote_form.vue'),
),
ScopeSelector,
Checkbox,
Select,
Attachment,
StatusContent,
Gallery,
DraftCloser,
Popover,
},
mounted() {
this.updateIdempotencyKey()
this.resize(this.$refs.textarea)
if (this.replyTo) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.replyTo || this.autoFocus) {
this.$refs.textarea.focus()
}
},
data() {
const preset = this.$route.query.message
let statusText = preset || ''
const { scopeCopy } = useMergedConfigStore().mergedConfig
const [statusType, refId] = typeAndRefId({
replyTo: this.replyTo,
profileMention: this.profileMention && this.repliedUser?.id,
statusId: this.statusId,
})
// If we are starting a new post, do not associate it with old drafts
let statusParams =
!this.disableDraft && (this.draftId || statusType !== 'new')
? this.getDraft(statusType, refId)
: null
if (!statusParams) {
if (statusType === 'reply' || statusType === 'mention') {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString(
{ user: this.repliedUser, attentions: this.attentions },
currentUser,
)
}
const scope =
(this.copyMessageScope && scopeCopy) ||
this.copyMessageScope === 'direct'
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
const { postContentType: contentType, sensitiveByDefault } =
useMergedConfigStore().mergedConfig
statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '',
status: statusText,
nsfw: !!sensitiveByDefault,
files: [],
poll: {},
hasPoll: false,
hasQuote: false,
quote: {
id: '',
url: '',
thread: false,
},
mediaDescriptions: {},
visibility: scope,
contentType,
quoting: false,
}
if (statusType === 'edit') {
const statusContentType = this.statusContentType || contentType
statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '',
status: this.statusText || '',
nsfw: this.statusIsSensitive || !!sensitiveByDefault,
files: this.statusFiles || [],
poll: this.statusPoll || {},
hasPoll: false,
hasQuote: false,
quote: {
id: '',
url: '',
thread: false,
},
mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || scope,
contentType: statusContentType,
}
}
}
return {
randomSeed: genRandomSeed(),
dropFiles: [],
uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
newStatus: statusParams,
caret: 0,
showDropIcon: 'hide',
dropStopTimeout: null,
preview: null,
previewLoading: false,
emojiInputShown: false,
idempotencyKey: '',
saveInhibited: true,
saveable: false,
}
},
computed: {
users() {
return this.$store.state.users.users
},
userDefaultScope() {
return this.$store.state.users.currentUser.default_scope
},
showAllScopes() {
return !this.mergedConfig.minimalScopesMode
},
hideExtraActions() {
return this.disableDraft || this.hideDraft
},
emojiUserSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
emoji() {
return useEmojiStore().standardEmojiList
},
customEmoji() {
return useEmojiStore().customEmoji
},
statusLength() {
return this.newStatus.status.length
},
spoilerTextLength() {
return this.newStatus.spoilerText.length
},
statusLengthLimit() {
return useInstanceStore().limits.textLimit
},
hasStatusLengthLimit() {
return this.statusLengthLimit > 0
},
charactersLeft() {
return (
this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
)
},
isOverLengthLimit() {
return this.hasStatusLengthLimit && this.charactersLeft < 0
},
minimalScopesMode() {
return useInstanceStore().minimalScopesMode
},
alwaysShowSubject() {
return this.mergedConfig.alwaysShowSubjectInput
},
postFormats() {
return useInstanceCapabilitiesStore().postFormats || []
},
safeDMEnabled() {
return useInstanceCapabilitiesStore().safeDM
},
pollsAvailable() {
return (
useInstanceCapabilitiesStore().pollsAvailable &&
useInstanceStore().limits.pollLimits.max_options >= 2 &&
this.disablePolls !== true
)
},
hideScopeNotice() {
return (
this.disableNotice ||
useMergedConfigStore().mergedConfig.hideScopeNotice
)
},
pollContentError() {
return (
this.pollFormVisible && this.newStatus.poll && this.newStatus.poll.error
)
},
showPreview() {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
emptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
},
uploadFileLimitReached() {
return this.newStatus.files.length >= this.fileLimit
},
isEdit() {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
},
quotingAvailable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false
}
return this.disableQuotes !== true
},
isReply() {
return this.newStatus.type === 'reply'
},
quotable() {
return this.quotingAvailable && this.replyTo
},
quoteThreadToggled: {
get() {
return this.newStatus.hasQuote && this.newStatus.quote.thread
},
set(value) {
this.newStatus.hasQuote = value
this.newStatus.quote.thread = value
this.newStatus.quote.id = value ? this.replyTo : ''
},
},
defaultQuotable() {
if (
!this.quotingAvailable ||
!this.isReply ||
!useMergedConfigStore().mergedConfig.quoteReply
) {
return false
}
const repliedStatus =
this.$store.state.statuses.allStatusesObject[this.replyTo]
if (!repliedStatus) {
return false
}
if (
repliedStatus.visibility === 'public' ||
repliedStatus.visibility === 'unlisted' ||
repliedStatus.visibility === 'local'
) {
return true
} else if (repliedStatus.visibility === 'private') {
return repliedStatus.user.id === this.$store.state.users.currentUser.id
}
return false
},
inReplyStatusId() {
return !this.newStatus.hasQuote ||
!this.newStatus.quote.thread ||
!this.newStatus.quote.id
? this.replyTo
: undefined
},
quoteId() {
return this.newStatus.hasQuote ? this.newStatus.quote.id : undefined
},
debouncedMaybeAutoSaveDraft() {
return debounce(this.maybeAutoSaveDraft, 3000)
},
pollFormVisible() {
return this.newStatus.hasPoll
},
quoteFormVisible() {
return this.newStatus.hasQuote && !this.newStatus.quote.thread
},
shouldAutoSaveDraft() {
return useMergedConfigStore().mergedConfig.autoSaveDraft
},
autoSaveState() {
if (this.saveable) {
return this.$t('post_status.auto_save_saving')
} else if (this.newStatus.id) {
return this.$t('post_status.auto_save_saved')
} else {
return this.$t('post_status.auto_save_nothing_new')
}
},
safeToSaveDraft() {
return (
(this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll ||
this.newStatus.hasQuote) &&
this.saveable
)
},
hasEmptyDraft() {
return (
this.newStatus.id &&
!(
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll ||
this.newStatus.hasQuote
)
)
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.mobileLayout,
}),
},
watch: {
newStatus: {
deep: true,
handler() {
this.statusChanged()
},
},
saveable(val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
// MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed
if (val) {
this.addBeforeUnloadListener()
} else {
this.removeBeforeUnloadListener()
}
},
},
beforeUnmount() {
this.maybeAutoSaveDraft()
this.removeBeforeUnloadListener()
},
methods: {
...mapActions(useMediaViewerStore, ['increment']),
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.saveable = true
this.saveInhibited = false
},
clearStatus() {
const newStatus = this.newStatus
this.saveInhibited = true
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
poll: {},
hasPoll: false,
hasQuote: false,
quote: {},
mediaDescriptions: {},
}
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
this.clearQuoteForm()
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
this.saveable = false
},
async postStatus(event, newStatus) {
if (this.posting && !this.optimisticPosting) {
return
}
if (this.disableSubmit) {
return
}
if (this.emojiInputShown) {
return
}
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (
this.optimisticPosting &&
(this.emptyStatus || this.isOverLengthLimit)
) {
return
}
if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
const poll = newStatus.hasPoll ? pollFormToMasto(newStatus.poll) : {}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true
try {
await this.setAllMediaDescriptions()
} catch {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const postingOptions = {
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.inReplyStatusId,
quoteId: this.quoteId,
contentType: newStatus.contentType,
poll,
idempotencyKey: this.idempotencyKey,
}
const postHandler = this.postHandler
? this.postHandler
: statusPoster.postStatus
postHandler(postingOptions).then((data) => {
if (!data.error) {
this.abandonDraft()
this.clearStatus()
this.$emit('posted', data)
} else {
this.error = data.error
}
this.posting = false
})
},
previewStatus() {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
}
const newStatus = this.newStatus
this.previewLoading = true
statusPoster
.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: [],
store: this.$store,
inReplyToStatusId: this.inReplyStatusId,
quoteId: this.quoteId,
contentType: newStatus.contentType,
poll: {},
preview: true,
})
.then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
})
.catch((error) => {
this.preview = { error }
})
.finally(() => {
this.previewLoading = false
})
},
debouncePreviewStatus: debounce(function () {
this.previewStatus()
}, 500),
autoPreview() {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview() {
this.preview = null
this.previewLoading = false
},
togglePreview() {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
},
addMediaFile(fileInfo) {
this.newStatus.files.push(fileInfo)
this.$emit('resize', { delayed: true })
},
removeMediaFile(fileInfo) {
const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
editAttachment(fileInfo, newText) {
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
shiftUpMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index + 1, 0, fileInfo)
},
uploadFailed(errString, templateArgs) {
templateArgs = templateArgs || {}
this.error =
this.$t('upload.error.base') +
' ' +
this.$t('upload.error.' + errString, templateArgs)
},
startedUploadingFiles() {
this.uploadingFiles = true
},
finishedUploadingFiles() {
this.$emit('resize')
this.uploadingFiles = false
},
paste(e) {
this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop(e) {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
}
},
fileDragStop() {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag(e) {
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
},
onEmojiInputInput() {
this.$nextTick(() => {
this.resize(this.$refs.textarea)
})
},
resize(e) {
const target = e.target || e
if (!(target instanceof window.Element)) {
return
}
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
this.$emit('resize')
return
}
const formRef = this.$refs.form
const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr =
window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef =
this.$el.closest('.column.-scrollable') ||
this.$el.closest('.post-form-modal-view') ||
window
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
const topPadding = pxStringToNumber(topPaddingStr)
const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
const oldHeight = pxStringToNumber(target.style.height)
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
* scrollHeight returns element's scrollable content height, i.e. visible
* element + overscrolled parts of it. We use it to determine when text
* inside the textarea exceeded its height, so we can set height to prevent
* overscroll, i.e. make textarea grow with the text. HOWEVER, since we
* explicitly set new height, scrollHeight won't go below that, so we can't
* SHRINK the textarea when there's extra space. To workaround that we set
* height to 'auto' which makes textarea tiny again, so that scrollHeight
* will match text height again. HOWEVER, shrinking textarea can screw with
* the scroll since there might be not enough padding around form-bottom to even
* warrant a scroll, so it will jump to 0 and refuse to move anywhere,
* so we check current scroll position before shrinking and then restore it
* with needed delta.
*/
// this part has to be BEFORE the content size update
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// BEGIN content size update
target.style.height = 'auto'
const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
let newHeight = this.maxHeight
? Math.min(heightWithoutPadding, this.maxHeight)
: heightWithoutPadding
// This is a bit of a hack to combat target.scrollHeight being different on every other input
// on some browsers for whatever reason. Don't change the height if difference is 1px or less.
if (Math.abs(newHeight - oldHeight) <= 1) {
newHeight = oldHeight
}
target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
// END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const bottomBottomBorder =
bottomRef.offsetHeight +
findOffset(bottomRef, scrollerRef).top +
bottomBottomPadding
const isBottomObstructed = scrollerBottomBorder < bottomBottomBorder
const isFormBiggerThanScroller = scrollerHeight < formRef.offsetHeight
const bottomChangeDelta = bottomBottomBorder - scrollerBottomBorder
// The intention is basically this;
// Keep form-bottom always visible so that submit button is in view EXCEPT
// if form element bigger than scroller and caret isn't at the end, so that
// if you scroll up and edit middle of text you won't get scrolled back to bottom
const shouldScrollToBottom =
isBottomObstructed &&
!(
isFormBiggerThanScroller &&
this.$refs.textarea.selectionStart !==
this.$refs.textarea.value.length
)
const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
const targetScroll = Math.round(currentScroll + totalDelta)
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
},
clearError() {
this.error = null
},
changeVis(visibility) {
this.newStatus.visibility = visibility
},
togglePollForm() {
this.newStatus.hasPoll = !this.newStatus.hasPoll
},
setPoll(poll) {
this.newStatus.poll = poll
},
clearPollForm() {
if (this.$refs.pollForm) {
this.$refs.pollForm.clear()
}
},
clearQuoteForm() {
if (this.$refs.quoteForm) {
this.$refs.quoteForm.clear()
}
},
toggleQuoteForm() {
this.newStatus.hasQuote = !this.newStatus.hasQuote
this.newStatus.quote = {}
this.newStatus.quote.thread = false
this.newStatus.quote.id = null
this.newStatus.quote.url = ''
},
dismissScopeNotice() {
useSyncConfigStore().setSimplePrefAndSave({
path: 'hideScopeNotice',
value: true,
})
},
setMediaDescription(id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({
store: this.$store,
id,
description,
})
},
setAllMediaDescriptions() {
const ids = this.newStatus.files.map((file) => file.id)
return Promise.all(ids.map((id) => this.setMediaDescription(id)))
},
handleEmojiInputShow(value) {
this.emojiInputShown = value
},
updateIdempotencyKey() {
this.idempotencyKey = Date.now().toString()
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
propsToNative(props) {
return propsToNative(props)
},
saveDraft() {
if (!this.disableDraft && !this.saveInhibited) {
if (this.safeToSaveDraft) {
return this.$store
.dispatch('addOrSaveDraft', { draft: this.newStatus })
.then((id) => {
if (this.newStatus.id !== id) {
this.newStatus.id = id
}
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.$emit('draft-done')
}
})
} else if (this.hasEmptyDraft) {
// There is a draft, but there is nothing in it, clear it
return this.abandonDraft().then(() => {
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.$emit('draft-done')
}
})
}
}
return Promise.resolve()
},
maybeAutoSaveDraft() {
if (this.shouldAutoSaveDraft) {
this.saveDraft(false)
}
},
abandonDraft() {
return this.$store.dispatch('abandonDraft', { id: this.newStatus.id })
},
getDraft(statusType, refId) {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
statusType,
refId,
)
if (existingDrafts.length) {
return existingDrafts[0]
}
}
// No draft available, fall back
},
requestClose() {
if (!this.saveable) {
this.$emit('can-close')
} else {
this.$refs.draftCloser.requestClose()
}
},
saveAndCloseDraft() {
this.saveDraft().then(() => {
this.$emit('can-close')
})
},
discardAndCloseDraft() {
this.abandonDraft().then(() => {
this.$emit('can-close')
})
},
addBeforeUnloadListener() {
this._beforeUnloadListener ||= () => {
this.saveDraft()
}
window.addEventListener('beforeunload', this._beforeUnloadListener)
},
removeBeforeUnloadListener() {
if (this._beforeUnloadListener) {
window.removeEventListener('beforeunload', this._beforeUnloadListener)
}
},
},
}
export default PostStatusForm
diff --git a/src/components/post_status_modal/post_status_modal.js b/src/components/post_status_modal/post_status_modal.js
index 6011281cf8..973c2b1a58 100644
--- a/src/components/post_status_modal/post_status_modal.js
+++ b/src/components/post_status_modal/post_status_modal.js
@@ -1,61 +1,60 @@
import { get } from 'lodash'
-import { defineAsyncComponent } from 'vue'
import Modal from 'src/components/modal/modal.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { usePostStatusStore } from 'src/stores/post_status.js'
const PostStatusModal = {
components: {
PostStatusForm,
Modal,
},
data() {
return {
resettingForm: false,
}
},
computed: {
isLoggedIn() {
return !!this.$store.state.users.currentUser
},
modalActivated() {
return usePostStatusStore().modalActivated
},
isFormVisible() {
return this.isLoggedIn && !this.resettingForm && this.modalActivated
},
params() {
return usePostStatusStore().params || {}
},
},
watch: {
params(newVal, oldVal) {
if (get(newVal, 'repliedUser.id') !== get(oldVal, 'repliedUser.id')) {
this.resettingForm = true
this.$nextTick(() => {
this.resettingForm = false
})
}
},
isFormVisible(val) {
if (val) {
this.$nextTick(
() => this.$el && this.$el.querySelector('textarea').focus(),
)
}
},
},
methods: {
closeModal() {
usePostStatusStore().closePostStatusModal()
},
resetAndClose() {
usePostStatusStore().resetPostStatusModal()
usePostStatusStore().closePostStatusModal()
},
},
}
export default PostStatusModal
diff --git a/src/components/quote/quote.js b/src/components/quote/quote.js
index 3b771b3f28..439d3440a9 100644
--- a/src/components/quote/quote.js
+++ b/src/components/quote/quote.js
@@ -1,99 +1,97 @@
-import { defineAsyncComponent } from 'vue'
-
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
export default {
components: {},
name: 'Quote',
props: {
visible: {
type: Boolean,
},
loading: {
type: Boolean,
},
statusId: {
type: String,
},
statusUrl: {
type: String,
},
statusVisible: {
type: Boolean,
},
initiallyExpanded: {
type: Boolean,
},
},
data() {
return {
displayQuote: this.initiallyExpanded,
fetchAttempted: false,
fetching: false,
error: null,
}
},
created() {
this.maybeFetchStatus()
},
watch: {
statusId() {
this.maybeFetchStatus()
},
},
computed: {
quotedStatus() {
return this.statusId
? this.$store.state.statuses.allStatusesObject[this.statusId]
: undefined
},
shouldDisplayQuote() {
return this.displayQuote && this.quotedStatus
},
hasVisibleQuote() {
return (
this.statusUrl &&
this.statusVisible &&
(this.showSpinner || this.quotedStatus)
)
},
hasInvisibleQuote() {
return this.statusUrl && !this.statusVisible
},
showSpinner() {
return this.loading || this.fetching
},
},
methods: {
toggleDisplayQuote() {
this.displayQuote = !this.displayQuote
this.maybeFetchStatus()
},
maybeFetchStatus() {
if (this.statusId && this.displayQuote && !this.quotedStatus) {
this.fetchStatus()
}
},
fetchStatus() {
this.fetchAttempted = true
this.fetching = true
this.$emit('loading', true)
this.$store
.dispatch('fetchStatus', this.statusId)
.then(() => {
this.displayQuote = true
})
.catch((error) => {
this.error = error
this.$emit('error', error)
})
.finally(() => {
this.fetching = false
this.$emit('loading', false)
})
},
},
}
diff --git a/src/components/settings_modal/admin_tabs/admin_user_card.js b/src/components/settings_modal/admin_tabs/admin_user_card.js
index a075db010a..ba0f0e9f7d 100644
--- a/src/components/settings_modal/admin_tabs/admin_user_card.js
+++ b/src/components/settings_modal/admin_tabs/admin_user_card.js
@@ -1,32 +1,30 @@
-import { defineAsyncComponent } from 'vue'
-
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import ModerationTools from 'src/components/moderation_tools/moderation_tools.vue'
const AdminUserCard = {
props: {
userId: {
type: String,
},
},
components: {
BasicUserCard,
ModerationTools,
},
computed: {
user() {
return this.$store.getters.findUser(this.userId)
},
isAdmin() {
return this.user.rights.admin
},
isModerator() {
return this.user.rights.moderator
},
isActivated() {
return !this.user.deactivated
},
},
}
export default AdminUserCard
diff --git a/src/components/settings_modal/admin_tabs/emoji_tab.js b/src/components/settings_modal/admin_tabs/emoji_tab.js
index f64128c57e..56361587de 100644
--- a/src/components/settings_modal/admin_tabs/emoji_tab.js
+++ b/src/components/settings_modal/admin_tabs/emoji_tab.js
@@ -1,318 +1,318 @@
import Checkbox from 'components/checkbox/checkbox.vue'
import Popover from 'components/popover/popover.vue'
import Select from 'components/select/select.vue'
import StillImage from 'components/still-image/still-image.vue'
-import { assign, clone } from 'lodash'
+import { clone } from 'lodash'
import { defineAsyncComponent } from 'vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue'
import ModifiedIndicator from '../helpers/modified_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowsRotate,
faDownload,
faFolderOpen,
faServer,
} from '@fortawesome/free-solid-svg-icons'
library.add(faArrowsRotate, faFolderOpen, faDownload, faServer)
const EmojiTab = {
components: {
TabSwitcher,
StringSetting,
Checkbox,
StillImage,
Select,
Popover,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
ModifiedIndicator,
EmojiEditingPopover,
},
data() {
return {
knownLocalPacks: {},
knownRemotePacks: {},
editedMetadata: {},
packName: '',
newPackName: '',
deleteModalVisible: false,
remotePackInstance: '',
remotePackDownloadAs: '',
remotePackURL: '',
remotePackFile: null,
}
},
provide() {
return { emojiAddr: this.emojiAddr }
},
computed: {
...SharedComputedObject(),
pack() {
return this.packName !== '' ? this.knownPacks[this.packName] : undefined
},
packMeta() {
if (this.packName === '') return {}
if (this.editedMetadata[this.packName] === undefined) {
this.editedMetadata[this.packName] = clone(this.pack.pack)
}
return this.editedMetadata[this.packName]
},
knownPacks() {
// Copy the object itself but not the children, so they are still passed by reference and modified
const result = clone(this.knownLocalPacks)
for (const instName in this.knownRemotePacks) {
for (const instPack in this.knownRemotePacks[instName]) {
result[`${instPack}@${instName}`] =
this.knownRemotePacks[instName][instPack]
}
}
return result
},
downloadWillReplaceLocal() {
return (
(this.remotePackDownloadAs.trim() === '' &&
this.pack.remote &&
this.pack.remote.baseName in this.knownLocalPacks) ||
this.remotePackDownloadAs in this.knownLocalPacks
)
},
},
methods: {
reloadEmoji() {
useAdminSettingsStore().reloadEmoji()
},
importFromFS() {
useAdminSettingsStore().importEmojiFromFS()
},
emojiAddr(name) {
if (this.pack.remote !== undefined) {
// Remote pack
return `${this.pack.remote.instance}/emoji/${encodeURIComponent(this.pack.remote.baseName)}/${name}`
} else {
return `${useInstanceStore().server}/emoji/${encodeURIComponent(this.packName)}/${name}`
}
},
createEmojiPack() {
useAdminSettingsStore()
.createEmojiPack({ name: this.newPackName })
.then((resp) => resp.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
})
},
deleteEmojiPack() {
useAdminSettingsStore()
.deleteEmojiPack({ name: this.packName })
.then((resp) => resp.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
delete this.editedMetadata[this.packName]
this.deleteModalVisible = false
this.packName = ''
})
},
metaEdited(prop) {
if (!this.pack) return
const def = this.pack.pack[prop] || ''
const edited = this.packMeta[prop] || ''
return edited !== def
},
savePackMetadata() {
useAdminSettingsStore()
.saveEmojiPackMetadata({ name: this.packName, newData: this.packMeta })
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.displayError(resp.error)
return
}
// Update actual pack data
this.pack.pack = resp
// Delete edited pack data, should auto-update itself
delete this.editedMetadata[this.packName]
})
},
updatePackFiles(newFiles, packName) {
this.knownPacks[packName].files = newFiles
this.sortPackFiles(packName)
},
refreshPackList() {
useEmojiStore()
.getAdminPacks(
this.remotePackInstance,
useAdminSettingsStore().listEmojiPacks,
)
.then((allPacks) => {
this.knownLocalPacks = allPacks
for (const name of Object.keys(this.knownLocalPacks)) {
this.sortPackFiles(name)
}
})
},
listRemotePacks() {
useEmojiStore()
.getAdminPacks(
this.remotePackInstance,
useAdminSettingsStore().listRemoteEmojiPacks,
)
.then((allPacks) => {
let inst = this.remotePackInstance
if (!inst.startsWith('http')) {
inst = 'https://' + inst
}
const instUrl = new URL(inst)
inst = instUrl.host
for (const packName in allPacks) {
allPacks[packName].remote = {
baseName: packName,
instance: instUrl.origin,
}
}
this.knownRemotePacks[inst] = allPacks
for (const pack in this.knownRemotePacks[inst]) {
this.sortPackFiles(`${pack}@${inst}`)
}
})
.catch((data) => {
this.displayError(data)
})
},
downloadRemotePack() {
if (this.remotePackDownloadAs.trim() === '') {
this.remotePackDownloadAs = this.pack.remote.baseName
}
useAdminSettingsStore()
.downloadRemoteEmojiPack({
instance: this.pack.remote.instance,
packName: this.pack.remote.baseName,
as: this.remotePackDownloadAs,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
this.packName = this.remotePackDownloadAs
this.remotePackDownloadAs = ''
})
},
downloadRemoteURLPack() {
useAdminSettingsStore()
.downloadRemoteEmojiPackZIP({
url: this.remotePackURL,
packName: this.newPackName,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
downloadRemoteFilePack() {
useAdminSettingsStore()
.downloadRemoteEmojiPackZIP({
file: this.remotePackFile[0],
packName: this.newPackName,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
return Promise.reject(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
displayError(msg) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error',
messageArgs: [msg],
level: 'error',
})
},
sortPackFiles(nameOfPack) {
// Sort by key
const sorted = Object.keys(this.knownPacks[nameOfPack].files)
.sort()
.reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = this.knownPacks[nameOfPack].files[key]
return acc
}, {})
this.knownPacks[nameOfPack].files = sorted
},
},
mounted() {
this.refreshPackList()
},
}
export default EmojiTab
diff --git a/src/components/settings_modal/admin_tabs/users_tab.js b/src/components/settings_modal/admin_tabs/users_tab.js
index ab40c723ab..68bc46aa2e 100644
--- a/src/components/settings_modal/admin_tabs/users_tab.js
+++ b/src/components/settings_modal/admin_tabs/users_tab.js
@@ -1,119 +1,117 @@
-import { isEmpty } from 'lodash'
-
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import List from 'src/components/list/list.vue'
import ModerationTools from 'src/components/moderation_tools/moderation_tools.vue'
import Select from 'src/components/select/select.vue'
import AdminUserCard from 'src/components/settings_modal/admin_tabs/admin_user_card.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
const UsersTab = {
components: {
Checkbox,
Select,
BasicUserCard,
List,
AdminUserCard,
ModerationTools,
},
data() {
return {
filtersOrigin: 'local',
filtersActivity: 'all',
filtersPrivileges: 'all',
filtersNeedApproval: false,
filtersUnconfirmed: false,
filtersQuery: '',
filtersName: '',
filtersEmail: '',
expandedUser: null,
}
},
computed: {
/**
* do we filter for admins?
* @returns {boolean}
*/
filtersIsAdmin() {
return (
this.filtersPrivileges === 'admin' ||
this.filtersPrivileges === 'modsnadmins'
)
},
/**
* do we filter for moderators?
* @returns {boolean}
*/
filtersIsModerator() {
return (
this.filtersPrivileges === 'moderator' ||
this.filtersPrivileges === 'modsnadmins'
)
},
/**
* do we filter for active users?
* @returns {boolean}
*/
filtersActive() {
return this.filtersActivity === 'active'
},
/**
* do we filter for deactivated users?
* @returns {boolean}
*/
filtersDeactivated() {
return this.filtersActivity === 'deactivated'
},
/**
* do we filter for local users?
* @returns {boolean}
*/
filtersLocal() {
return this.filtersOrigin === 'local'
},
/**
* do we filter for external users?
* @return {boolean}
*/
filtersExternal() {
return this.filtersOrigin === 'external'
},
fetchOptions() {
const filters = {
isAdmin: this.filtersIsAdmin,
isModerator: this.filtersIsModerator,
active: this.filtersActive,
deactivated: this.filtersDeactivated,
local: this.filtersLocal,
external: this.filtersExternal,
needApproval: this.filtersNeedApproval,
unconfirmed: this.filtersUnconfirmed,
}
return {
query: this.filtersQuery,
name: this.filtersName,
email: this.filtersEmail,
pageSize: 50,
filters,
}
},
},
methods: {
fetchUsers(page) {
return useAdminSettingsStore().fetchUsers({
...this.fetchOptions,
page,
})
},
},
watch: {
fetchOptions() {
this.$refs.usersList?.reset()
},
},
}
export default UsersTab
diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js
index b9ff08bb5d..3b76708fed 100644
--- a/src/components/settings_modal/helpers/setting.js
+++ b/src/components/settings_modal/helpers/setting.js
@@ -1,421 +1,420 @@
import { cloneDeep, get, isEqual, set } from 'lodash'
import DraftButtons from './draft_buttons.vue'
import LocalSettingIndicator from './local_setting_indicator.vue'
import ModifiedIndicator from './modified_indicator.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
export default {
components: {
ModifiedIndicator,
DraftButtons,
LocalSettingIndicator,
},
props: {
modelValue: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
path: {
type: [String, Array],
required: false,
},
showDescription: {
type: Boolean,
required: false,
},
descriptionPathOverride: {
type: [String, Array],
required: false,
},
suggestions: {
type: [String, Array],
required: false,
},
subgroup: {
type: String,
required: false,
},
disabled: {
type: Boolean,
default: false,
},
local: {
type: Boolean,
default: false,
},
parentPath: {
type: [String, Array],
},
parentInvert: {
type: Boolean,
default: false,
},
expert: {
type: [Number, String],
default: 0,
},
source: {
type: String,
default: undefined,
},
hideDraftButtons: {
// this is for the weird backend hybrid (Boolean|String or Boolean|Number) settings
required: false,
type: Boolean,
},
hideLabel: {
type: Boolean,
},
hideDescription: {
type: Boolean,
},
swapDescriptionAndLabel: {
type: Boolean,
},
backendDescriptionPath: {
type: [String, Array],
},
overrideBackendDescription: {
type: Boolean,
},
overrideBackendDescriptionLabel: {
type: [Boolean, String],
},
draftMode: {
type: Boolean,
default: undefined,
},
timedApplyMode: {
type: Boolean,
default: false,
},
},
inject: {
defaultSource: {
default: 'default',
},
defaultDraftMode: {
default: false,
},
},
data() {
return {
localDraft: null,
}
},
created() {
if (
this.realDraftMode &&
(this.realSource !== 'admin' || this.path == null)
) {
this.draft = cloneDeep(this.state)
}
},
computed: {
draft: {
get() {
if (this.realSource === 'admin' || this.path == null) {
return get(useAdminSettingsStore().draft, this.canonPath)
} else {
return this.localDraft
}
},
set(value) {
if (this.realSource === 'admin' || this.path == null) {
useAdminSettingsStore().updateAdminDraft({
path: this.canonPath,
value,
})
} else {
this.localDraft = value
}
},
},
state() {
if (this.path == null) {
return this.modelValue
}
const value = get(this.configSource, this.canonPath)
if (value === undefined) {
return this.defaultState
} else {
return value
}
},
visibleState() {
return this.realDraftMode ? this.draft : this.state
},
realSource() {
return this.source || this.defaultSource
},
realDraftMode() {
return typeof this.draftMode === 'undefined'
? this.defaultDraftMode
: this.draftMode
},
backendDescription() {
return get(useAdminSettingsStore().descriptions, this.descriptionPath)
},
backendDescriptionLabel() {
if (this.realSource !== 'admin') return ''
if (
this.overrideBackendDescriptionLabel !== '' &&
typeof this.overrideBackendDescriptionLabel === 'string'
) {
return this.overrideBackendDescriptionLabel
}
if (!this.backendDescription || this.overrideBackendDescriptionLabel) {
return this.$t(
[
'admin_dash',
'temp_overrides',
...this.canonPath.map((p) => p.replace(/\./g, '_DOT_')),
'label',
].join('.'),
)
} else {
return this.swapDescriptionAndLabel
? this.backendDescription?.description
: this.backendDescription?.label
}
},
backendDescriptionDescription() {
if (this.description) return this.description
if (this.realSource !== 'admin') return ''
if (this.hideDescription) return null
if (!this.backendDescription || this.overrideBackendDescription) {
return this.$t(
[
'admin_dash',
'temp_overrides',
...this.canonPath.map((p) => p.replace(/\./g, '_DOT_')),
'description',
].join('.'),
)
} else {
return this.swapDescriptionAndLabel
? this.backendDescription?.label
: this.backendDescription?.description
}
},
backendDescriptionSuggestions() {
return this.backendDescription?.suggestions || this.suggestions
},
shouldBeDisabled() {
if (this.path == null) {
return this.disabled
}
let parentValue = null
if (this.parentPath !== undefined && this.realSource === 'admin') {
if (this.realDraftMode) {
parentValue = get(useAdminSettingsStore().draft, this.parentPath)
} else {
parentValue = get(this.configSource, this.parentPath)
}
}
return (
this.disabled ||
(parentValue !== null
? this.parentInvert
? parentValue
: !parentValue
: false)
)
},
configSource() {
switch (this.realSource) {
case 'profile':
return this.$store.state.profileConfig
case 'admin':
return useAdminSettingsStore().config
default:
return useMergedConfigStore().mergedConfig
}
},
configSink() {
if (this.path == null) {
return (k, v) => this.$emit('update:modelValue', v)
}
switch (this.realSource) {
case 'profile':
return (k, v) =>
this.$store.dispatch('setProfileOption', { name: k, value: v })
case 'admin':
return (k, v) =>
useAdminSettingsStore().pushAdminSetting({ path: k, value: v })
default:
return (readPath, value) => {
const writePath = `${readPath}`
if (!this.timedApplyMode) {
if (this.local) {
useLocalConfigStore().set({
path: writePath,
value,
})
} else {
useSyncConfigStore().setSimplePrefAndSave({
path: writePath,
value,
})
}
} else {
if (useInterfaceStore().temporaryChangesTimeoutId !== null) {
console.error("Can't track more than one temporary change")
return
}
const oldValue = get(this.configSource, readPath)
if (this.local) {
useLocalConfigStore().setTemporarily({ path: writePath, value })
} else {
useSyncConfigStore().setPreference({ path: writePath, value })
}
const confirm = () => {
if (this.local) {
useLocalConfigStore().set({ path: writePath, value })
} else {
useSyncConfigStore().pushSyncConfig()
}
useInterfaceStore().clearTemporaryChanges()
}
const revert = () => {
if (this.local) {
useLocalConfigStore().unsetTemporarily({
path: writePath,
value,
})
} else {
useSyncConfigStore().setPreference({
path: writePath,
value: oldValue,
})
}
useInterfaceStore().clearTemporaryChanges()
}
useInterfaceStore().setTemporaryChanges({ confirm, revert })
}
}
}
},
defaultState() {
switch (this.realSource) {
case 'profile':
return {}
default: {
return get(useMergedConfigStore().mergedConfigDefault, this.path)
}
}
},
isProfileSetting() {
return this.realSource === 'profile'
},
isLocalSetting() {
return this.local
},
isChanged() {
if (this.path == null) return false
switch (this.realSource) {
case 'profile':
case 'admin':
return false
default:
return this.state !== this.defaultState
}
},
canonPath() {
if (this.path == null) return null
return Array.isArray(this.path) ? this.path : this.path.split('.')
},
descriptionPath() {
if (this.path == null) return null
if (this.descriptionPathOverride) return this.descriptionPathOverride
const path = Array.isArray(this.path) ? this.path : this.path.split('.')
if (this.subgroup) {
return [
...path.slice(0, path.length - 1),
':subgroup,' + this.subgroup,
...path.slice(path.length - 1),
]
}
return path
},
isDirty() {
if (this.path == null) return false
if (this.realSource === 'admin' && this.canonPath.length > 3) {
return false // should not show draft buttons for "grouped" values
} else {
return this.realDraftMode && !isEqual(this.draft, this.state)
}
},
canHardReset() {
return (
this.realSource === 'admin' &&
useAdminSettingsStore().modifiedPaths?.has(this.canonPath.join(' -> '))
)
},
matchesExpertLevel() {
const settingExpertLevel = this.expert || 0
const userToggleExpert =
useMergedConfigStore().mergedConfig.expertLevel || 0
return settingExpertLevel <= userToggleExpert
},
},
methods: {
getValue(e) {
return e.target.value
},
update(e) {
if (this.realDraftMode) {
this.draft = this.getValue(e)
} else {
this.configSink(this.path, this.getValue(e))
}
},
commitDraft() {
if (this.realDraftMode) {
this.configSink(this.path, this.draft)
}
},
reset() {
if (this.realDraftMode) {
this.draft = cloneDeep(this.state)
} else {
set(
useMergedConfigStore().mergedConfig,
this.path,
cloneDeep(this.defaultState),
)
}
},
hardReset() {
switch (this.realSource) {
case 'admin':
return this.$store
.dispatch('resetAdminSetting', { path: this.path })
.then(() => {
this.draft = this.state
})
default:
console.warn('Hard reset not implemented yet!')
}
},
},
}
diff --git a/src/components/settings_modal/helpers/vertical_tab_switcher.jsx b/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
index cbe1be1580..61ed6bee11 100644
--- a/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
+++ b/src/components/settings_modal/helpers/vertical_tab_switcher.jsx
@@ -1,215 +1,214 @@
// eslint-disable-next-line no-unused
-import { throttle } from 'lodash'
-import { mapState as mapPiniaState, mapState } from 'pinia'
-import { Fragment, h } from 'vue'
+import { mapState as mapPiniaState } from 'pinia'
+import { Fragment } from 'vue'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
import './vertical_tab_switcher.scss'
import { useInterfaceStore } from 'src/stores/interface.js'
const findFirstUsable = (slots) => slots.findIndex((_) => _.props)
export default {
name: 'VerticalTabSwitcher',
props: {
renderOnlyFocused: {
required: false,
type: Boolean,
default: false,
},
onSwitch: {
required: false,
type: Function,
default: undefined,
},
activeTab: {
required: false,
type: String,
default: undefined,
},
bodyScrollLock: {
required: false,
type: Boolean,
default: false,
},
},
data() {
return {
active: findFirstUsable(this.slots()),
resizeHandler: null,
navSide: 'tabs',
}
},
computed: {
activeIndex() {
// In case of controlled component
if (this.activeTab) {
return this.slots().findIndex(
(slot) => slot && slot.props && this.activeTab === slot.props.key,
)
} else {
return this.active
}
},
isActive() {
return (tabName) => {
const isWanted = (slot) =>
slot.props && slot.props['data-tab-name'] === tabName
return this.$slots.default().findIndex(isWanted) === this.activeIndex
}
},
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
beforeUpdate() {
const currentSlot = this.slots()[this.active]
if (!currentSlot.props) {
this.active = findFirstUsable(this.slots())
}
},
methods: {
clickTab(index) {
return (e) => {
e.preventDefault()
this.setTab(index)
}
},
setTab(index) {
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
this.changeNavSide('content')
},
changeNavSide(side) {
if (this.navSide !== side) {
this.navSide = side
}
},
// DO NOT put it to computed, it doesn't work (caching?)
slots() {
if (this.$slots.default()[0].type === Fragment) {
return this.$slots.default()[0].children
}
return this.$slots.default()
},
},
render() {
const tabs = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const classesTab = ['vertical-tab', 'menu-item']
if (
this.activeIndex === index &&
useInterfaceStore().layoutType !== 'mobile'
) {
classesTab.push('-active')
}
return (
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
title={props.label}
>
{!props.icon ? (
''
) : (
<FAIcon class="tab-icon" size="1x" fixed-width icon={props.icon} />
)}
<span class="text">{props.label}</span>
</button>
)
})
const contents = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const active = this.activeIndex === index
let delayRender = slot.props['delay-render']
if (delayRender && active) {
slot.props['delay-render'] = false
delayRender = false
}
const renderSlot =
!delayRender && (!this.renderOnlyFocused || active) ? slot : ''
const headerClasses = ['tab-content-label']
const header = (
<h2 class={headerClasses}>
<button
type="button"
onClick={() => this.changeNavSide('tabs')}
title={this.$t('nav.back')}
class="button-unstyled"
>
<FAIcon size="lg" class="back-button-icon" icon="chevron-left" />
</button>
{props.label}
</h2>
)
const wrapperClasses = [
'tab-content-wrapper',
active ? '-active' : '-hidden',
]
const slotWrapperClasses = [
'tab-slot-wrapper',
active ? '-active' : '-hidden',
]
const contentClasses = ['tab-content']
if (props['full-width'] || props['full-width'] != null) {
contentClasses.push('-full-width')
wrapperClasses.push('-full-width')
slotWrapperClasses.push('-full-width')
}
if (props['full-height'] || props['full-width'] != null) {
contentClasses.push('-full-height')
wrapperClasses.push('-full-height')
slotWrapperClasses.push('-full-height')
}
return (
<div class={wrapperClasses}>
<div class="tab-mobile-header">{header}</div>
<div class={slotWrapperClasses}>
<div class={contentClasses}>{renderSlot}</div>
</div>
</div>
)
})
const rootClasses = ['vertical-tab-switcher']
if (useInterfaceStore().layoutType === 'mobile') {
rootClasses.push('-mobile')
}
if (this.navSide === 'tabs') {
rootClasses.push('-nav-tabs')
} else {
rootClasses.push('-nav-contents')
}
return (
<div ref="root" class={rootClasses.join(' ')}>
<div class="tabs" role="tablist" ref="nav">
{tabs}
</div>
<div
role="tabpanel"
class="contents"
v-body-scroll-lock={this.bodyScrollLock}
ref="contents"
>
{contents}
</div>
</div>
)
},
}
diff --git a/src/components/settings_modal/settings_modal_admin_content.js b/src/components/settings_modal/settings_modal_admin_content.js
index fb2d267787..22ba0e16c7 100644
--- a/src/components/settings_modal/settings_modal_admin_content.js
+++ b/src/components/settings_modal/settings_modal_admin_content.js
@@ -1,156 +1,155 @@
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import AuthTab from './admin_tabs/auth_tab.vue'
import EmojiTab from './admin_tabs/emoji_tab.vue'
import FederationTab from './admin_tabs/federation_tab.vue'
import FrontendsTab from './admin_tabs/frontends_tab.vue'
import HTTPTab from './admin_tabs/http_tab.vue'
import InstanceTab from './admin_tabs/instance_tab.vue'
import JobQueuesTab from './admin_tabs/job_queues_tab.vue'
import LimitsTab from './admin_tabs/limits_tab.vue'
import LinksTab from './admin_tabs/links_tab.vue'
import MailerTab from './admin_tabs/mailer_tab.vue'
import MediaProxyTab from './admin_tabs/media_proxy_tab.vue'
import MonitoringTab from './admin_tabs/monitoring_tab.vue'
import OtherTab from './admin_tabs/other_tab.vue'
import PostsTab from './admin_tabs/posts_tab.vue'
import RatesTab from './admin_tabs/rates_tab.vue'
import RegistrationsTab from './admin_tabs/registrations_tab.vue'
import UploadsTab from './admin_tabs/uploads_tab.vue'
import UsersTab from './admin_tabs/users_tab.vue'
import VerticalTabSwitcher from './helpers/vertical_tab_switcher.jsx'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faChain,
faChartLine,
faCircleNodes,
faDoorOpen,
faDownload,
faEllipsis,
faEnvelope,
faEyeSlash,
faGauge,
faGears,
faGlobe,
faHand,
faInfo,
faKey,
faLaptopCode,
faMessage,
faPaintBrush,
faTowerBroadcast,
faUpload,
faUser,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faWrench,
faHand,
faChain,
faGlobe,
faLaptopCode,
faPaintBrush,
faBell,
faDownload,
faEyeSlash,
faInfo,
faUser,
faTowerBroadcast,
faEnvelope,
faChartLine,
faDoorOpen,
faGears,
faKey,
faCircleNodes,
faUpload,
faMessage,
faEllipsis,
faGauge,
)
const SettingsModalAdminContent = {
components: {
VerticalTabSwitcher,
InstanceTab,
UsersTab,
LimitsTab,
RegistrationsTab,
EmojiTab,
FrontendsTab,
FederationTab,
MailerTab,
UploadsTab,
MediaProxyTab,
LinksTab,
JobQueuesTab,
AuthTab,
HTTPTab,
MonitoringTab,
RatesTab,
OtherTab,
PostsTab,
},
computed: {
user() {
return this.$store.state.users.currentUser
},
isLoggedIn() {
return !!this.$store.state.users.currentUser
},
open() {
return useInterfaceStore().settingsModalState !== 'hidden'
},
bodyLock() {
return useInterfaceStore().settingsModalState === 'visible'
},
adminDbLoaded() {
return useAdminSettingsStore().loaded
},
adminDescriptionsLoaded() {
return useAdminSettingsStore().descriptions !== null
},
noDb() {
return useAdminSettingsStore().dbConfigEnabled === false
},
},
created() {
if (this.user.rights.admin) {
useAdminSettingsStore().loadAdminStuff()
}
},
methods: {
onOpen() {
const targetTab = useInterfaceStore().settingsModalTargetTab
// 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 settings is opened
// it doesn't force it.
useInterfaceStore().clearSettingsModalTargetTab()
},
},
mounted() {
this.onOpen()
},
watch: {
open: function (value) {
if (value) this.onOpen()
},
},
}
export default SettingsModalAdminContent
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index bed6f65004..3ab3846d25 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -1,77 +1,76 @@
import { mapState } from 'pinia'
import FontControl from 'src/components/font_control/font_control.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { updateProfile } from 'src/api/user.js'
import localeService from 'src/services/locale/locale.service.js'
const GeneralTab = {
data() {
return {
absoluteTime12hOptions: ['24h', '12h'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.absolute_time_format_12h_${mode}`),
})),
emailLanguage: this.$store.state.users.currentUser.language || [''],
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
FloatSetting,
FontControl,
InterfaceLanguageSwitcher,
},
computed: {
language: {
get: function () {
return useMergedConfigStore().mergedConfig.interfaceLanguage
},
set: function (val) {
useSyncConfigStore().setSimplePrefAndSave({
path: 'interfaceLanguage',
value: val,
})
},
},
...SharedComputedObject(),
...mapState(useInstanceCapabilitiesStore, ['blockExpiration']),
},
methods: {
updateProfile() {
const params = {
language: localeService.internalToBackendLocaleMulti(
this.emailLanguage,
),
}
updateProfile({
params,
credentials: useOAuthStore().token,
}).then(({ data: user }) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
},
updateFont(path, value) {
useLocalConfigStore().set({ path, value })
},
},
}
export default GeneralTab
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
index 7814122497..c45d6ad763 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js
@@ -1,140 +1,140 @@
-import { get, isEmpty, map, reject } from 'lodash'
+import { get, map, reject } from 'lodash'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import BlockCard from 'src/components/block_card/block_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'
import List from 'src/components/list/list.vue'
import MuteCard from 'src/components/mute_card/mute_card.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens.js'
import { importBlocks, importFollows } from 'src/api/user.js'
const MutesAndBlocks = {
data() {
return {
activeTab: 'profile',
}
},
created() {
useOAuthTokensStore().fetchTokens()
useInstanceStore().getKnownDomains()
},
components: {
TabSwitcher,
DomainMuteCard,
BlockCard,
List,
MuteCard,
ProgressButton,
Autosuggest,
Checkbox,
},
computed: {
knownDomains() {
return useInstanceStore().knownDomains
},
user() {
return this.$store.state.users.currentUser
},
blocks() {
return get(this.$store.state.users.currentUser, 'blockIds', [])
},
mutes() {
return get(this.$store.state.users.currentUser, 'muteIds', [])
},
domains() {
return get(this.$store.state.users.currentUser, 'domainMutes', [])
},
},
methods: {
fetchItems(group) {
return () => this.$store.dispatch('fetch' + group, this.userId)
},
importFollows(file) {
return importFollows({
file,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
if (!status) {
throw new Error('failed')
}
})
},
importBlocks(file) {
return importBlocks({
file,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
if (!status) {
throw new Error('failed')
}
})
},
generateExportableUsersContent(users) {
// Get addresses
return users
.map((user) => {
// check is it's a local user
if (user && user.is_local) {
// append the instance address
return user.screen_name + '@' + location.hostname
}
return user.screen_name
})
.join('\n')
},
activateTab(tabName) {
this.activeTab = tabName
},
filterUnblockedUsers(userIds) {
return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId)
return relationship.blocking || userId === this.user.id
})
},
filterUnMutedUsers(userIds) {
return reject(userIds, (userId) => {
const relationship = this.$store.getters.relationship(this.userId)
return relationship.muting || userId === this.user.id
})
},
queryUserIds(query) {
return this.$store
.dispatch('searchUsers', { query })
.then((users) => map(users, 'id'))
},
blockUsers(ids) {
return this.$store.dispatch('blockUsers', ids)
},
unblockUsers(ids) {
return this.$store.dispatch('unblockUsers', ids)
},
muteUsers(ids) {
return this.$store.dispatch('muteUsers', ids)
},
unmuteUsers(ids) {
return this.$store.dispatch('unmuteUsers', ids)
},
filterUnMutedDomains(urls) {
return urls.filter((url) => !this.user.domainMutes.includes(url))
},
queryKnownDomains(query) {
return new Promise((resolve) => {
resolve(
this.knownDomains.filter((url) => url.toLowerCase().includes(query)),
)
})
},
unmuteDomains(domains) {
return this.$store.dispatch('unmuteDomains', domains)
},
},
}
export default MutesAndBlocks
diff --git a/src/components/settings_modal/tabs/posts_tab.js b/src/components/settings_modal/tabs/posts_tab.js
index dbf284d938..640ba5084a 100644
--- a/src/components/settings_modal/tabs/posts_tab.js
+++ b/src/components/settings_modal/tabs/posts_tab.js
@@ -1,78 +1,77 @@
import FontControl from 'src/components/font_control/font_control.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useLocalConfigStore } from 'src/stores/local_config.js'
-import { useSyncConfigStore } from 'src/stores/sync_config.js'
const PostsTab = {
data() {
return {
conversationDisplayOptions: ['tree', 'linear'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_display_${mode}`),
})),
conversationOtherRepliesButtonOptions: ['below', 'inside'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.conversation_other_replies_button_${mode}`),
}),
),
mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(
(mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.mention_link_display_${mode}`),
}),
),
userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.user_popover_avatar_action_${mode}`),
})),
unsavedPostActionOptions: ['save', 'discard', 'confirm'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.unsaved_post_action_${mode}`),
})),
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(
HTMLVideoElement.prototype,
'mozHasAudio',
) ||
// Chrome-likes
Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'webkitAudioDecodedByteCount',
) ||
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(
HTMLMediaElement.prototype,
'audioTracks',
),
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FontControl,
UnitSetting,
},
computed: {
...SharedComputedObject(),
},
methods: {
updateFont(path, value) {
useLocalConfigStore().set({ path, value })
},
},
}
export default PostsTab
diff --git a/src/components/settings_modal/tabs/security_tab/mfa.js b/src/components/settings_modal/tabs/security_tab/mfa.js
index 20c28419d5..1277b42ece 100644
--- a/src/components/settings_modal/tabs/security_tab/mfa.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa.js
@@ -1,179 +1,178 @@
import VueQrcode from '@chenfengyuan/vue-qrcode'
-import { mapState } from 'vuex'
import Confirm from './confirm.vue'
import RecoveryCodes from './mfa_backup_codes.vue'
import TOTP from './mfa_totp.vue'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
generateMfaBackupCodes,
mfaConfirmOTP,
mfaSetupOTP,
settingsMFA,
} from 'src/api/user.js'
const Mfa = {
data: () => ({
settings: {
// current settings of MFA
available: false,
enabled: false,
totp: false,
},
setupState: {
// setup mfa
state: '', // state of setup. '' -> 'getBackupCodes' -> 'setupOTP' -> 'complete'
setupOTPState: '', // state of setup otp. '' -> 'prepare' -> 'confirm' -> 'complete'
},
backupCodes: {
getNewCodes: false,
inProgress: false, // progress of fetch codes
codes: [],
},
otpSettings: {
// pre-setup setting of OTP. secret key, qrcode url.
provisioning_uri: '',
key: '',
},
currentPassword: null,
otpConfirmToken: null,
error: null,
readyInit: false,
}),
components: {
'recovery-codes': RecoveryCodes,
'totp-item': TOTP,
qrcode: VueQrcode,
confirm: Confirm,
},
computed: {
canSetupOTP() {
return (
((this.setupInProgress && this.backupCodesPrepared) ||
this.settings.enabled) &&
!this.settings.totp &&
!this.setupOTPInProgress
)
},
setupInProgress() {
return (
this.setupState.state !== '' && this.setupState.state !== 'complete'
)
},
setupOTPInProgress() {
return this.setupState.state === 'setupOTP' && !this.completedOTP
},
prepareOTP() {
return this.setupState.setupOTPState === 'prepare'
},
confirmOTP() {
return this.setupState.setupOTPState === 'confirm'
},
completedOTP() {
return this.setupState.setupOTPState === 'completed'
},
backupCodesPrepared() {
return !this.backupCodes.inProgress && this.backupCodes.codes.length > 0
},
confirmNewBackupCodes() {
return this.backupCodes.getNewCodes
},
},
methods: {
activateOTP() {
if (!this.settings.enabled) {
this.setupState.state = 'getBackupcodes'
this.fetchBackupCodes()
}
},
fetchBackupCodes() {
this.backupCodes.inProgress = true
this.backupCodes.codes = []
return generateMfaBackupCodes({
credentials: useOAuthStore().token,
}).then(({ data: res }) => {
this.backupCodes.codes = res.codes
this.backupCodes.inProgress = false
})
},
getBackupCodes() {
// get a new backup codes
this.backupCodes.getNewCodes = true
},
confirmBackupCodes() {
// confirm getting new backup codes
this.fetchBackupCodes().then(() => {
this.backupCodes.getNewCodes = false
})
},
cancelBackupCodes() {
// cancel confirm form of new backup codes
this.backupCodes.getNewCodes = false
},
// Setup OTP
setupOTP() {
// prepare setup OTP
this.setupState.state = 'setupOTP'
this.setupState.setupOTPState = 'prepare'
mfaSetupOTP({
credentials: useOAuthStore().token,
}).then(({ data: res }) => {
this.otpSettings = res
this.setupState.setupOTPState = 'confirm'
})
},
doConfirmOTP() {
// handler confirm enable OTP
this.error = null
mfaConfirmOTP({
token: this.otpConfirmToken,
password: this.currentPassword,
credentials: useOAuthStore().token,
})
.then(() => {
this.completeSetup()
})
.catch((error) => {
this.error = error
})
},
completeSetup() {
this.setupState.setupOTPState = 'complete'
this.setupState.state = 'complete'
this.currentPassword = null
this.error = null
this.fetchSettings()
},
cancelSetup() {
// cancel setup
this.setupState.setupOTPState = ''
this.setupState.state = ''
this.currentPassword = null
this.error = null
},
// end Setup OTP
// fetch settings from server
async fetchSettings() {
const { data: result } = await settingsMFA({
credentials: useOAuthStore().token,
})
if (result.error) return
this.settings = result.settings
this.settings.available = true
return result
},
},
mounted() {
this.fetchSettings().then(() => {
this.readyInit = true
})
},
}
export default Mfa
diff --git a/src/components/settings_modal/tabs/security_tab/mfa_totp.js b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
index bbe06c2176..2c4c194bce 100644
--- a/src/components/settings_modal/tabs/security_tab/mfa_totp.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
@@ -1,54 +1,52 @@
-import { mapState } from 'vuex'
-
import Confirm from './confirm.vue'
import { useOAuthStore } from 'src/stores/oauth.js'
import { mfaDisableOTP } from 'src/api/user.js'
export default {
props: ['settings'],
data: () => ({
error: false,
currentPassword: '',
deactivate: false,
inProgress: false, // progress peform request to disable otp method
}),
components: {
confirm: Confirm,
},
computed: {
isActivated() {
return this.settings.totp
},
},
methods: {
doActivate() {
this.$emit('activate')
},
cancelDeactivate() {
this.deactivate = false
},
doDeactivate() {
this.error = null
this.deactivate = true
},
confirmDeactivate() {
// confirm deactivate TOTP method
this.error = null
this.inProgress = true
mfaDisableOTP({
password: this.currentPassword,
credentials: useOAuthStore().token,
}).then(({ data: res }) => {
this.inProgress = false
if (res.error) {
this.error = res.error
return
}
this.deactivate = false
this.$emit('deactivate')
})
},
},
}
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js
index 45876f8485..a2571bd544 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.js
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.js
@@ -1,186 +1,185 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Mfa from './mfa.vue'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens'
import {
addAlias,
changeEmail,
changePassword,
deleteAccount,
deleteAlias,
listAliases,
moveAccount,
} from 'src/api/user.js'
import localeService from 'src/services/locale/locale.service.js'
const SecurityTab = {
data() {
return {
newEmail: '',
changeEmailError: false,
changeEmailPassword: '',
changedEmail: false,
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
changePasswordInputs: ['', '', ''],
changedPassword: false,
changePasswordError: false,
moveAccountTarget: '',
moveAccountPassword: '',
movedAccount: false,
moveAccountError: false,
aliases: [],
listAliasesError: false,
addAliasTarget: '',
addedAlias: false,
addAliasError: false,
}
},
created() {
useOAuthTokensStore().fetchTokens()
this.fetchAliases()
},
components: {
ProgressButton,
Mfa,
Checkbox,
},
computed: {
user() {
return this.$store.state.users.currentUser
},
pleromaExtensionsAvailable() {
return useInstanceCapabilitiesStore().pleromaExtensionsAvailable
},
oauthTokens() {
return useOAuthTokensStore().tokens.map((oauthToken) => {
return {
id: oauthToken.id,
appName: oauthToken.app_name,
validUntil: new Date(oauthToken.valid_until).toLocaleDateString(
localeService.internalToBrowserLocale(this.$i18n.locale),
),
}
})
},
},
methods: {
confirmDelete() {
this.deletingAccount = true
},
deleteAccount() {
deleteAccount({
credentials: useOAuthStore().token,
password: this.deleteAccountConfirmPasswordInput,
}).then(({ data: res }) => {
if (res.status === 'success') {
this.$store.dispatch('logout')
this.$router.push({ name: 'root' })
} else {
this.deleteAccountError = res.error
}
})
},
changePassword() {
const params = {
password: this.changePasswordInputs[0],
newPassword: this.changePasswordInputs[1],
newPasswordConfirmation: this.changePasswordInputs[2],
credentials: useOAuthStore().token,
}
changePassword(params).then(({ data: res }) => {
if (res.status === 'success') {
this.changedPassword = true
this.changePasswordError = false
this.logout()
} else {
this.changedPassword = false
this.changePasswordError = res.error
}
})
},
changeEmail() {
const params = {
email: this.newEmail,
password: this.changeEmailPassword,
credentials: useOAuthStore().token,
}
changeEmail(params).then(({ data: res }) => {
if (res.status === 'success') {
this.changedEmail = true
this.changeEmailError = false
} else {
this.changedEmail = false
this.changeEmailError = res.error
}
})
},
moveAccount() {
const params = {
targetAccount: this.moveAccountTarget,
password: this.moveAccountPassword,
credentials: useOAuthStore().token,
}
moveAccount(params).then(({ data: res }) => {
if (res.status === 'success') {
this.movedAccount = true
this.moveAccountError = false
} else {
this.movedAccount = false
this.moveAccountError = res.error
}
})
},
removeAlias(alias) {
deleteAlias({
alias,
credentials: useOAuthStore().token,
}).then(() => this.fetchAliases())
},
addAlias() {
addAlias({
alias: this.addAliasTarget,
credentials: useOAuthStore().token,
})
.then(() => {
this.addedAlias = true
this.addAliasError = false
this.addAliasTarget = ''
})
.catch((error) => {
this.addedAlias = false
this.addAliasError = error
})
.then(() => this.fetchAliases())
},
fetchAliases() {
listAliases({
credentials: useOAuthStore().token,
})
.then(({ data: res }) => {
this.aliases = res.aliases
this.listAliasesError = false
})
.catch((error) => {
this.listAliasesError = error.error
})
},
logout() {
this.$store.dispatch('logout')
this.$router.replace('/')
},
revokeToken(id) {
if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
useOAuthTokensStore().revokeToken(id)
}
},
},
}
export default SecurityTab
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index 840beeb7c9..27e9a5249e 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -1,137 +1,136 @@
import { mapActions, mapState } from 'pinia'
-import { defineAsyncComponent } from 'vue'
import { mapGetters } from 'vuex'
import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import UserCard from 'src/components/user_card/user_card.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useShoutStore } from 'src/stores/shout'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBullhorn,
faCog,
faComments,
faCompass,
faFilePen,
faHome,
faInfoCircle,
faList,
faSearch,
faSignInAlt,
faSignOutAlt,
faTachometerAlt,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle,
faCompass,
faList,
faFilePen,
)
const SideDrawer = {
props: ['logout'],
data: () => ({
closed: true,
closeGesture: undefined,
}),
created() {
this.closeGesture = GestureService.swipeGesture(
GestureService.DIRECTION_LEFT,
this.toggleDrawer,
)
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
},
components: {
UserCard,
},
computed: {
currentUser() {
return this.$store.state.users.currentUser
},
shout() {
return useShoutStore().joined
},
unseenNotifications() {
return unseenNotificationsFromStore(
this.$store,
useMergedConfigStore().mergedConfig.notificationVisibility,
useMergedConfigStore().mergedConfig.ignoreInactionableSeen,
)
},
unseenNotificationsCount() {
return this.unseenNotifications.length
},
followRequestCount() {
return this.$store.state.api.followRequests.length
},
timelinesRoute() {
let name
if (useInterfaceStore().lastTimeline) {
name = useInterfaceStore().lastTimeline
}
name = this.currentUser ? 'friends' : 'public-timeline'
if (USERNAME_ROUTES.has(name)) {
return { name, params: { username: this.currentUser.screen_name } }
} else {
return { name }
}
},
...mapState(useAnnouncementsStore, [
'supportsAnnouncements',
'unreadAnnouncementCount',
]),
...mapState(useInstanceCapabilitiesStore, [
'pleromaChatMessagesAvailable',
'suggestionsEnabled',
]),
...mapState(useInstanceStore, ['privateMode', 'federating']),
...mapState(useInstanceStore, {
logo: (store) => store.instanceIdentity.logo,
sitename: (store) => store.instanceIdentity.name,
hideSitename: (store) => store.instanceIdentity.hideSitename,
}),
...mapGetters(['unreadChatCount', 'draftCount']),
},
methods: {
toggleDrawer() {
this.closed = !this.closed
},
doLogout() {
this.logout()
this.toggleDrawer()
},
touchStart(e) {
GestureService.beginSwipe(e, this.closeGesture)
},
touchMove(e) {
GestureService.updateSwipe(e, this.closeGesture)
},
...mapActions(useInterfaceStore, ['openSettingsModal']),
},
}
export default SideDrawer
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 0f02bb6fb3..2186498ef7 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -1,160 +1,159 @@
import { mapState as mapPiniaState } from 'pinia'
-import { defineAsyncComponent } from 'vue'
-import { mapGetters, mapState } from 'vuex'
+import { mapState } from 'vuex'
import Attachment from 'src/components/attachment/attachment.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import LinkPreview from 'src/components/link-preview/link-preview.vue'
import Poll from 'src/components/poll/poll.vue'
import StatusBody from 'src/components/status_body/status_body.vue'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCircleNotch,
faFile,
faImage,
faLink,
faMusic,
faPollH,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faFile, faMusic, faImage, faLink, faPollH)
const camelCase = (name) => name.charAt(0).toUpperCase() + name.slice(1)
const controlledOrUncontrolledGetters = (list) =>
list.reduce((res, name) => {
const camelized = camelCase(name)
const toggle = `controlledToggle${camelized}`
const controlledName = `controlled${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
res[name] = function () {
return (this.$data[toggle] !== undefined ||
this.$props[toggle] !== undefined) &&
this[toggle]
? this[controlledName]
: this[uncontrolledName]
}
return res
}, {})
const controlledOrUncontrolledToggle = (obj, name) => {
const camelized = camelCase(name)
const toggle = `controlledToggle${camelized}`
const uncontrolledName = `uncontrolled${camelized}`
if (obj[toggle]) {
obj[toggle]()
} else {
obj[uncontrolledName] = !obj[uncontrolledName]
}
}
const StatusContent = {
name: 'StatusContent',
props: [
'status',
'compact',
'collapse',
'focused',
'noHeading',
'fullContent',
'singleLine',
'controlledShowingTall',
'controlledExpandingSubject',
'controlledToggleShowingTall',
'controlledToggleExpandingSubject',
'controlledShowingLongSubject',
'controlledToggleShowingLongSubject',
],
emits: ['parseReady', 'mediaplay', 'mediapause'],
data() {
return {
uncontrolledShowingTall:
this.fullContent || (this.inConversation && this.focused),
uncontrolledShowingLongSubject: false,
// not as computed because it sets the initial state which will be changed later
uncontrolledExpandingSubject:
!useMergedConfigStore().mergedConfig.collapseMessageWithSubject,
}
},
computed: {
...controlledOrUncontrolledGetters([
'showingTall',
'expandingSubject',
'showingLongSubject',
]),
statusCard() {
if (!this.status.card) return null
return this.status.card.url === this.status.quote_url
? null
: this.status.card
},
hideAttachments() {
return (
(this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
)
},
nsfwClickthrough() {
if (!this.status.nsfw) {
return false
}
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
return true
},
localCollapseSubjectDefault() {
return this.mergedConfig.collapseMessageWithSubject
},
attachmentSize() {
if (this.compact) {
return 'small'
} else if (
(this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation) ||
this.status.attachments.length > this.maxThumbnails
) {
return 'hide'
}
return 'normal'
},
maxThumbnails() {
return this.mergedConfig.maxThumbnails
},
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
currentUser: (state) => state.users.currentUser,
}),
},
components: {
Attachment,
Poll,
Gallery,
LinkPreview,
StatusBody,
},
methods: {
toggleShowingTall() {
controlledOrUncontrolledToggle(this, 'showingTall')
},
toggleExpandingSubject() {
controlledOrUncontrolledToggle(this, 'expandingSubject')
},
toggleShowingLongSubject() {
controlledOrUncontrolledToggle(this, 'showingLongSubject')
},
setMedia() {
const attachments =
this.attachmentSize === 'hide'
? this.status.attachments
: this.galleryAttachments
return () => useMediaViewerStore().setMedia(attachments)
},
},
}
export default StatusContent
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index fe00563052..95da91ba7d 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -1,50 +1,49 @@
import { find } from 'lodash'
import Popover from 'src/components/popover/popover.vue'
-import Status from 'src/components/status/status.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const StatusPopover = {
name: 'StatusPopover',
props: ['statusId'],
data() {
return {
error: false,
}
},
computed: {
status() {
return find(this.$store.state.statuses.allStatuses, { id: this.statusId })
},
},
components: {
Popover,
},
methods: {
enter() {
if (!this.status) {
if (!this.statusId) {
this.error = true
return
}
this.$store
.dispatch('fetchStatus', this.statusId)
.then(() => (this.error = false))
.catch(() => (this.error = true))
}
},
},
watch: {
status(newStatus, oldStatus) {
if (newStatus !== oldStatus) {
this.$nextTick(() => this.$refs.popover.updateStyles())
}
},
},
}
export default StatusPopover
diff --git a/src/components/sticker_picker/sticker_picker.js b/src/components/sticker_picker/sticker_picker.js
index 949d2ecf92..482aacb81c 100644
--- a/src/components/sticker_picker/sticker_picker.js
+++ b/src/components/sticker_picker/sticker_picker.js
@@ -1,57 +1,56 @@
/* eslint-env browser */
import statusPosterService from '../../services/status_poster/status_poster.service.js'
import TabSwitcher from '../tab_switcher/tab_switcher.jsx'
import { useEmojiStore } from 'src/stores/emoji.js'
-import { useInstanceStore } from 'src/stores/instance.js'
const StickerPicker = {
components: {
TabSwitcher,
},
data() {
return {
meta: {
stickers: [],
},
path: '',
}
},
computed: {
pack() {
return useEmojiStore().stickers || []
},
},
methods: {
clear() {
this.meta = {
stickers: [],
}
},
pick(sticker, name) {
const store = this.$store
// TODO remove this workaround by finding a way to bypass reuploads
fetch(sticker).then((res) => {
res.blob().then((blob) => {
const file = new File([blob], name, { mimetype: 'image/png' })
const formData = new FormData()
formData.append('file', file)
statusPosterService.uploadMedia({ store, formData }).then(
(fileData) => {
this.$emit('uploaded', fileData)
this.clear()
},
(error) => {
console.warn("Can't attach sticker")
console.warn(error)
this.$emit('upload-failed', 'default')
},
)
})
})
},
},
}
export default StickerPicker
diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx
index b485b3ff85..a3c5b61cd1 100644
--- a/src/components/tab_switcher/tab_switcher.jsx
+++ b/src/components/tab_switcher/tab_switcher.jsx
@@ -1,186 +1,183 @@
// eslint-disable-next-line no-unused
-import { mapState } from 'pinia'
-import { Fragment, h } from 'vue'
+import { Fragment } from 'vue'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
import './tab_switcher.scss'
-import { useInterfaceStore } from 'src/stores/interface.js'
-
const findFirstUsable = (slots) => slots.findIndex((_) => _.props)
export default {
name: 'TabSwitcher',
props: {
renderOnlyFocused: {
required: false,
type: Boolean,
default: false,
},
onSwitch: {
required: false,
type: Function,
default: undefined,
},
activeTab: {
required: false,
type: String,
default: undefined,
},
scrollableTabs: {
required: false,
type: Boolean,
default: false,
},
bodyScrollLock: {
required: false,
type: Boolean,
default: false,
},
},
data() {
return {
active: findFirstUsable(this.slots()),
}
},
computed: {
activeIndex() {
// In case of controlled component
if (this.activeTab) {
return this.slots().findIndex(
(slot) => slot && slot.props && this.activeTab === slot.props.key,
)
} else {
return this.active
}
},
isActive() {
return (tabName) => {
const isWanted = (slot) =>
slot.props && slot.props['data-tab-name'] === tabName
return this.$slots.default().findIndex(isWanted) === this.activeIndex
}
},
},
beforeUpdate() {
const currentSlot = this.slots()[this.active]
if (!currentSlot.props) {
this.active = findFirstUsable(this.slots())
}
},
methods: {
clickTab(index) {
return (e) => {
e.preventDefault()
this.setTab(index)
}
},
// DO NOT put it to computed, it doesn't work (caching?)
slots() {
if (this.$slots.default()[0].type === Fragment) {
return this.$slots.default()[0].children
}
return this.$slots.default()
},
setTab(index) {
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
if (this.scrollableTabs) {
this.$refs.contents.scrollTop = 0
}
},
},
render() {
const tabs = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const classesTab = ['tab']
const classesWrapper = ['tab-wrapper']
if (this.activeIndex === index) {
classesTab.push('active')
classesWrapper.push('active')
}
if (props.image) {
return (
<div class={classesWrapper.join(' ')}>
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
>
<img src={props.image} title={props['image-tooltip']} />
{props.label ? '' : props.label}
</button>
</div>
)
}
return (
<div class={classesWrapper.join(' ')}>
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
>
{!props.icon ? (
''
) : (
<FAIcon
class="tab-icon"
size="2x"
fixed-width
icon={props.icon}
/>
)}
<span class="text">{props.label}</span>
</button>
</div>
)
})
const contents = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const active = this.activeIndex === index
const classes = [active ? 'active' : 'hidden']
if (props.fullHeight || props['full-height']) {
classes.push('-full-height')
}
if (props.fullWidth || props['full-width']) {
classes.push('-full-width')
}
let delayRender = slot.props['delay-render']
if (delayRender && active) {
slot.props['delay-render'] = false
delayRender = false
}
const renderSlot =
!delayRender && (!this.renderOnlyFocused || active) ? slot : ''
return <div class={classes}>{renderSlot}</div>
})
return (
<div class="tab-switcher top-tabs" ref="root">
<div class="tabs" role="tablist" ref="nav">
{tabs}
</div>
<div
role="tabpanel"
class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}
v-body-scroll-lock={this.bodyScrollLock}
ref="content"
>
{contents}
</div>
</div>
)
},
}
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 70fb057af1..31a5131300 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -1,622 +1,621 @@
import {
isEqual,
escape as ldEscape,
unescape as ldUnescape,
merge,
} from 'lodash'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Select from 'src/components/select/select.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMediaViewerStore } from 'src/stores/media_viewer'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
-import { useOAuthStore } from 'src/stores/oauth.js'
import { usePostStatusStore } from 'src/stores/post_status'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { updateProfile } from 'src/api/user.js'
import { propsToNative } from 'src/services/attributes_helper/attributes_helper.service.js'
import localeService from 'src/services/locale/locale.service.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBirthdayCake,
faClockRotateLeft,
faEdit,
faExpandAlt,
faExternalLinkAlt,
faRss,
faSave,
faSearchPlus,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSave,
faRss,
faBell,
faSearchPlus,
faExternalLinkAlt,
faEdit,
faTimes,
faExpandAlt,
faBirthdayCake,
faClockRotateLeft,
)
const KNOWN_TAGS = new Set([
'mrf_tag:media-force-nsfw',
'mrf_tag:media-strip',
'mrf_tag:force-unlisted',
'mrf_tag:sandbox',
'mrf_tag:disable-remote-subscription',
'mrf_tag:disable-any-subscription',
])
export default {
props: {
// Enables all the options for profile editing, used in settings -> profile tab
editable: {
required: false,
default: false,
type: Boolean,
},
// ID of user to show data of
userId: {
required: true,
type: String,
},
// Use a compact layout that hides bio, stats etc.
hideBio: {
required: false,
default: false,
type: Boolean,
},
// Hide action buttons
hideButtons: {
required: false,
default: false,
type: Boolean,
},
// default - open profile, 'zoom' - zoom, function - call function
avatarAction: {
required: false,
type: String,
default: 'default',
},
// Show note editor if supported
hasNoteEditor: {
required: false,
type: Boolean,
default: false,
},
// Show close icon (for popovers)
showClose: {
required: false,
type: Boolean,
default: false,
},
// Show close icon (for popovers)
showExpand: {
required: false,
type: Boolean,
default: false,
},
// Disable forced 3:1 aspect ratio
compact: {
required: false,
type: Boolean,
default: false,
},
},
components: {
DialogModal: defineAsyncComponent(
() => import('src/components/dialog_modal/dialog_modal.vue'),
),
UserAvatar,
Checkbox,
RemoteFollow: defineAsyncComponent(
() => import('src/components/remote_follow/remote_follow.vue'),
),
ModerationTools: defineAsyncComponent(
() => import('src/components/moderation_tools/moderation_tools.vue'),
),
AccountActions: defineAsyncComponent(
() => import('src/components/account_actions/account_actions.vue'),
),
ProgressButton,
FollowButton: defineAsyncComponent(
() => import('src/components/follow_button/follow_button.vue'),
),
Select,
UserLink,
UserNote: defineAsyncComponent(
() => import('src/components/user_note/user_note.vue'),
),
UserTimedFilterModal: defineAsyncComponent(
() =>
import(
'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
),
),
ColorInput,
EmojiInput,
ImageCropper: defineAsyncComponent(
() => import('src/components/image_cropper/image_cropper.vue'),
),
},
data() {
const user = this.$store.getters.findUser(this.userId)
return {
followRequestInProgress: false,
// Editable stuff
editImage: false,
newName: user.name_unescaped,
editingName: false,
newBio: ldUnescape(user.description),
editingBio: false,
newAvatar: null,
newAvatarFile: null,
newBanner: null,
newBannerFile: null,
newActorType: user.actor_type,
newBirthday: user.birthday,
newShowBirthday: user.show_birthday,
newShowRole: user.show_role,
newFields: user.fields?.map((field) => ({
name: field.name,
value: field.value,
})),
editingFields: false,
}
},
created() {
this.$store.dispatch('fetchUserRelationship', this.user.id)
},
computed: {
escapedNewBio() {
return ldEscape(this.newBio).replace(/\n/g, '<br>')
},
somethingToSave() {
if (this.newName !== this.user.name_unescaped) return true
if (this.newBio !== ldUnescape(this.user.description)) return true
if (this.newAvatar !== null) return true
if (this.newBanner !== null) return true
if (this.newActorType !== this.user.actor_type) return true
if (this.newBirthday !== this.user.birthday) return true
if (this.newShowBirthday !== this.user.show_birthday) return true
if (this.newShowRole !== this.user.show_role) return true
if (
!isEqual(
this.newFields,
this.user.fields?.map((field) => ({
name: field.name,
value: field.value,
})),
)
)
return true
return false
},
groupActorAvailable() {
return useInstanceCapabilitiesStore().groupActorAvailable
},
availableActorTypes() {
return this.groupActorAvailable
? ['Person', 'Service', 'Group']
: ['Person', 'Service']
},
user() {
return this.$store.getters.findUser(this.userId)
},
role() {
return this.user.role
},
relationship() {
return this.$store.getters.relationship(this.userId)
},
isOtherUser() {
return this.user.id !== this.$store.state.users.currentUser.id
},
subscribeUrl() {
const serverUrl = new URL(this.user.statusnet_profile_url)
return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus`
},
loggedIn() {
return this.$store.state.users.currentUser
},
dailyAvg() {
const days = Math.ceil(
(new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000),
)
return Math.round(this.user.statuses_count / days)
},
emoji() {
return useEmojiStore().customEmoji.map((e) => ({
shortcode: e.displayText,
static_url: e.imageUrl,
url: e.imageUrl,
}))
},
userHighlightType: {
get() {
return useUserHighlightStore().get(this.user.screen_name).type
},
set(type) {
if (type !== 'disabled') {
useUserHighlightStore().setAndSave({
user: this.user.screen_name,
value: { type },
})
} else {
useUserHighlightStore().unsetAndSave({ user: this.user.screen_name })
}
},
},
userHighlightColor: {
get() {
return useUserHighlightStore().get(this.user.screen_name).color
},
set(color) {
useUserHighlightStore().setAndSave({
user: this.user.screen_name,
value: { color },
})
},
},
visibleRole() {
if (!this.user.show_role && !this.user.adminData) {
return
}
const rights = this.user.rights
if (!rights) {
return
}
const validRole = rights.admin || rights.moderator
const roleTitle = rights.admin ? 'admin' : 'moderator'
return validRole && roleTitle
},
hideFollowsCount() {
return this.isOtherUser && this.user.hide_follows_count
},
hideFollowersCount() {
return this.isOtherUser && this.user.hide_followers_count
},
showModerationMenu() {
const privileges = this.loggedIn.privileges
return (
this.loggedIn.role === 'admin' ||
privileges.has('users_manage_activation_state') ||
privileges.has('users_delete') ||
privileges.has('users_manage_tags')
)
},
hasNote() {
return this.relationship.note
},
supportsNote() {
return 'note' in this.relationship
},
muteExpiryAvailable() {
return Object.hasOwn(this.user, 'mute_expires_at')
},
muteExpiry() {
return this.user.mute_expires_at === false
? this.$t('user_card.mute_expires_forever')
: this.$t('user_card.mute_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
blockExpiryAvailable() {
return Object.hasOwn(this.user, 'block_expires_at')
},
blockExpiry() {
return this.user.block_expires_at == null
? this.$t('user_card.block_expires_forever')
: this.$t('user_card.block_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
formattedBirthday() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.birthday &&
new Date(Date.parse(this.user.birthday)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
formattedJoinDate() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.created_at &&
new Date(Date.parse(this.user.created_at)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
// Editable stuff
avatarImgSrc() {
const currentUrl =
this.user.profile_image_url_original || this.defaultAvatar
if (!this.editable) return currentUrl
const newUrl =
this.newAvatar === null ? this.defaultAvatar : this.newAvatar
return this.newAvatar === null ? currentUrl : newUrl
},
bannerImgSrc() {
const currentUrl = this.user.cover_photo || this.defaultBanner
if (!this.editable) return currentUrl
const newUrl =
this.newBanner === null ? this.defaultBanner : this.newBanner
return this.newBanner === null ? currentUrl : newUrl
},
defaultAvatar() {
return (
useInstanceStore().server +
useInstanceStore().instanceIdentity.defaultAvatar
)
},
defaultBanner() {
return (
useInstanceStore().server +
useInstanceStore().instanceIdentity.defaultBanner
)
},
isDefaultAvatar() {
const baseAvatar = useInstanceStore().instanceIdenitity.defaultAvatar
return (
!this.$store.state.users.currentUser.profile_image_url ||
this.$store.state.users.currentUser.profile_image_url.includes(
baseAvatar,
)
)
},
isDefaultBanner() {
const baseBanner = useInstanceStore().instanceIdentity.defaultBanner
return (
!this.$store.state.users.currentUser.cover_photo ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
)
},
fieldsLimits() {
return useInstanceStore().limits.fieldsLimits
},
maxFields() {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
},
emojiUserSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
hideUserStats() {
return this.mergedConfig.hideUserStats
},
hideRemarks() {
return this.mergedConfig.userCardHidePersonalMarks
},
...mapState(useMergedConfigStore, ['mergedConfig']),
},
methods: {
isKnownTag(tag) {
return KNOWN_TAGS.has(tag)
},
muteUser() {
this.$refs.timedMuteDialog.optionallyPrompt()
},
unmuteUser() {
this.$store.dispatch('unmuteUser', this.user.id)
},
subscribeUser() {
return this.$store.dispatch('subscribeUser', this.user.id)
},
unsubscribeUser() {
return this.$store.dispatch('unsubscribeUser', this.user.id)
},
linkClicked({ target }) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
zoomAvatar() {
const attachment = {
url: this.user.profile_image_url_original,
type: 'image',
}
useMediaViewerStore().setMedia([attachment])
useMediaViewerStore().setCurrentMedia(attachment)
},
mentionUser() {
usePostStatusStore().openPostStatusModal({
profileMention: true,
repliedUser: this.user,
})
},
onAvatarClickHandler(e) {
if (this.onAvatarClick) {
e.preventDefault()
this.onAvatarClick()
}
},
// Editable stuff
changeAvatar() {
this.editImage = 'avatar'
},
changeBanner() {
this.editImage = 'banner'
},
submitImage({ canvas, file }) {
if (canvas) {
return canvas.toBlob((data) =>
this.submitImage({ canvas: null, file: data }),
)
}
const reader = new window.FileReader()
reader.onload = (e) => {
const dataUrl = e.target.result
if (this.editImage === 'avatar') {
this.newAvatar = dataUrl
this.newAvatarFile = file
} else {
this.newBanner = dataUrl
this.newBannerFile = file
}
this.editImage = false
}
reader.readAsDataURL(file)
},
resetImage() {
if (this.editImage === 'avatar') {
this.newAvatar = null
this.newAvatarFile = null
} else {
this.newBanner = null
this.newBannerFile = null
}
this.editImage = false
},
addField() {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
}
},
deleteField(index) {
this.newFields.splice(index, 1)
},
propsToNative(props) {
return propsToNative(props)
},
cancelImageText() {
return
},
resetState() {
const user = this.$store.state.users.currentUser
this.newName = user.name_unescaped
this.newBio = ldUnescape(user.description)
this.newAvatar = null
this.newAvatarFile = null
this.newBanner = null
this.newBannerFile = null
this.newActorType = user.actor_type
this.newBirthday = user.birthday
this.newShowBirthday = user.show_birthday
this.newShowRole = user.show_role
this.newFields = user.fields.map((field) => ({
name: field.name,
value: field.value,
}))
},
updateProfile() {
const params = {
note: this.newBio,
// Backend notation.
display_name: this.newName,
fields_attributes: this.newFields.filter((el) => el != null),
show_role: !!this.newShowRole,
birthday: this.newBirthday || '',
show_birthday: !!this.newShowBirthday,
}
if (this.newActorType) {
params.actor_type = this.newActorType
}
if (this.newAvatarFile !== null) {
params.avatar = this.newAvatarFile
}
if (this.newBannerFile !== null) {
params.header = this.newBannerFile
}
updateProfile({ params })
.then(({ data: user }) => {
this.newFields.splice(this.newFields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.resetState()
})
.catch((error) => {
this.displayUploadError(error)
})
},
displayUploadError(error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [error.message],
level: 'error',
})
},
},
}
diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js
index 8a46126906..7f9ba4ffb9 100644
--- a/src/components/user_list_popover/user_list_popover.js
+++ b/src/components/user_list_popover/user_list_popover.js
@@ -1,44 +1,42 @@
-import { defineAsyncComponent } from 'vue'
-
import Popover from 'src/components/popover/popover.vue'
import UnicodeDomainIndicator from 'src/components/unicode_domain_indicator/unicode_domain_indicator.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const UserListPopover = {
name: 'UserListPopover',
props: ['users'],
components: {
UnicodeDomainIndicator,
Popover,
UserAvatar,
},
computed: {
usersCapped() {
return this.users.slice(0, 16)
},
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
},
methods: {
generateProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
},
}
export default UserListPopover
diff --git a/src/components/user_popover/user_popover.js b/src/components/user_popover/user_popover.js
index de354d676b..67c75be501 100644
--- a/src/components/user_popover/user_popover.js
+++ b/src/components/user_popover/user_popover.js
@@ -1,23 +1,22 @@
import { mapState } from 'pinia'
-import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
import UserCard from 'src/components/user_card/user_card.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const UserPopover = {
name: 'UserPopover',
props: ['userId', 'overlayCenters', 'disabled', 'overlayCentersSelector'],
components: {
UserCard,
Popover,
},
computed: mapState(useMergedConfigStore, {
userPopoverAvatarAction: (state) =>
state.mergedConfig.userPopoverAvatarAction,
userPopoverOverlay: (state) => state.mergedConfig.userPopoverOverlay,
}),
}
export default UserPopover
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index d2d766a7f3..eb1d8e0683 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -1,210 +1,208 @@
import { get } from 'lodash'
-import { mapState } from 'pinia'
import FollowCard from 'src/components/follow_card/follow_card.vue'
import List from 'src/components/list/list.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Timeline from 'src/components/timeline/timeline.vue'
import UserCard from 'src/components/user_card/user_card.vue'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const defaultTabKey = 'statuses'
const UserProfile = {
data() {
return {
error: false,
userId: null,
tab: defaultTabKey,
footerRef: null,
}
},
created() {
const routeParams = this.$route.params
this.load({ name: routeParams.name, id: routeParams.id })
this.tab = get(this.$route, 'query.tab', defaultTabKey)
useInterfaceStore().setForeignProfileBackground(this.user?.background_image)
},
updated() {
useInterfaceStore().setForeignProfileBackground(this.user?.background_image)
},
unmounted() {
this.stopFetching()
useInterfaceStore().setForeignProfileBackground(null)
this.$store.dispatch('clearFollowers', this.userId)
this.$store.dispatch('clearFriends', this.userId)
},
computed: {
timeline() {
return this.$store.state.statuses.timelines.user
},
favorites() {
return this.$store.state.statuses.timelines.favorites
},
media() {
return this.$store.state.statuses.timelines.media
},
isUs() {
return (
this.userId &&
this.$store.state.users.currentUser.id &&
this.userId === this.$store.state.users.currentUser.id
)
},
user() {
return this.$store.getters.findUser(this.userId)
},
isExternal() {
return this.$route.name === 'external-user-profile'
},
followsTabVisible() {
return this.isUs || !this.user.hide_follows
},
followersTabVisible() {
return this.isUs || !this.user.hide_followers
},
favoritesTabVisible() {
return (
this.isUs ||
(useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable &&
!this.user.hide_favorites)
)
},
compactProfiles() {
return useMergedConfigStore().mergedConfig.compactProfiles
},
friends() {
return get(
this.$store.getters.findUser(this.userId),
'friendIds',
[],
).map((id) => this.$store.getters.findUser(id))
},
followers() {
return get(
this.$store.getters.findUser(this.userId),
'followerIds',
[],
).map((id) => this.$store.getters.findUser(id))
},
},
methods: {
setFooterRef(el) {
this.footerRef = el
},
fetchUsers(group) {
return () =>
this.$store
.dispatch('fetch' + group, this.userId)
.then((result) => ({ items: result }))
},
load(userNameOrId) {
const startFetchingTimeline = (timeline, userId) => {
// Clear timeline only if load another user's profile
if (userId !== this.$store.state.statuses.timelines[timeline].userId) {
this.$store.commit('clearTimeline', { timeline })
}
this.$store.dispatch('startFetchingTimeline', { timeline, userId })
}
const loadById = (userId) => {
this.userId = userId
startFetchingTimeline('user', userId)
startFetchingTimeline('media', userId)
if (this.isUs) {
startFetchingTimeline('favorites')
} else if (!this.user.hide_favorites) {
startFetchingTimeline('favorites', userId)
}
// Fetch all pinned statuses immediately
this.$store.dispatch('fetchPinnedStatuses', userId)
}
// Reset view
this.userId = null
this.error = false
const maybeId = userNameOrId.id
const maybeName = userNameOrId.name
// Check if user data is already loaded in store
const user = maybeId
? this.$store.getters.findUser(maybeId)
: this.$store.getters.findUserByName(maybeName)
if (user) {
loadById(user.id)
} else {
;(maybeId
? this.$store.dispatch('fetchUser', maybeId)
: this.$store.dispatch('fetchUserByName', maybeName)
)
.then(({ id }) => loadById(id))
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
if (errorMessage === 'No user with such user_id') {
// Known error
this.error = this.$t('user_profile.profile_does_not_exist')
} else if (errorMessage) {
this.error = errorMessage
} else {
this.error = this.$t('user_profile.profile_loading_error')
}
})
}
},
stopFetching() {
this.$store.dispatch('stopFetchingTimeline', 'user')
this.$store.dispatch('stopFetchingTimeline', 'favorites')
this.$store.dispatch('stopFetchingTimeline', 'media')
},
switchUser(userNameOrId) {
this.stopFetching()
this.load(userNameOrId)
},
onTabSwitch(tab) {
this.tab = tab
this.$router.replace({ query: { tab } })
},
linkClicked({ target }) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
},
watch: {
'$route.params.id': function (newVal) {
if (newVal) {
this.switchUser({ id: newVal })
}
},
'$route.params.name': function (newVal) {
if (newVal) {
this.switchUser({ name: newVal })
}
},
'$route.query': function (newVal) {
this.tab = newVal.tab || defaultTabKey
},
},
components: {
UserCard,
Timeline,
List,
FollowCard,
TabSwitcher,
},
}
export default UserProfile
diff --git a/src/components/user_profile/user_profile_admin_view.js b/src/components/user_profile/user_profile_admin_view.js
index 94092b1557..da2c6c322f 100644
--- a/src/components/user_profile/user_profile_admin_view.js
+++ b/src/components/user_profile/user_profile_admin_view.js
@@ -1,71 +1,68 @@
-import { get } from 'lodash'
-import { mapState } from 'pinia'
-
import Checkbox from 'src/components/checkbox/checkbox.vue'
import List from 'src/components/list/list.vue'
import Status from 'src/components/status/status.vue'
import UserCard from 'src/components/user_card/user_card.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const UserProfileAdminView = {
data() {
return {
godmode: false,
showReblogs: false,
}
},
created() {
this.$store.dispatch('fetchUserIfMissing', this.userId)
useInterfaceStore().setForeignProfileBackground(this.user?.background_image)
},
updated() {
useInterfaceStore().setForeignProfileBackground(this.user?.background_image)
},
unmounted() {
useInterfaceStore().setForeignProfileBackground(null)
},
computed: {
fetchOptions() {
return {
pageSize: 20,
godmode: this.godmode,
id: this.userId,
withReblogs: this.showReblogs,
}
},
user() {
return this.$store.getters.findUser(this.userId)
},
userId() {
return this.$route.params.id
},
},
methods: {
fetchStatuses(page) {
return useAdminSettingsStore().fetchStatuses({
...this.fetchOptions,
page,
})
},
},
components: {
UserCard,
List,
Status,
Checkbox,
},
watch: {
fetchOptions() {
this.$refs.list.reset()
},
},
}
export default UserProfileAdminView
diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js
index 6f00b3e5e7..28b63c9737 100644
--- a/src/components/who_to_follow/who_to_follow.js
+++ b/src/components/who_to_follow/who_to_follow.js
@@ -1,45 +1,44 @@
import FollowCard from 'src/components/follow_card/follow_card.vue'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { fetchUser, suggestions } from 'src/api/public.js'
const WhoToFollow = {
components: {
FollowCard,
},
data() {
return {
users: [],
}
},
mounted() {
this.getWhoToFollow()
},
methods: {
showWhoToFollow(reply) {
reply.forEach(({ id }) => {
fetchUser({
id,
credentials: useOAuthStore().token,
}).then(({ data: externalUser }) => {
if (!externalUser.error) {
this.$store.commit('addNewUsers', [externalUser])
this.users.push(externalUser)
}
})
})
},
getWhoToFollow() {
const credentials = useOAuthStore().token
if (credentials) {
suggestions({ credentials }).then(({ data: reply }) => {
this.showWhoToFollow(reply)
})
}
},
},
}
export default WhoToFollow
diff --git a/src/lib/language.js b/src/lib/language.js
index af53eeffdb..dc51214520 100644
--- a/src/lib/language.js
+++ b/src/lib/language.js
@@ -1,18 +1,16 @@
-import Cookies from 'js-cookie'
-
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n.js'
export const piniaLanguagePlugin = ({ store, options }) => {
if (store.$id === 'sync_config') {
store.$onAction(({ name, args }) => {
if (name === 'setPreference') {
const { path, value } = args[0]
if (path === 'simple.interfaceLanguage') {
useI18nStore().setLanguage(value)
useEmojiStore().loadUnicodeEmojiData(value)
}
}
})
}
}
diff --git a/src/modules/api.js b/src/modules/api.js
index 03d184160b..72d7fb4d79 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -1,345 +1,344 @@
import { Socket } from 'phoenix'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useShoutStore } from 'src/stores/shout.js'
import { fetchTimeline } from 'src/api/timelines.js'
import {
getMastodonSocketURI,
ProcessedWS,
WSConnectionStatus,
} from 'src/api/websocket.js'
import followRequestFetcher from 'src/services/follow_request_fetcher/follow_request_fetcher.service'
import notificationsFetcher from 'src/services/notifications_fetcher/notifications_fetcher.service.js'
import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js'
const retryTimeout = (multiplier) => 1000 * multiplier
const api = {
state: {
retryMultiplier: 1,
fetchers: {},
socket: null,
mastoUserSocket: null,
mastoUserSocketStatus: null,
followRequests: [],
},
getters: {
followRequestCount: (state) => state.followRequests.length,
},
mutations: {
addFetcher(state, { fetcherName, fetcher }) {
state.fetchers[fetcherName] = fetcher
},
removeFetcher(state, { fetcherName }) {
state.fetchers[fetcherName].stop()
delete state.fetchers[fetcherName]
},
setWsToken(state, token) {
state.wsToken = token
},
setSocket(state, socket) {
state.socket = socket
},
setFollowRequests(state, value) {
state.followRequests = value
},
setMastoUserSocketStatus(state, value) {
state.mastoUserSocketStatus = value
},
incrementRetryMultiplier(state) {
state.retryMultiplier = Math.max(++state.retryMultiplier, 3)
},
resetRetryMultiplier(state) {
state.retryMultiplier = 1
},
},
actions: {
/**
* Global MastoAPI socket control, in future should disable ALL sockets/(re)start relevant sockets
*
* @param {Boolean} [initial] - whether this enabling happened at boot time or not
*/
enableMastoSockets(store, initial) {
const { state, dispatch, commit } = store
// Do not initialize unless nonexistent or closed
if (
state.mastoUserSocket &&
![WebSocket.CLOSED, WebSocket.CLOSING].includes(
state.mastoUserSocket.getState(),
)
) {
return
}
if (initial) {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING_INITIAL)
} else {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING)
}
return dispatch('startMastoUserSocket')
},
disableMastoSockets(store) {
const { state, dispatch, commit } = store
if (!state.mastoUserSocket) return
commit('setMastoUserSocketStatus', WSConnectionStatus.DISABLED)
return dispatch('stopMastoUserSocket')
},
// MastoAPI 'User' sockets
startMastoUserSocket(store) {
return new Promise((resolve, reject) => {
try {
const { state, commit, dispatch, rootState } = store
const timelineData = rootState.statuses.timelines.friends
const credentials = useOAuthStore().token
const url = getMastodonSocketURI({ credentials })
state.mastoUserSocket = ProcessedWS({
url,
id: 'Unified',
credentials,
})
state.mastoUserSocket.addEventListener(
'pleroma:authenticated',
() => {
state.mastoUserSocket.subscribe('user')
},
)
state.mastoUserSocket.addEventListener(
'message',
({ detail: message }) => {
if (!message) return // pings
if (message.event === 'notification') {
dispatch('addNewNotifications', {
notifications: [message.notification],
older: false,
})
} else if (message.event === 'update') {
dispatch('addNewStatuses', {
statuses: [message.status],
userId: false,
showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends',
})
} else if (message.event === 'status.update') {
dispatch('addNewStatuses', {
statuses: [message.status],
userId: false,
showImmediately:
message.status.id in timelineData.visibleStatusesObject,
timeline: 'friends',
})
} else if (message.event === 'delete') {
dispatch('deleteStatusById', message.id)
} else if (message.event === 'pleroma:chat_update') {
// The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
// The cause of the duplicates is the WS event arriving earlier than the HTTP response.
// This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
// (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
setTimeout(() => {
dispatch('addChatMessages', {
chatId: message.chatUpdate.id,
messages: [message.chatUpdate.lastMessage],
})
dispatch('updateChat', { chat: message.chatUpdate })
maybeShowChatNotification(store, message.chatUpdate)
}, 100)
}
},
)
state.mastoUserSocket.addEventListener('open', () => {
// Do not show notification when we just opened up the page
if (
state.mastoUserSocketStatus !==
WSConnectionStatus.STARTING_INITIAL
) {
useInterfaceStore().pushGlobalNotice({
level: 'success',
messageKey: 'timeline.socket_reconnected',
timeout: 5000,
})
}
// Stop polling if we were errored or disabled
if (
new Set([
WSConnectionStatus.ERROR,
WSConnectionStatus.DISABLED,
]).has(state.mastoUserSocketStatus)
) {
dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications')
dispatch('stopFetchingChats')
}
commit('resetRetryMultiplier')
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
})
state.mastoUserSocket.addEventListener(
'error',
({ detail: error }) => {
console.error('Error in MastoAPI websocket:', error)
// TODO is this needed?
dispatch('clearOpenedChats')
},
)
state.mastoUserSocket.addEventListener(
'close',
({ detail: closeEvent }) => {
const ignoreCodes = new Set([
1000, // Normal (intended) closure
1001, // Going away
])
const { code } = closeEvent
if (ignoreCodes.has(code)) {
console.debug(
`Not restarting socket becasue of closure code ${code} is in ignore list`,
)
commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
} else {
console.warn(
`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`,
)
setTimeout(() => {
dispatch('startMastoUserSocket')
}, retryTimeout(state.retryMultiplier))
commit('incrementRetryMultiplier')
if (state.mastoUserSocketStatus !== WSConnectionStatus.ERROR) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
dispatch('startFetchingChats')
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'timeline.socket_broke',
messageArgs: [code],
timeout: 5000,
})
}
commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
}
dispatch('clearOpenedChats')
},
)
resolve()
} catch (e) {
reject(e)
}
})
},
stopMastoUserSocket({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
dispatch('startFetchingChats')
state.mastoUserSocket.close()
},
// Timelines
startFetchingTimeline(
store,
{
timeline = 'friends',
tag = false,
userId = false,
listId = false,
statusId = false,
bookmarkFolderId = false,
},
) {
if (
timeline === 'favourites' &&
!useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
)
return
if (store.state.fetchers[timeline]) return
const fetcher = timelineFetcher.startFetching({
timeline,
store,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: timeline, fetcher })
},
stopFetchingTimeline(store, timeline) {
const fetcher = store.state.fetchers[timeline]
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: timeline, fetcher })
},
fetchTimeline(store, { timeline, ...rest }) {
fetchTimeline({
store,
timeline,
...rest,
credentials: useOAuthStore().token,
})
},
// Notifications
startFetchingNotifications(store) {
if (store.state.fetchers.notifications) return
const fetcher = notificationsFetcher.startFetching({
store,
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: 'notifications', fetcher })
},
stopFetchingNotifications(store) {
const fetcher = store.state.fetchers.notifications
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
},
// Follow requests
startFetchingFollowRequests(store) {
if (store.state.fetchers.followRequests) return
const fetcher = followRequestFetcher.startFetchingFollowRequests({
store,
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: 'followRequests', fetcher })
},
stopFetchingFollowRequests(store) {
const fetcher = store.state.fetchers.followRequests
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher })
},
// Pleroma websocket
setWsToken(store, token) {
store.commit('setWsToken', token)
},
initializeSocket({ commit, state, rootState }) {
// Set up websocket connection
const token = state.wsToken
if (
useInstanceCapabilitiesStore().shoutAvailable &&
typeof token !== 'undefined' &&
state.socket === null
) {
const socket = new Socket('/socket', { params: { token } })
socket.connect()
commit('setSocket', socket)
useShoutStore().initializeShout(socket)
}
},
disconnectFromSocket({ commit, state }) {
state.socket && state.socket.disconnect()
commit('setSocket', null)
},
},
}
export default api
diff --git a/src/modules/default_config_state.js b/src/modules/default_config_state.js
index 6a970ef0f7..ec190af286 100644
--- a/src/modules/default_config_state.js
+++ b/src/modules/default_config_state.js
@@ -1,827 +1,827 @@
-import { get, set } from 'lodash'
+import { get } from 'lodash'
const browserLocale = (navigator.language || 'en').split('-')[0]
export const convertDefinitions = (definitions) =>
Object.fromEntries(
Object.entries(definitions).map(([k, v]) => {
const defaultValue = v.default ?? null
return [k, defaultValue]
}),
)
/// Instance config entries provided by static config or pleroma api
/// Put settings here only if it does not make sense for a normal user
/// to override it.
export const INSTANCE_IDENTITY_DEFAULT_DEFINITIONS = {
style: {
description: 'Instance default style name',
type: 'string',
required: false,
},
palette: {
description: 'Instance default palette name',
type: 'string',
required: false,
},
theme: {
description: 'Instance default theme name',
type: 'string',
required: false,
},
defaultAvatar: {
description: "Default avatar image to use when user doesn't have one set",
type: 'string',
default: '/images/avi.png',
},
defaultBanner: {
description: "Default banner image to use when user doesn't have one set",
type: 'string',
default: '/images/banner.png',
},
background: {
description: 'Instance background/wallpaper',
type: 'string',
default: '/static/aurora_borealis.jpg',
},
embeddedToS: {
description: 'Whether to show Terms of Service title bar',
type: 'boolean',
default: true,
},
logo: {
description: 'Instance logo',
type: 'string',
default: '/static/logo.svg',
},
logoMargin: {
description: 'Margin for logo (spacing above/below)',
type: 'string',
default: '.2em',
},
logoMask: {
description:
'Use logo as a mask (works well for monochrome/transparent logos)',
type: 'boolean',
default: true,
},
logoLeft: {
description: 'Show logo on the left side of navbar',
type: 'boolean',
default: false,
},
redirectRootLogin: {
description: 'Where to redirect user after login',
type: 'string',
default: '/main/friends',
},
redirectRootNoLogin: {
description: 'Where to redirect anonymous visitors',
type: 'string',
default: '/main/all',
},
hideSitename: {
description: 'Hide the instance name in navbar',
type: 'boolean',
default: false,
},
nsfwCensorImage: {
description: 'Default NSFW censor image',
type: 'string',
required: false,
},
showFeaturesPanel: {
description: 'Show features panel to anonymous visitors',
type: 'boolean',
default: true,
},
showInstanceSpecificPanel: {
description: 'Show instance-specific panel',
type: 'boolean',
default: false,
},
// Html stuff
instanceSpecificPanelContent: {
description: 'HTML of Instance-specific panel',
type: 'string',
required: false,
},
tos: {
description: 'HTML of Terms of Service panel',
type: 'string',
required: false,
},
name: {
description: 'Instance Name',
type: 'string',
required: false,
},
}
export const INSTANCE_IDENTITY_DEFAULT = convertDefinitions(
INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
)
export const INSTANCE_IDENTIY_EXTERNAL = new Set([
'tos',
'instanceSpecificPanelContent',
])
/// This object contains setting entries that makes sense
/// at the user level. The defaults can also be overriden by
/// instance admins in the frontend_configuration endpoint or static config.
export const INSTANCE_DEFAULT_CONFIG_DEFINITIONS = {
expertLevel: {
description:
'Used to track which settings to show and hide in settings modal',
type: 'number', // not a boolean so we could potentially make multiple levels of expert-ness
default: 0,
},
hideISP: {
description: 'Hide Instance-specific panel',
default: false,
},
allowForeignUserBackground: {
description: "Allow other user's profiles to override wallpaper",
default: true,
},
hideInstanceWallpaper: {
description: 'Hide Instance default background',
default: false,
},
hideShoutbox: {
description: 'Hide shoutbox if present',
default: false,
},
hideMutedPosts: {
// bad name
description: 'Hide posts of muted users entirely',
default: false,
},
hideMutedThreads: {
description: 'Hide muted threads entirely',
default: true,
},
hideWordFilteredPosts: {
description: 'Hide wordfiltered posts entirely',
default: false,
},
muteBotStatuses: {
description: 'Mute posts made by bots',
default: false,
},
muteSensitiveStatuses: {
description: 'Mute posts marked as NSFW',
default: false,
},
collapseMessageWithSubject: {
description: 'Collapse posts with subject',
default: false,
},
padEmoji: {
description: 'Pad emoji with spaces when using emoji picker',
default: true,
},
hideAttachmentsInConv: {
description: 'Hide attachments',
default: false,
},
hideScrobbles: {
description: 'Hide scrobbles',
default: false,
},
hideScrobblesAfter: {
description: 'Hide scrobbles older than',
default: '2d',
},
maxThumbnails: {
description: 'Maximum attachments to show',
default: 16,
},
loopVideo: {
description: 'Loop videos',
default: true,
},
loopVideoSilentOnly: {
description: 'Loop only videos without sound',
default: true,
},
/// This is not the streaming API configuration, but rather an option
/// for automatically loading new posts into the timeline without
/// the user clicking the Show New button.
streaming: {
description: 'Automatically show new posts',
default: false,
},
pauseOnUnfocused: {
description: 'Pause showing new posts when tab is unfocused',
default: true,
},
emojiReactionsOnTimeline: {
description: 'Show emoji reactions on timeline',
default: true,
},
alwaysShowNewPostButton: {
description: 'Always show mobile "new post" button, even in desktop mode',
default: false,
},
autohideFloatingPostButton: {
description:
'Automatically hide mobile "new post" button when scrolling down',
default: false,
},
stopGifs: {
description: 'Play animated gifs on hover only',
default: true,
},
nonSquareEmoji: {
description: 'Allow emoji to be non-square (max 3:1 aspect)',
default: true,
},
replyVisibility: {
description: 'Type of replies to show',
default: 'all',
},
thirdColumnMode: {
description: 'What to display in third column',
default: 'notifications',
},
notificationVisibility: {
description: 'What types of notifications to show',
default: {
follows: true,
mentions: true,
statuses: true,
likes: true,
repeats: true,
moves: true,
emojiReactions: true,
followRequest: true,
reports: true,
chatMention: true,
polls: true,
},
},
notificationNative: {
description: 'What type of notifications to show desktop notification for',
default: {
follows: true,
mentions: true,
statuses: true,
likes: false,
repeats: false,
moves: false,
emojiReactions: false,
followRequest: true,
reports: true,
chatMention: true,
polls: true,
},
},
webPushNotifications: {
description: 'Use WebPush',
default: false,
},
webPushAlwaysShowNotifications: {
description: 'Ignore filter when using WebPush',
default: false,
},
interfaceLanguage: {
description: 'UI language',
default: [browserLocale],
},
hideScopeNotice: {
description: 'Hide scope notification',
default: false,
},
scopeCopy: {
description: 'Copy scope like mastodon does',
default: true,
},
subjectLineBehavior: {
description: 'How to treat subject line',
default: 'email',
},
alwaysShowSubjectInput: {
description: 'Always show subject line field',
default: true,
},
minimalScopesMode: {
description: 'Minimize amount of options shown in scope selector',
default: false,
},
// This hides statuses filtered via a word filter
hideFilteredStatuses: {
description: 'Hide wordfiltered entirely',
default: false,
},
// Confirmations
modalOnRepeat: {
description: 'Show confirmation modal for repeat',
default: false,
},
modalOnUnfollow: {
description: 'Show confirmation modal for unfollow',
default: false,
},
modalOnBlock: {
description: 'Show confirmation modal for block',
default: true,
},
modalOnMute: {
description: 'Show confirmation modal for mute',
default: false,
},
modalOnMuteConversation: {
description: 'Show confirmation modal for mute conversation',
default: false,
},
modalOnMuteDomain: {
description: 'Show confirmation modal for mute domain',
default: true,
},
modalOnDelete: {
description: 'Show confirmation modal for delete',
default: true,
},
modalOnLogout: {
description: 'Show confirmation modal for logout',
default: true,
},
modalOnApproveFollow: {
description: 'Show confirmation modal for approve follow',
default: false,
},
modalOnDenyFollow: {
description: 'Show confirmation modal for deny follow',
default: false,
},
modalOnRemoveUserFromFollowers: {
description: 'Show confirmation modal for follower removal',
default: false,
},
// Expiry confirmations/default actions
onMuteDefaultAction: {
description: 'Default action when muting user',
default: 'ask',
},
onBlockDefaultAction: {
description: 'Default action when blocking user',
default: 'ask',
},
modalMobileCenter: {
description: 'Center mobile dialogs vertically',
default: false,
},
playVideosInModal: {
description: 'Play videos in gallery view',
default: false,
},
useContainFit: {
description: 'Use object-fit: contain for attachments',
default: true,
},
disableStickyHeaders: {
description: 'Disable sticky headers',
default: false,
},
showScrollbars: {
description: 'Always show scrollbars',
default: false,
},
userPopoverAvatarAction: {
description: 'What to do when clicking popover avatar',
default: 'open',
},
userPopoverOverlay: {
description: 'Overlay user popover with centering on avatar',
default: false,
},
userCardLeftJustify: {
description: 'Justify user bio to the left',
default: false,
},
userCardHidePersonalMarks: {
description: 'Hide highlight/personal note in user view',
default: false,
},
forcedRoundness: {
description: 'Force roundness of the theme',
default: -1,
},
greentext: {
description: 'Highlight plaintext >quotes',
default: false,
},
mentionLinkShowTooltip: {
description: 'Show tooltips for mention links',
default: true,
},
mentionLinkShowAvatar: {
description: 'Show avatar next to mention link',
default: false,
},
mentionLinkFadeDomain: {
description:
'Mute (fade) domain name in mention links if configured to show it',
default: true,
},
mentionLinkShowYous: {
description: 'Show (you)s when you are mentioned',
default: false,
},
mentionLinkBoldenYou: {
description: 'Boldern mentionlink of you',
default: true,
},
hidePostStats: {
description: 'Hide post stats (rt, favs)',
default: false,
},
hideBotIndication: {
description: 'Hide bot indicator',
default: false,
},
hideUserStats: {
description: 'Hide user stats (followers etc)',
default: false,
},
virtualScrolling: {
description: 'Timeline virtual scrolling',
default: true,
},
sensitiveByDefault: {
description: 'Assume attachments are NSFW by default',
default: false,
},
conversationDisplay: {
description: 'Style of conversation display',
default: 'linear',
},
conversationTreeAdvanced: {
description: 'Advanced features of tree view conversation',
default: false,
},
conversationOtherRepliesButton: {
description: 'Where to show "other replies" in tree conversation view',
default: 'below',
},
conversationTreeFadeAncestors: {
description: 'Fade ancestors in tree conversation view',
default: false,
},
showExtraNotifications: {
description:
'Show extra notifications (chats, announcements etc) in notification panel',
default: true,
},
showExtraNotificationsTip: {
description: 'Show tip for extra notifications (that user can remove them)',
default: true,
},
showChatsInExtraNotifications: {
description: 'Show chat messages in notifications',
default: true,
},
showAnnouncementsInExtraNotifications: {
description: 'Show announcements in notifications',
default: true,
},
showFollowRequestsInExtraNotifications: {
description: 'Show follow requests in notifications',
default: true,
},
maxDepthInThread: {
description: 'Maximum depth in tree conversation view',
default: 6,
},
autocompleteSelect: {
description: '',
default: false,
},
closingDrawerMarksAsSeen: {
description: 'Closing mobile notification pane marks everything as seen',
default: true,
},
unseenAtTop: {
description: 'Show unseen notifications above others',
default: false,
},
ignoreInactionableSeen: {
description: 'Treat inactionable (fav, rt etc) notifications as "seen"',
default: false,
},
unsavedPostAction: {
description: 'What to do if post is aborted',
default: 'confirm',
},
autoSaveDraft: {
description: 'Save drafts automatically',
default: false,
},
useAbsoluteTimeFormat: {
description: 'Use absolute time format',
default: false,
},
absoluteTimeFormatMinAge: {
description: 'Show absolute time format only after this post age',
default: '0d',
},
absoluteTime12h: {
description: 'Use 24h time format',
default: '24h',
},
themeChecksum: {
description: 'Checksum of theme used',
type: 'string',
required: false,
},
highlights: {
description: 'User highlights',
type: 'object',
required: false,
default: {},
},
underlay: {
description: 'Underlay override',
required: true,
default: 'none',
},
compactProfiles: {
description: 'Reduce profile height on user pages',
default: false,
},
}
export const INSTANCE_DEFAULT_CONFIG = convertDefinitions(
INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
)
export const LOCAL_DEFAULT_CONFIG_DEFINITIONS = {
// TODO these two used to be separate but since separation feature got broken it doesn't matter
hideAttachments: {
description: 'Hide attachments in timeline',
default: false,
},
hideAttachmentsInConv: {
description: 'Hide attachments in coversation',
default: false,
},
hideNsfw: {
description: 'Hide nsfw posts',
default: true,
},
useOneClickNsfw: {
description: 'Open NSFW images directly in media modal',
default: false,
},
preloadImage: {
description: 'Preload images for NSFW',
default: true,
},
postContentType: {
description: 'Default post content type',
default: 'text/plain',
},
sidebarRight: {
description: 'Reverse order of columns',
default: false,
},
sidebarColumnWidth: {
description: 'Sidebar column width',
default: '25rem',
},
contentColumnWidth: {
description: 'Middle column width',
default: '45rem',
},
notifsColumnWidth: {
description: 'Notifications column width',
default: '25rem',
},
themeEditorMinWidth: {
description: 'Hack for theme editor on mobile',
default: '0rem',
},
emojiReactionsScale: {
description: 'Emoji reactions scale factor',
default: 0.5,
},
textSize: {
description: 'Font size',
default: '1rem',
},
emojiSize: {
description: 'Emoji size',
default: '2.2rem',
},
navbarSize: {
description: 'Navbar size',
default: '3.5rem',
},
panelHeaderSize: {
description: 'Panel header size',
default: '3.2rem',
},
navbarColumnStretch: {
description: 'Stretch navbar to match columns width',
default: false,
},
mentionLinkDisplay: {
description: 'How to display mention links',
default: 'short',
},
imageCompression: {
description: 'Image compression (WebP/JPEG)',
default: true,
},
alwaysUseJpeg: {
description: 'Compress images using JPEG only',
default: false,
},
useStreamingApi: {
description: 'Streaming API (WebSocket)',
default: false,
},
fontInterface: {
description: 'Interface font override',
type: 'string',
default: null,
},
fontInput: {
description: 'Input font override',
type: 'string',
default: null,
},
fontPosts: {
description: 'Post font override',
type: 'string',
default: null,
},
fontMonospace: {
description: 'Monospace font override',
type: 'string',
default: null,
},
themeDebug: {
description:
'Debug mode that uses computed backgrounds instead of real ones to debug contrast functions',
default: false,
},
forceThemeRecompilation: {
description: 'Flag that forces recompilation on boot even if cache exists',
default: false,
},
}
export const LOCAL_DEFAULT_CONFIG = convertDefinitions(
LOCAL_DEFAULT_CONFIG_DEFINITIONS,
)
export const LOCAL_ONLY_KEYS = new Set(Object.keys(LOCAL_DEFAULT_CONFIG))
export const SYNC_DEFAULT_CONFIG_DEFINITIONS = {
dontShowUpdateNotifs: {
description: 'Never show update notification (pleroma-tan)',
default: false,
},
collapseNav: {
description: 'Collapse navigation panel to header only',
default: false,
},
muteFilters: {
description: 'Object containing mute filters',
type: 'object',
default: {},
},
}
export const SYNC_DEFAULT_CONFIG = convertDefinitions(
SYNC_DEFAULT_CONFIG_DEFINITIONS,
)
export const SYNC_ONLY_KEYS = new Set(Object.keys(SYNC_DEFAULT_CONFIG))
export const THEME_CONFIG_DEFINITIONS = {
theme: {
description: 'Very old theme store, stores preset name, still in use',
default: null,
},
colors: {
description:
'VERY old theme store, just colors of V1, probably not even used anymore',
default: {},
},
// V2
customTheme: {
description:
'"Snapshot", previously was used as actual theme store for V2 so it\'s still used in case of PleromaFE downgrade event.',
default: null,
},
customThemeSource: {
description: '"Source", stores original theme data',
default: null,
},
// V3
style: {
description: 'Style name for builtins',
default: null,
},
styleCustomData: {
description: 'Custom style data (i.e. not builtin)',
default: null,
},
palette: {
description: 'Palette name for builtins',
default: null,
},
paletteCustomData: {
description: 'Custom palette data (i.e. not builtin)',
default: null,
},
}
export const THEME_CONFIG = convertDefinitions(THEME_CONFIG_DEFINITIONS)
export const makeUndefined = (c) =>
Object.fromEntries(Object.keys(c).map((key) => [key, undefined]))
/// For properties with special processing or properties that does not
/// make sense to be overriden on a instance-wide level.
export const ROOT_CONFIG = {
// Set these to undefined so it does not interfere with default settings check
...INSTANCE_DEFAULT_CONFIG,
...LOCAL_DEFAULT_CONFIG,
...SYNC_DEFAULT_CONFIG,
...THEME_CONFIG,
}
export const ROOT_CONFIG_DEFINITIONS = {
...INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
...LOCAL_DEFAULT_CONFIG_DEFINITIONS,
...SYNC_DEFAULT_CONFIG_DEFINITIONS,
...THEME_CONFIG_DEFINITIONS,
}
export const validateSetting = ({
value,
path: fullPath,
definition,
throwError,
defaultState,
validateObjects = true,
}) => {
if (value === undefined) return undefined // only null is allowed as missing value
if (definition === undefined) return undefined // invalid definition
const path = fullPath.replace(/^simple./, '')
const depth = path.split('.')
if (
validateObjects &&
definition.type === 'object' &&
path.split('.').length <= 1
) {
console.error(
`attempt to set object ${fullPath} instead of its children. ignoring.`,
)
return undefined
}
if (get(defaultState, path.split('.')[0]) === undefined) {
const string = `Unknown option ${fullPath}, value: ${value}`
if (throwError) {
throw new Error(string)
} else {
console.error(string)
return undefined
}
}
let { required, type, default: defaultValue } = definition
if (type == null && defaultValue != null) {
type = typeof defaultValue
}
if (required && value == null) {
const string = `Value required for setting ${path} but was provided nullish; defaulting`
if (throwError) {
throw new Error(string)
} else {
console.error(string)
return defaultValue
}
}
if (depth > 2 && value !== null && type != null && typeof value !== type) {
const string = `Invalid type for setting ${path}: expected type ${type}, got ${typeof value}, value ${value}; defaulting`
if (throwError) {
throw new Error(string)
} else {
console.error(string)
return defaultValue
}
}
return value
}
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index 46101db7da..8530c468cc 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -1,128 +1,127 @@
import { promiseInterval } from '../promise_interval/promise_interval.js'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { fetchTimeline } from 'src/api/timelines.js'
const update = ({ store, notifications, older }) => {
store.dispatch('addNewNotifications', { notifications, older })
}
//
// For using include_types when fetching notifications.
// Note: chat_mention excluded as pleroma-fe polls them separately
const mastoApiNotificationTypes = new Set([
'mention',
'status',
'favourite',
'reblog',
'follow',
'follow_request',
'move',
'poll',
'pleroma:emoji_reaction',
'pleroma:report',
])
const fetchAndUpdate = ({ store, credentials, older = false, sinceId }) => {
const args = { credentials }
const rootState = store.rootState || store.state
const timelineData = rootState.notifications
const hideMutedPosts = useMergedConfigStore().mergedConfig.hideMutedPosts
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
mastoApiNotificationTypes.add('pleroma:chat_mention')
}
args.includeTypes = [...mastoApiNotificationTypes]
args.withMuted = !hideMutedPosts
args.timeline = 'notifications'
if (older) {
if (timelineData.minId !== Number.POSITIVE_INFINITY) {
args.maxId = timelineData.minId
}
return fetchNotifications({ store, args, older })
} else {
// fetch new notifications
if (
sinceId === undefined &&
timelineData.maxId !== Number.POSITIVE_INFINITY
) {
args.sinceId = timelineData.maxId
} else if (sinceId !== null) {
args.sinceId = sinceId
}
const result = fetchNotifications({ store, args, older })
// If there's any unread notifications, try fetch notifications since
// the newest read notification to check if any of the unread notifs
// have changed their 'seen' state (marked as read in another session), so
// we can update the state in this session to mark them as read as well.
// The normal maxId-check does not tell if older notifications have changed
const notifications = timelineData.data
const readNotifsIds = notifications.filter((n) => n.seen).map((n) => n.id)
const unreadNotifsIds = notifications
.filter((n) => !n.seen)
.map((n) => n.id)
if (readNotifsIds.length > 0 && readNotifsIds.length > 0) {
const minId = Math.min(...unreadNotifsIds) // Oldest known unread notification
if (minId !== Infinity) {
args.sinceId = null // Don't use since_id since it sorta conflicts with min_id
args.minId = minId - 1 // go beyond
fetchNotifications({ store, args, older })
}
}
return result
}
}
const fetchNotifications = ({ store, args, older }) => {
return fetchTimeline(args)
.then((response) => {
const notifications = response.data
update({ store, notifications, older })
return notifications
})
.catch((error) => {
if (
error.statusCode === 400 &&
error.statusText.includes('Invalid value for enum')
) {
error.statusText
.matchAll(/(\w+) - Invalid value for enum./g)
.toArray()
.map((x) => x[1])
.forEach((x) => mastoApiNotificationTypes.delete(x))
return fetchNotifications({ store, args, older })
}
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'notifications.error',
messageArgs: [error.message],
timeout: 5000,
})
console.error(error)
})
}
const startFetching = ({ credentials, store }) => {
// Initially there's set flag to silence all desktop notifications so
// that there won't spam of them when user just opened up the FE we
// reset that flag after a while to show new notifications once again.
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
boundFetchAndUpdate()
return promiseInterval(boundFetchAndUpdate, 10000)
}
const notificationsFetcher = {
fetchAndUpdate,
startFetching,
}
export default notificationsFetcher
diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js
index c006ae172d..80dbc75d0d 100644
--- a/src/services/timeline_fetcher/timeline_fetcher.service.js
+++ b/src/services/timeline_fetcher/timeline_fetcher.service.js
@@ -1,161 +1,160 @@
import { camelCase } from 'lodash'
import { promiseInterval } from '../promise_interval/promise_interval.js'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { fetchTimeline } from 'src/api/timelines.js'
const update = ({
store,
statuses,
timeline,
showImmediately,
userId,
listId,
pagination,
}) => {
const ccTimeline = camelCase(timeline)
store.dispatch('addNewStatuses', {
timeline: ccTimeline,
userId,
listId,
statuses,
showImmediately,
pagination,
})
}
const fetchAndUpdate = ({
store,
credentials,
timeline = 'friends',
older = false,
showImmediately = false,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
maxId,
sinceId,
}) => {
const args = { timeline, credentials }
const rootState = store.rootState || store.state
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const { hideMutedPosts, replyVisibility } =
useMergedConfigStore().mergedConfig
const loggedIn = !!rootState.users.currentUser
if (older) {
// When minId = 0 we need to fetch without maxId param
args.maxId = maxId || timelineData.minId || null
} else {
if (sinceId === undefined) {
args.sinceId = timelineData.maxId
} else if (sinceId !== null) {
args.sinceId = sinceId
}
}
args.userId = userId
args.listId = listId
args.statusId = statusId
args.bookmarkFolderId = bookmarkFolderId
args.tag = tag
args.withMuted = !hideMutedPosts
if (
loggedIn &&
['friends', 'public', 'publicAndExternal', 'bubble'].includes(timeline)
) {
args.replyVisibility = replyVisibility
}
const numStatusesBeforeFetch = timelineData.statuses.length
return fetchTimeline(args)
.then((response) => {
const { data: statuses, pagination } = response
if (
!older &&
statuses.length >= 20 &&
!timelineData.loading &&
numStatusesBeforeFetch > 0
) {
store.dispatch('queueFlush', { timeline, id: timelineData.maxId })
}
update({
store,
statuses,
timeline,
showImmediately,
userId,
listId,
pagination,
})
return { statuses, pagination }
})
.catch((error) => {
if (error.statusCode === 403 && timeline === 'favorites') {
useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable = false
return
}
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'timeline.error',
messageArgs: [error.message],
timeout: 5000,
})
})
}
const startFetching = ({
timeline = 'friends',
credentials,
store,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
}) => {
const rootState = store.rootState || store.state
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const showImmediately = timelineData.visibleStatuses.length === 0
timelineData.userId = userId
timelineData.listId = listId
timelineData.bookmarkFolderId = bookmarkFolderId
fetchAndUpdate({
timeline,
credentials,
store,
showImmediately,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
})
const boundFetchAndUpdate = () =>
fetchAndUpdate({
timeline,
credentials,
store,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
})
return promiseInterval(boundFetchAndUpdate, 10000)
}
const timelineFetcher = {
fetchAndUpdate,
startFetching,
}
export default timelineFetcher
diff --git a/src/stores/instance.js b/src/stores/instance.js
index ea3419e804..02edd12358 100644
--- a/src/stores/instance.js
+++ b/src/stores/instance.js
@@ -1,235 +1,235 @@
-import { get, set } from 'lodash'
+import { set } from 'lodash'
import { defineStore } from 'pinia'
import {
convertDefinitions,
INSTANCE_DEFAULT_CONFIG,
INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
INSTANCE_IDENTITY_DEFAULT,
INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
LOCAL_DEFAULT_CONFIG,
LOCAL_DEFAULT_CONFIG_DEFINITIONS,
validateSetting,
} from '../modules/default_config_state.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { fetchKnownDomains } from 'src/api/public.js'
const REMOTE_INTERACTION_URL = '/main/ostatus'
const ROOT_STATE_DEFINITIONS = {
name: {
type: 'string',
default: 'PleromaFE',
},
registrationOpen: {
required: true,
type: 'boolean',
},
server: {
description: 'Server URL',
required: true,
type: 'string',
},
privateMode: {
description: 'Private instance?',
required: true,
type: 'boolean',
},
federating: {
description: 'Is federation enabled?',
required: true,
type: 'boolean',
},
federationPolicy: {
description: '????',
required: true,
type: 'object',
},
// Indexes of themes/styles/palettes
themesIndex: {
required: true,
type: 'object',
},
stylesIndex: {
required: true,
type: 'object',
},
palettesIndex: {
required: true,
type: 'object',
},
themeData: {
description: 'Used for theme v2 editor',
required: true,
type: 'object',
},
vapidPublicKey: {
description: 'Used for WebPush',
required: true,
type: 'string',
},
// Stuff from static/config.json
loginMethod: {
description: 'Login method (token/password)',
default: 'password',
},
disableUpdateNotification: {
description: 'Disable update notification (pleroma-tan one)',
default: false,
},
knownDomains: {
description: 'List of known domains; used for domain list for domain mutes',
default: [],
},
// Moderation stuff
staffAccounts: {
description: 'List of staff accounts',
default: [],
},
accountActivationRequired: {
description:
'Account activation (by email or admin) required after registration',
required: true,
type: 'boolean',
},
accountApprovalRequired: {
description: 'Admin approval required after registration',
required: true,
type: 'boolean',
},
birthdayRequired: {
description: 'Require birthday entry when registering',
required: true,
type: 'boolean',
},
birthdayMinAge: {
description: 'Minimum age required for registration',
default: 0,
},
restrictedNicknames: {
description: 'List of nicknames that are not allowed',
default: [],
},
localBubbleInstances: {
description: "Akkoma's bubble feature, list of instances",
default: [],
}, // Akkoma
// Version Information
backendVersion: {
required: true,
type: 'string',
},
backendRepository: {
required: true,
type: 'string',
},
frontendVersion: {
required: true,
type: 'string',
},
}
const ROOT_STATE = convertDefinitions(ROOT_STATE_DEFINITIONS)
const DEFAULT_STATE = {
...ROOT_STATE,
// Instance-wide configurations that should not be changed by individual users
instanceIdentity: {
...INSTANCE_IDENTITY_DEFAULT,
},
limits: {
bannerlimit: null,
avatarlimit: null,
backgroundlimit: null,
uploadlimit: null,
fieldsLimits: null,
textLimit: null,
pollLimits: {
max_options: 4,
max_option_chars: 255,
min_expiration: 60,
max_expiration: 60 * 60 * 24,
},
},
// Instance admins can override default settings for the whole instance
prefsStorage: {
...INSTANCE_DEFAULT_CONFIG,
...LOCAL_DEFAULT_CONFIG,
},
}
export const useInstanceStore = defineStore('instance', {
state: () => ({ ...DEFAULT_STATE }),
getters: {
instanceDomain(state) {
return new URL(this.server).hostname
},
},
actions: {
set({ path, value }) {
let definition
const pathArray = path.split('.')
const subpath = pathArray[1] ?? pathArray[0]
if (path.startsWith('instanceIdentity.')) {
definition = INSTANCE_IDENTITY_DEFAULT_DEFINITIONS[subpath]
} else if (path.startsWith('prefsStorage.')) {
definition = {
...LOCAL_DEFAULT_CONFIG_DEFINITIONS,
...INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
}[subpath]
} else if (path.startsWith('limits.')) {
definition =
path === 'limits.pollLimits' || path === 'limits.fieldsLimits'
? { required: true, type: 'object' }
: { required: true, type: 'number' }
} else {
const definitions = ROOT_STATE_DEFINITIONS
definition = definitions[subpath]
}
const finalValue = validateSetting({
path,
value,
definition,
throwError: true,
defaultState: DEFAULT_STATE,
validateObjects: false,
})
set(this, path, finalValue)
if (path === 'name') useInterfaceStore().setPageTitle()
},
async getKnownDomains() {
try {
const { data } = await fetchKnownDomains({
credentials: window.vuex.state.users.currentUser.credentials,
})
this.knownDomains = data
} catch (e) {
console.warn("Can't load known domains\n", e)
}
},
getRemoteInteractionLink({ statusId, nickname }) {
const server = this.server.endsWith('/')
? this.server.slice(0, -1)
: this.server
const link = server + REMOTE_INTERACTION_URL
if (statusId) {
return `${link}?status_id=${statusId}`
} else {
return `${link}?nickname=${nickname}`
}
},
},
})
diff --git a/src/stores/local_config.js b/src/stores/local_config.js
index 5b15c6ff1e..786da657b3 100644
--- a/src/stores/local_config.js
+++ b/src/stores/local_config.js
@@ -1,67 +1,64 @@
import { cloneDeep, set } from 'lodash'
import { defineStore } from 'pinia'
-import { toRaw } from 'vue'
-
-import { useInstanceStore } from 'src/stores/instance'
import {
LOCAL_DEFAULT_CONFIG,
LOCAL_DEFAULT_CONFIG_DEFINITIONS,
makeUndefined,
validateSetting,
} from 'src/modules/default_config_state'
export const defaultState = {
prefsStorage: {
...makeUndefined(LOCAL_DEFAULT_CONFIG),
},
tempStorage: {
...makeUndefined(LOCAL_DEFAULT_CONFIG),
},
}
export const useLocalConfigStore = defineStore('local_config', {
state() {
return cloneDeep(defaultState)
},
actions: {
set({ path, value }) {
const definition = LOCAL_DEFAULT_CONFIG_DEFINITIONS[path]
const finalValue = validateSetting({
path,
value,
definition,
throwError: false,
defaultState: LOCAL_DEFAULT_CONFIG,
})
set(this.prefsStorage, path, finalValue)
},
setTemporarily({ path, value }) {
set(this.tempStorage, path, value)
},
unsetTemporarily({ path, value }) {
set(this.tempStorage, path, undefined)
},
unset({ path, value }) {
set(this.prefsStorage, path, undefined)
},
clearLocalConfig() {
Object.keys(this).forEach((k) => {
this.prefsStorage[k] = undefined
this.tempStorage[k] = undefined
})
},
},
persist: {
afterLoad(state) {
return {
prefsStorage: state.prefsStorage ?? {
...makeUndefined(LOCAL_DEFAULT_CONFIG),
},
tempStorage: { ...makeUndefined(LOCAL_DEFAULT_CONFIG) },
}
},
},
})
diff --git a/src/stores/sync_config.js b/src/stores/sync_config.js
index ccc4bbd4d5..47c6978d5a 100644
--- a/src/stores/sync_config.js
+++ b/src/stores/sync_config.js
@@ -1,841 +1,840 @@
import sum from 'hash-sum'
import {
merge as _merge,
clamp,
cloneDeep,
findLastIndex,
flatten,
get,
groupBy,
isEqual,
set,
take,
takeRight,
uniqWith,
unset,
} from 'lodash'
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { toRaw } from 'vue'
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
-import { useInstanceStore } from 'src/stores/instance.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileJSON } from 'src/api/user.js'
import { storage } from 'src/lib/storage.js'
import {
makeUndefined,
ROOT_CONFIG,
ROOT_CONFIG_DEFINITIONS,
validateSetting,
} from 'src/modules/default_config_state.js'
import { oldDefaultConfigSync } from 'src/modules/old_default_config_state.js'
export const VERSION = 2
export const NEW_USER_DATE = new Date('2026-03-16') // date of writing this, basically
export const COMMAND_TRIM_FLAGS = 1000
export const COMMAND_TRIM_FLAGS_AND_RESET = 1001
export const COMMAND_WIPE_JOURNAL = 1010
export const COMMAND_WIPE_JOURNAL_AND_STORAGE = 1011
export const defaultState = {
// do we need to update data on server?
dirty: false,
// storage of flags - stuff that can only be set and incremented
flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen
reset: 0, // special flag that can be used to force-reset all data, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0
},
prefsStorage: {
_journal: [],
simple: {
muteFilters: {},
...makeUndefined({ ...ROOT_CONFIG }),
},
collections: {
pinnedStatusActions: ['reply', 'retweet', 'favorite', 'emoji'],
pinnedNavItems: ['home', 'dms', 'chats'],
},
},
// raw data
raw: null,
// local cache
cache: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
updateCounter: CURRENT_UPDATE_COUNTER, // new users don't need to see update notification
}
export const _moveItemInArray = (array, value, movement) => {
const oldIndex = array.indexOf(value)
const newIndex = oldIndex + movement
const newArray = [...array]
// remove old
newArray.splice(oldIndex, 1)
// add new
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
return newArray
}
const _wrapData = (data, userName) => {
return {
...data,
_user: userName,
_timestamp: Date.now(),
_version: VERSION,
}
}
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
const _verifyPrefs = (state) => {
state.prefsStorage = state.prefsStorage || {
simple: {},
collections: {},
}
// Simple
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
if (typeof v === 'undefined') return
if (typeof v === 'number' || typeof v === 'boolean') return
if (typeof v === 'object') return
console.warn(
`Preference simple.${k} as invalid type ${typeof v}, reinitializing`,
)
set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
})
// Collections
Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
if (Array.isArray(v)) return
console.warn(
`Preference collections.${k} as invalid type ${typeof v}, reinitializing`,
)
set(
state.prefsStorage.collections,
k,
defaultState.prefsStorage.collections[k],
)
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug('Both sources have valid data, figuring things out...')
if (
live._timestamp === cache._timestamp &&
live._version === cache._version
) {
console.debug(
'Same version/timestamp on both sources, source of truth irrelevant',
)
result.recent = cache
result.stale = live
} else {
console.debug(
'Different timestamp or version, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('Both sources are invalid, start from scratch')
result.needUpload = true
}
const merge = (a, b) => {
return {
_user: a._user ?? b._user,
_version: a._version ?? b._version,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
prefsStorage: _merge(cloneDeep(a.prefsStorage), b.prefsStorage),
flagStorage: _merge(a.flagStorage, b.flagStorage),
}
}
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
export const _getAllFlags = (recent, stale) => {
return Array.from(
new Set([
...Object.keys(toRaw((recent || {}).flagStorage || {})),
...Object.keys(toRaw((stale || {}).flagStorage || {})),
]),
)
}
export const _mergeFlags = (recent, stale, allFlagKeys) => {
if (!stale.flagStorage) return recent.flagStorage
if (!recent.flagStorage) return stale.flagStorage
return Object.fromEntries(
allFlagKeys.map((flag) => {
const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag]
// use flag that is of higher value
return [
flag,
Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0),
]
}),
)
}
export const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = flatten(
journals.map((j) => (Array.isArray(j) ? j : [])),
).filter(
(entry) =>
Object.hasOwn(entry, 'path') &&
Object.hasOwn(entry, 'operation') &&
Object.hasOwn(entry, 'args') &&
Object.hasOwn(entry, 'timestamp'),
)
const grouped = groupBy(allJournals, 'path')
const trimmedGrouped = Object.entries(grouped).map(([path, rawJournal]) => {
const journal = rawJournal
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
if (a.timestamp === b.timestamp) {
return ai - bi
} else {
return a.timestamp > b.timestamp ? 1 : -1
}
})
.map((x) => x.data)
if (path.startsWith('collections')) {
const lastRemoveIndex = findLastIndex(
journal,
({ operation }) => operation === 'removeFromCollection',
)
// everything before last remove is unimportant
let remainder
if (lastRemoveIndex > 0) {
remainder = journal.slice(lastRemoveIndex)
} else {
// everything else doesn't need trimming
remainder = journal
}
return uniqWith(remainder, (a, b) => {
if (a.path !== b.path) {
return false
}
if (a.operation !== b.operation) {
return false
}
if (a.operation === 'addToCollection') {
return a.args[0] === b.args[0]
}
return false
})
} else if (path.startsWith('simple')) {
// Only the last record is important
return takeRight(journal)
} else {
return journal
}
})
const flat = flatten(trimmedGrouped)
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
if (a.timestamp === b.timestamp) {
return ai - bi
} else {
return a.timestamp > b.timestamp ? 1 : -1
}
})
.map((x) => x.data)
return take(flat, 500)
}
export const _mergePrefs = (recent, stale) => {
if (!stale) return recent
if (!recent) return stale
const { _journal: recentJournal, ...recentData } = recent
const { _journal: staleJournal } = stale
/** Journal entry format:
* path: path to entry in prefsStorage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
* shouldn't be used with collections!
*/
const resultOutput = { ...recentData }
const totalJournal = _mergeJournal(staleJournal, recentJournal)
totalJournal
.filter(({ path, operation, args }) => {
const entry = path.split('.')[1]
if (operation === 'unset') return ROOT_CONFIG[entry] !== undefined
if (operation !== 'set') return true
const definition = path.startsWith('simple.muteFilters')
? { default: {} }
: ROOT_CONFIG_DEFINITIONS[entry]
const finalValue = validateSetting({
path,
value: args[0],
definition,
throwError: false,
defaultState: ROOT_CONFIG,
})
return finalValue !== undefined
})
.forEach(({ path, operation, args }) => {
if (path.startsWith('_')) {
throw new Error(
`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`,
)
}
switch (operation) {
case 'set': {
if (path.startsWith('collections')) {
return console.error('Illegal operation "set" on a collection')
}
if (path.split(/\./g).length <= 1) {
return console.error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
set(resultOutput, path, args[0])
break
}
case 'unset':
if (path.startsWith('collections')) {
return console.error('Illegal operation "unset" on a collection')
}
if (path.split(/\./g).length <= 2) {
return console.error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
unset(resultOutput, path)
break
case 'addToCollection':
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "addToCollection" on a non-collection',
)
}
set(
resultOutput,
path,
Array.from(new Set(get(resultOutput, path)).add(args[0])),
)
break
case 'removeFromCollection': {
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "removeFromCollection" on a non-collection',
)
}
const newSet = new Set(get(resultOutput, path))
newSet.delete(args[0])
set(resultOutput, path, Array.from(newSet))
break
}
case 'reorderCollection': {
const [value, movement] = args
set(
resultOutput,
path,
_moveItemInArray(get(resultOutput, path), value, movement),
)
break
}
default:
return console.error(
`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`,
)
}
})
return { ...resultOutput, _journal: totalJournal }
}
export const _resetFlags = (
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
let result = { ...totalFlags }
const allFlagKeys = Object.keys(totalFlags)
// flag reset functionality
if (
totalFlags.reset >= COMMAND_TRIM_FLAGS &&
totalFlags.reset <= COMMAND_TRIM_FLAGS_AND_RESET
) {
console.debug('Received command to trim the flags')
const knownKeysSet = new Set(Object.keys(knownKeys))
// Trim
result = {}
allFlagKeys.forEach((flag) => {
if (knownKeysSet.has(flag)) {
result[flag] = totalFlags[flag]
}
})
// Reset
if (totalFlags.reset === COMMAND_TRIM_FLAGS_AND_RESET) {
// 1001 - and reset everything to 0
console.debug('Received command to reset the flags')
Object.keys(knownKeys).forEach((flag) => {
result[flag] = 0
})
}
}
result.reset = 0
return result
}
const _resetPrefs = (
totalPrefs,
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
// prefs reset functionality
if (
totalFlags.reset >= COMMAND_WIPE_JOURNAL &&
totalFlags.reset <= COMMAND_WIPE_JOURNAL_AND_STORAGE
) {
console.debug('Received command to reset journals')
this.flagStorage.reset = COMMAND_WIPE_JOURNAL
this.prefsStorage._journal = []
this.cache.prefsStorage._journal = []
this.raw.prefsStorage._journal = []
this.pushSyncConfig()
if (totalFlags.reset === COMMAND_WIPE_JOURNAL_AND_STORAGE) {
console.debug('Received command to reset storage')
return cloneDeep(defaultState)
}
}
return totalPrefs
}
const _doMigrations = async (data, setPreference) => {
if (data._version < VERSION) {
console.debug(
'Data has older version, seeing if there any migrations that can be applied',
)
}
if (data._version > VERSION) {
console.debug(
'Data has newer version, seeing if there any reverse migrations that can be applied',
)
// no reverse migrations right now but we leave a possibility of loading a hotpatch if need be
if (window._PLEROMA_HOTPATCH) {
if (window._PLEROMA_HOTPATCH.reverseMigrations) {
console.debug('Found hotpatch migration, applying')
return window._PLEROMA_HOTPATCH.reverseMigrations.call(
{},
'syncConfigStore',
{ from: data._version, to: VERSION },
data,
)
}
}
}
return data
}
export const useSyncConfigStore = defineStore('sync_config', {
state() {
return cloneDeep(defaultState)
},
actions: {
setFlag({ flag, value }) {
this.flagStorage[flag] = value
this.dirty = true
},
setSimplePrefAndSave({ path, value }) {
this.setPreference({ path: `simple.${path}`, value })
this.pushSyncConfig()
},
unsetSimplePrefAndSave({ path }) {
this.unsetPreference({ path: `simple.${path}` })
this.pushSyncConfig()
},
setPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'set' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 1) {
throw new Error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling set on depth > 3 (path: ${path}) is not allowed`,
)
}
if (path.startsWith('collections.')) return value
const definition = path.startsWith('simple.muteFilters')
? { default: {} }
: ROOT_CONFIG_DEFINITIONS[path.split('.')[1]]
const finalValue = validateSetting({
path,
value,
definition,
throwError: false,
defaultState: ROOT_CONFIG,
})
if (finalValue !== undefined) set(this.prefsStorage, path, finalValue)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'set', path, args: [value], timestamp: Date.now() },
]
this.dirty = true
},
unsetPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'unset' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 2) {
throw new Error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling unset on depth > 3 (path: ${path}) is not allowed`,
)
}
unset(this.prefsStorage, path)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'unset', path, args: [], timestamp: Date.now() },
]
this.dirty = true
},
addCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}'`,
)
}
if (path.startsWith('collections')) {
const collection = new Set(get(this.prefsStorage, path))
collection.add(value)
set(this.prefsStorage, path, [...collection])
}
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'addToCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
removeCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const { _key } = value
if (path.startsWith('collection')) {
const collection = new Set(get(this.prefsStorage, path))
collection.delete(value)
set(this.prefsStorage, path, [...collection])
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'removeFromCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
}
},
reorderCollectionPreference({ path, value, movement }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const collection = get(this.prefsStorage, path)
const newCollection = _moveItemInArray(collection, value, movement)
set(this.prefsStorage, path, newCollection)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'arrangeCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
updateCache({ username }) {
this.prefsStorage._journal = _mergeJournal(this.prefsStorage._journal)
this.cache = _wrapData(
{
flagStorage: toRaw(this.flagStorage),
prefsStorage: toRaw(this.prefsStorage),
},
username,
)
},
clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
this.flagStorage.reset = COMMAND_WIPE_JOURNAL_AND_STORAGE
},
async initSyncConfig(userData) {
const live = userData.storage
this.raw = live
let cache = this.cache
if (cache?._user !== userData.fqn) {
console.warn(
'Cache belongs to another user! reinitializing local cache!',
)
cache = null
}
let { recent, stale, needUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage
let dirty = false
if (recent === null) {
console.debug(
`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
)
recent = _wrapData({
flagStorage: { ...flagsTemplate },
prefsStorage: { ...defaultState.prefsStorage },
})
}
recent = recent && (await _doMigrations(recent, this.setPreference))
stale = stale && (await _doMigrations(stale, this.setPreference))
// Various migrations
console.debug('Migrating from old config')
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
const config = vuexState.config ?? {}
const migratedEntries = new Set(config._syncMigration ?? [])
console.debug(
`Already migrated Values: ${[...migratedEntries].join() || '[none]'}`,
)
Object.entries(oldDefaultConfigSync).forEach(([key, value]) => {
const oldValue = config[key]
const defaultValue = value
const present = oldValue !== undefined
const migrated = migratedEntries.has(key)
const different = !isEqual(oldValue, defaultValue)
if (present && !migrated && different) {
console.debug(`Migrating config ${key}: ${oldValue}`)
if (key === 'theme3hacks') {
useLocalConfigStore().set({
path: 'fontInterface',
value: oldValue.fonts.interface,
})
useLocalConfigStore().set({
path: 'fontInput',
value: oldValue.fonts.input,
})
useLocalConfigStore().set({
path: 'fontPost',
value: oldValue.fonts.post,
})
useLocalConfigStore().set({
path: 'fontMonospace',
value: oldValue.fonts.monospace,
})
useSyncConfigStore().setSimplePrefAndSave({
path: 'underlay',
value: oldValue.underlay,
})
} else if (key == 'muteWords') {
oldValue.forEach((word, order) => {
const uniqueId = uuidv4()
useSyncConfigStore().setPreference({
path: 'simple.muteFilters.' + uniqueId,
value: {
type: 'word',
value: word,
name: word,
enabled: true,
expires: null,
hide: false,
order,
},
})
})
} else {
this.setPreference({ path: `simple.${key}`, value: oldValue })
}
migratedEntries.add(key)
needUpload = true
}
})
config._syncMigration = [...migratedEntries]
vuexState.config = config
storage.setItem('vuex-lz', vuexState)
if (!needUpload && recent && stale) {
console.debug('Checking if data needs merging...')
// discarding timestamps and versions
const { _timestamp: _0, _version: _1, ...recentData } = recent
const { _timestamp: _2, _version: _3, ...staleData } = stale
dirty = sum(recentData) !== sum(staleData)
console.debug(`Data ${dirty ? 'needs' : "doesn't need"} merging`)
}
const allFlagKeys = _getAllFlags(recent, stale)
let totalFlags
let totalPrefs
if (dirty) {
// Merge the flags
console.debug('Merging the data...')
totalFlags = _mergeFlags(recent, stale, allFlagKeys)
_verifyPrefs(recent)
_verifyPrefs(stale)
totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
} else {
totalFlags = recent.flagStorage
totalPrefs = recent.prefsStorage
}
totalPrefs = _resetPrefs(totalPrefs, totalFlags)
totalFlags = _resetFlags(totalFlags)
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
this.dirty = dirty || needUpload
this.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.flagStorage = this.cache.flagStorage
this.prefsStorage = this.cache.prefsStorage
this.pushSyncConfig()
},
pushSyncConfig({ force = false } = {}) {
const needPush = this.dirty || force
if (!needPush) return
this.updateCache({ username: window.vuex.state.users.currentUser.fqn })
const params = { pleroma_settings_store: { 'pleroma-fe': this.cache } }
updateProfileJSON({
params,
credentials: useOAuthStore().token,
})
},
},
persist: {
afterLoad(state) {
console.debug('Validating persisted state of SyncConfig')
const newState = { ...state }
newState.prefsStorage = newState.prefsStorage || {}
const newEntries = Object.entries(ROOT_CONFIG).map(([path, value]) => {
const definition = ROOT_CONFIG_DEFINITIONS[path]
const finalValue = validateSetting({
path,
value: newState.prefsStorage.simple?.[path],
definition,
throwError: false,
validateObjects: false,
defaultState: ROOT_CONFIG,
})
return finalValue === undefined
? [path, definition.default]
: [path, finalValue]
})
newState.prefsStorage.simple = Object.fromEntries(
newEntries.filter((_) => _),
)
return newState
},
},
})
diff --git a/src/stores/user_highlight.js b/src/stores/user_highlight.js
index b056ca8086..759ebd5097 100644
--- a/src/stores/user_highlight.js
+++ b/src/stores/user_highlight.js
@@ -1,364 +1,349 @@
import {
merge as _merge,
- clamp,
clone,
cloneDeep,
- findLastIndex,
flatten,
- get,
groupBy,
isEqual,
takeRight,
- uniqWith,
} from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
import { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileJSON } from 'src/api/user.js'
import { storage } from 'src/lib/storage.js'
export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically
export const defaultState = {
// do we need to update data on server?
dirty: false,
highlight: {
_journal: [],
},
// raw data
raw: null,
// local cache
cache: null,
}
-export const _moveItemInArray = (array, value, movement) => {
- const oldIndex = array.indexOf(value)
- const newIndex = oldIndex + movement
- const newArray = [...array]
- // remove old
- newArray.splice(oldIndex, 1)
- // add new
- newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
- return newArray
-}
-
const _wrapData = (data, userName) => {
return {
...data,
_user: userName,
_timestamp: Date.now(),
}
}
const _checkValidity = (data) => data._timestamp > 0
const _verifyHighlights = (state) => {
state.highlight = state.highlight || {}
// Simple
Object.entries(defaultState.highlight).forEach(([k, v]) => {
if (typeof v === 'undefined') return
if (typeof v === 'object') return
console.warn(`User highlight ${k} is invalid type ${typeof v}, unsetting`)
delete state.highlight[k]
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'[HIGHLIGHT] Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'[HIGHLIGHT] Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug(
'[HIGHLIGHT] Both sources have valid data, figuring things out...',
)
if (live._timestamp === cache._timestamp) {
console.debug(
'[HIGHLIGHT] Same timestamp on both sources, source of truth irrelevant',
)
result.recent = cache
result.stale = live
} else {
console.debug(
'[HIGHLIGHT] Different timestamp, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('[HIGHLIGHT] Both sources are invalid, start from scratch')
result.needUpload = true
}
const merge = (a, b) => {
return {
_user: a._user ?? b._user,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
highlight: _merge(cloneDeep(a.highlight), b.highlight),
}
}
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = flatten(
journals.map((j) => (Array.isArray(j) ? j : [])),
).filter(
(entry) =>
Object.hasOwn(entry, 'user') &&
Object.hasOwn(entry, 'operation') &&
Object.hasOwn(entry, 'args') &&
Object.hasOwn(entry, 'timestamp'),
)
const grouped = groupBy(allJournals, 'user')
const trimmedGrouped = Object.entries(grouped).map(([user, journal]) => {
// side effect
journal.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1))
return takeRight(journal)
})
return flatten(trimmedGrouped).sort((a, b) =>
a.timestamp > b.timestamp ? 1 : -1,
)
}
export const _mergeHighlights = (recent, stale) => {
if (!stale) return recent
if (!recent) return stale
const { _journal: recentJournal, ...recentHighlight } = recent
const { _journal: staleJournal } = stale
/** Journal entry format:
* user: user to entry in highlight storage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
*/
const resultHighlight = { ...recentHighlight }
const Journal = _mergeJournal(staleJournal, recentJournal)
Journal.forEach(({ user, operation, args }) => {
if (user.startsWith('_')) {
throw new Error(
`journal contains entry to edit internal (starts with _) field '${user}', something is incorrect here, ignoring.`,
)
}
switch (operation) {
case 'set':
resultHighlight[user] = args[0]
break
case 'unset':
delete resultHighlight[user]
break
default:
return console.error(`Unknown journal operation: '${operation}'`)
}
})
return { ...resultHighlight, _journal: Journal }
}
export const useUserHighlightStore = defineStore('user_highlight', {
state() {
return cloneDeep(defaultState)
},
actions: {
setAndSave({ user, value }) {
this.set({ user, value })
this.pushHighlight()
},
unsetAndSave({ user }) {
this.unset({ user })
this.pushHighlight()
},
get(user) {
const present = this.highlight[user] || {}
return {
user,
type: 'disabled',
color: '#FFFFFF',
...present,
}
},
set({ user, value }) {
if (user.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${user}', ignoring.`,
)
}
const oldValue = this.highlight[user]
const newValue = {
user,
...oldValue,
...value,
}
this.highlight[user] = newValue
this.highlight._journal = [
...this.highlight._journal,
{ operation: 'set', user, args: [newValue], timestamp: Date.now() },
]
this.dirty = true
},
unset({ user, value }) {
if (user.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${user}', ignoring.`,
)
}
delete this.highlight[user]
this.highlight._journal = [
...this.highlight._journal,
{ operation: 'unset', user, args: [], timestamp: Date.now() },
]
this.dirty = true
},
updateCache({ username }) {
this.highlight._journal = _mergeJournal(this.highlight._journal)
this.cache = _wrapData(
{
highlight: toRaw(this.highlight),
},
username,
)
},
clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
},
clearJournals() {
this.highlight._journal = []
this.cache.highlight._journal = []
this.raw.highlight._journal = []
this.pushSyncConfig()
},
async initUserHighlight(userData) {
const live = userData.user_highlight
this.raw = live
let cache = this.cache
if (cache?._user !== userData.fqn) {
console.warn(
'Cache belongs to another user! reinitializing local cache!',
)
cache = null
}
let { recent, stale, needUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
let dirty = false
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
const config = vuexState.config ?? {}
const highlight = config.highlight ?? {}
Object.entries(highlight).forEach(([user, value]) => {
if ((highlight[user]._migrated || 0) < 1) {
dirty = true
needUpload = true
this.set({ user, value: clone(value) })
highlight[user]._migrated = 1
console.debug(
`[HIGHLIGHT] Migrating user ${user}: ${JSON.stringify(value)}`,
)
}
})
storage.setItem('vuex-lz', {
...vuexState,
config: { ...config, highlight },
})
if (recent === null) {
console.debug(
`[HIGHLIGHT] Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
)
recent = _wrapData({
highlight: { ...defaultState.highlight },
})
}
if (!needUpload && recent && stale) {
console.debug('[HIGHLIGHT] Checking if data needs merging...')
// discarding timestamps
const { _timestamp: _0, ...recentData } = recent
const { _timestamp: _2, ...staleData } = stale
dirty = !isEqual(recentData, staleData)
console.debug(
`[HIGHLIGHT] Data ${dirty ? 'needs' : "doesn't need"} merging`,
)
}
let highlights
if (dirty) {
console.debug('[HIGHLIGHT] Merging the data...')
_verifyHighlights(recent)
_verifyHighlights(stale)
highlights = _mergeHighlights(recent.highlight, stale.highlight)
} else {
highlights = recent.highlight
}
recent.highlight = { ...defaultState.highlight, ...highlights }
this.dirty = dirty || needUpload
this.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.highlight = this.cache.highlight
},
pushHighlight({ force = false } = {}) {
const needPush = this.dirty || force
if (!needPush) return
this.updateCache({ username: window.vuex.state.users.currentUser.fqn })
const params = {
pleroma_settings_store: { user_highlight: this.cache },
}
updateProfileJSON({
params,
credentials: useOAuthStore().token,
}).then(({ data: user }) => {
this.initUserHighlight(user)
this.dirty = false
})
},
},
persist: {
afterLoad(state) {
return state
},
},
})
diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
index a9cb3fd274..afd17e56bc 100644
--- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
+++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
@@ -1,214 +1,213 @@
import mastoapidata from '../../../../fixtures/mastoapi.json'
import {
parseLinkHeaderPagination,
- parseNotification,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const makeMockUserMasto = (overrides = {}) => {
return Object.assign(
{
acct: 'hj',
avatar:
'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
avatar_static:
'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
bot: false,
created_at: '2017-12-17T21:54:14.000Z',
display_name: 'whatever whatever whatever witch',
emojis: [],
fields: [],
followers_count: 705,
following_count: 326,
header:
'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
header_static:
'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
id: '1',
locked: false,
note: 'Volatile Internet Weirdo. Name pronounced as Hee Jay. JS and Java dark arts mage, Elixir trainee. I love sampo and lain. Matrix is <span><a data-user="1" href="https://shigusegubu.club/users/hj">@<span>hj</span></a></span>:matrix.heldscal.la Pronouns are whatever. Do not DM me unless it\'s truly private matter and you\'re instance\'s admin or you risk your DM to be reposted publicly.Wish i was Finnish girl.',
pleroma: { confirmation_pending: false, tags: null },
source: { note: '', privacy: 'public', sensitive: false },
statuses_count: 41775,
url: 'https://shigusegubu.club/users/hj',
username: 'hj',
},
overrides,
)
}
const makeMockStatusMasto = (overrides = {}) => {
return Object.assign(
{
account: makeMockUserMasto(),
application: { name: 'Web', website: null },
content:
'<span><a data-user="14660" href="https://pleroma.soykaf.com/users/sampo">@<span>sampo</span></a></span> god i wish i was there',
created_at: '2019-01-17T16:29:23.000Z',
emojis: [],
favourited: false,
favourites_count: 1,
id: '10423476',
in_reply_to_account_id: '14660',
in_reply_to_id: '10423197',
language: null,
media_attachments: [],
mentions: [
{
acct: 'sampo@pleroma.soykaf.com',
id: '14660',
url: 'https://pleroma.soykaf.com/users/sampo',
username: 'sampo',
},
],
muted: false,
reblog: null,
reblogged: false,
reblogs_count: 0,
replies_count: 0,
sensitive: false,
spoiler_text: '',
tags: [],
uri: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
url: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
visibility: 'public',
pleroma: {
local: true,
},
},
overrides,
)
}
const makeMockEmojiMasto = (overrides = [{}]) => {
return [
Object.assign(
{
shortcode: 'image',
static_url: 'https://example.com/image.png',
url: 'https://example.com/image.png',
visible_in_picker: false,
},
overrides[0],
),
Object.assign(
{
shortcode: 'thinking',
static_url: 'https://example.com/think.png',
url: 'https://example.com/think.png',
visible_in_picker: false,
},
overrides[1],
),
]
}
describe('API Entities normalizer', () => {
describe('parseStatus', () => {
describe('Mastoapi preprocessing and converting', () => {
it("doesn't blow up", () => {
const parsed = mastoapidata.map(parseStatus)
expect(parsed.length).to.eq(mastoapidata.length)
})
it('processes repeats correctly', () => {
const post = makeMockStatusMasto({ reblog: null, id: 'deadbeef' })
const repeat = makeMockStatusMasto({ reblog: post, id: 'foobar' })
const parsedPost = parseStatus(post)
const parsedRepeat = parseStatus(repeat)
expect(parsedPost).to.have.property('type', 'status')
expect(parsedRepeat).to.have.property('type', 'retweet')
expect(parsedRepeat).to.have.property('retweeted_status')
expect(parsedRepeat).to.have.nested.property(
'retweeted_status.id',
'deadbeef',
)
})
})
})
// Statuses generally already contain some info regarding users and there's nearly 1:1 mapping, so very little to test
describe('parseUsers (MastoAPI)', () => {
it('sets correct is_local for users depending on their screen_name', () => {
const local = makeMockUserMasto({ acct: 'foo' })
const remote = makeMockUserMasto({ acct: 'foo@bar.baz' })
expect(parseUser(local)).to.have.property('is_local', true)
expect(parseUser(remote)).to.have.property('is_local', false)
})
it('removes html tags from user profile fields', () => {
const user = makeMockUserMasto({
emojis: makeMockEmojiMasto(),
fields: [
{
name: 'user',
value: '<a rel="me" href="https://example.com/@user">@user</a>',
},
],
})
const parsedUser = parseUser(user)
expect(parsedUser).to.have.property('fields_text').to.be.an('array')
const field = parsedUser.fields_text[0]
expect(field).to.have.property('name').that.equal('user')
expect(field).to.have.property('value').that.equal('@user')
})
it('adds hide_follows and hide_followers user settings', () => {
const user = makeMockUserMasto({
pleroma: {
hide_followers: true,
hide_follows: false,
hide_followers_count: false,
hide_follows_count: true,
},
})
expect(parseUser(user)).to.have.property('hide_followers', true)
expect(parseUser(user)).to.have.property('hide_follows', false)
expect(parseUser(user)).to.have.property('hide_followers_count', false)
expect(parseUser(user)).to.have.property('hide_follows_count', true)
})
it('converts IDN to unicode and marks it as internatonal', () => {
const user = makeMockUserMasto({ acct: 'lain@xn--lin-6cd.com' })
expect(parseUser(user))
.to.have.property('screen_name_ui')
.that.equal('lain@lаin.com')
expect(parseUser(user))
.to.have.property('screen_name_ui_contains_non_ascii')
.that.equal(true)
})
})
describe('Link header pagination', () => {
it('Parses min and max ids as integers', () => {
const linkHeader =
'<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader)
expect(result).to.eql({
maxId: 861676,
minId: 861741,
})
})
it('Parses min and max ids as flakes', () => {
const linkHeader =
'<http://example.com/api/v1/timelines/home?max_id=9waQx5IIS48qVue2Ai>; rel="next", <http://example.com/api/v1/timelines/home?min_id=9wi61nIPnfn674xgie>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader, { flakeId: true })
expect(result).to.eql({
maxId: '9waQx5IIS48qVue2Ai',
minId: '9wi61nIPnfn674xgie',
})
})
})
})
diff --git a/test/unit/specs/stores/lists.spec.js b/test/unit/specs/stores/lists.spec.js
index c71cce5554..3720aa7765 100644
--- a/test/unit/specs/stores/lists.spec.js
+++ b/test/unit/specs/stores/lists.spec.js
@@ -1,139 +1,139 @@
import { createTestingPinia } from '@pinia/testing'
import { HttpResponse, http } from 'msw'
import { setActivePinia } from 'pinia'
import { test as it } from '/test/fixtures/mock_api.js'
import { useListsStore } from 'src/stores/lists.js'
import { MASTODON_LIST_ACCOUNTS_URL, MASTODON_LIST_URL } from 'src/api/user.js'
describe('The lists store', () => {
let store
beforeEach(() => {
- createTestingPinia({ stubActions: false })
+ setActivePinia(createTestingPinia({ stubActions: false }))
store = useListsStore()
})
describe('actions', () => {
it('updates array of all lists', () => {
const list = { id: '1', title: 'testList' }
store.setLists([list])
expect(store.allLists).to.have.length(1)
expect(store.allLists).to.eql([list])
})
it('adds a new list with a title, updating the title for existing lists', async ({
worker,
}) => {
const list = { id: '1', title: 'testList' }
const modList = { id: '1', title: 'anotherTestTitle' }
worker.use(
http.put(MASTODON_LIST_URL(':id'), () =>
HttpResponse.json({ ok: true }),
),
)
console.log('1 =========', worker.listHandlers())
await store.setList({ listId: list.id, title: list.title })
expect(store.allListsObject[list.id]).to.eql({
title: list.title,
accountIds: [],
})
expect(store.allLists).to.have.length(1)
expect(store.allLists[0]).to.eql(list)
console.log('2 =========', worker.listHandlers())
await store.setList({ listId: modList.id, title: modList.title })
expect(store.allListsObject[modList.id]).to.eql({
title: modList.title,
accountIds: [],
})
expect(store.allLists).to.have.length(1)
expect(store.allLists[0]).to.eql(modList)
console.log('3 =========', worker.listHandlers())
})
it('adds a new list with an array of IDs, updating the IDs for existing lists', async ({
worker,
}) => {
const list = { id: '1', accountIds: ['1', '2', '3'] }
const modList = { id: '1', accountIds: ['3', '4', '5'] }
worker.use(
http.post(MASTODON_LIST_ACCOUNTS_URL(':id'), () =>
HttpResponse.json({ ok: true }),
),
http.delete(MASTODON_LIST_ACCOUNTS_URL(':id'), () =>
HttpResponse.json({ ok: true }),
),
)
await store.setListAccounts({
listId: list.id,
accountIds: list.accountIds,
})
expect(store.allListsObject[list.id].accountIds).to.eql(list.accountIds)
await store.setListAccounts({
listId: modList.id,
accountIds: modList.accountIds,
})
expect(store.allListsObject[modList.id].accountIds).to.eql(
modList.accountIds,
)
})
it('deletes a list', async ({ worker }) => {
store.$patch({
allLists: [{ id: '1', title: 'testList' }],
allListsObject: {
1: { title: 'testList', accountIds: ['1', '2', '3'] },
},
})
const listId = '1'
worker.use(
http.delete(MASTODON_LIST_URL(':id'), () =>
HttpResponse.json({ ok: true }),
),
)
await store.deleteList({ listId })
expect(store.allLists).to.have.length(0)
expect(store.allListsObject).to.eql({})
})
})
describe('getters', () => {
it('returns list title', () => {
store.$patch({
allLists: [{ id: '1', title: 'testList' }],
allListsObject: {
1: { title: 'testList', accountIds: ['1', '2', '3'] },
},
})
const id = '1'
expect(store.findListTitle(id)).to.eql('testList')
})
it('returns list accounts', () => {
store.$patch({
allLists: [{ id: '1', title: 'testList' }],
allListsObject: {
1: { title: 'testList', accountIds: ['1', '2', '3'] },
},
})
const id = '1'
expect(store.findListAccounts(id)).to.eql(['1', '2', '3'])
})
})
})
diff --git a/test/unit/specs/stores/oauth.spec.js b/test/unit/specs/stores/oauth.spec.js
index 828175c9f7..6e61fea79b 100644
--- a/test/unit/specs/stores/oauth.spec.js
+++ b/test/unit/specs/stores/oauth.spec.js
@@ -1,267 +1,265 @@
import { createTestingPinia } from '@pinia/testing'
import { HttpResponse, http } from 'msw'
import { setActivePinia } from 'pinia'
import { test as it } from '/test/fixtures/mock_api.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
MASTODON_APP_URL,
MASTODON_APP_VERIFY_URL,
- OAUTH_MFA_CHALLENGE_URL,
- OAUTH_REVOKE_URL,
OAUTH_TOKEN_URL,
} from 'src/api/oauth.js'
const authApis = () => [
http.post(MASTODON_APP_URL, () => {
return HttpResponse.json({
client_id: 'test-id',
client_secret: 'test-secret',
})
}),
http.get(MASTODON_APP_VERIFY_URL, ({ request }) => {
const authHeader = request.headers.get('Authorization')
if (
authHeader === 'Bearer test-app-token' ||
authHeader === 'Bearer also-good-app-token'
) {
return HttpResponse.json({})
} else {
// Pleroma 2.9.0 gives the following respoonse upon error
return HttpResponse.json(
{ error: { detail: 'Internal server error' } },
{
status: 400,
},
)
}
}),
http.post(OAUTH_TOKEN_URL, async ({ request }) => {
const data = await request.formData()
if (
data.get('client_id') === 'test-id' &&
data.get('client_secret') === 'test-secret' &&
data.get('grant_type') === 'client_credentials' &&
data.has('redirect_uri')
) {
return HttpResponse.json({ access_token: 'test-app-token' })
} else {
// Pleroma 2.9.0 gives the following respoonse upon error
return HttpResponse.json(
{ error: 'Invalid credentials' },
{
status: 400,
},
)
}
}),
]
describe('oauth store', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('createApp', () => {
it('should use create an app and record client id and secret', async ({
worker,
}) => {
worker.use(
http.post(MASTODON_APP_URL, () => {
return HttpResponse.text('Throttled', { status: 429 })
}),
)
const store = useOAuthStore()
worker.use(...authApis())
const app = await store.createApp()
expect(store.clientId).to.eql('test-id')
expect(store.clientSecret).to.eql('test-secret')
expect(app.clientId).to.eql('test-id')
expect(app.clientSecret).to.eql('test-secret')
})
it('should throw and not update if failed', async ({ worker }) => {
worker.use(
http.post(MASTODON_APP_URL, () => {
return HttpResponse.text('Throttled', { status: 429 })
}),
)
const store = useOAuthStore()
const res = store.createApp()
await expect(res).rejects.toThrowError('Throttled')
expect(store.clientId).to.eql(false)
expect(store.clientSecret).to.eql(false)
})
})
describe('ensureApp', () => {
it('should create an app if it does not exist', async ({ worker }) => {
worker.use(...authApis())
const store = useOAuthStore()
const app = await store.ensureApp()
expect(store.clientId).to.eql('test-id')
expect(store.clientSecret).to.eql('test-secret')
expect(app.clientId).to.eql('test-id')
expect(app.clientSecret).to.eql('test-secret')
})
it('should not create an app if it exists', async ({ worker }) => {
worker.use(
http.post(MASTODON_APP_URL, () => {
return HttpResponse.text('Should not call this API', { status: 400 })
}),
)
const store = useOAuthStore()
store.clientId = 'another-id'
store.clientSecret = 'another-secret'
const app = await store.ensureApp()
expect(store.clientId).to.eql('another-id')
expect(store.clientSecret).to.eql('another-secret')
expect(app.clientId).to.eql('another-id')
expect(app.clientSecret).to.eql('another-secret')
})
})
describe('getAppToken', () => {
it('should get app token and set it in state', async ({ worker }) => {
worker.use(...authApis())
const store = useOAuthStore()
store.clientId = 'test-id'
store.clientSecret = 'test-secret'
const token = await store.getAppToken()
expect(token).to.eql('test-app-token')
expect(store.appToken).to.eql('test-app-token')
})
it('should throw and not set state if it cannot get app token', async ({
worker,
}) => {
worker.use(...authApis())
const store = useOAuthStore()
store.clientId = 'bad-id'
store.clientSecret = 'bad-secret'
await expect(store.getAppToken()).rejects.toThrowError('400')
expect(store.appToken).to.eql(false)
})
})
describe('ensureAppToken', () => {
it('should work if the state is empty', async ({ worker }) => {
worker.use(...authApis())
console.log('=========', worker.listHandlers())
const store = useOAuthStore()
const token = await store.ensureAppToken()
expect(token).to.eql('test-app-token')
expect(store.appToken).to.eql('test-app-token')
})
it('should work if we already have a working token', async ({ worker }) => {
worker.use(...authApis())
const store = useOAuthStore()
store.appToken = 'also-good-app-token'
const token = await store.ensureAppToken()
expect(token).to.eql('also-good-app-token')
expect(store.appToken).to.eql('also-good-app-token')
})
it('should work if we have a bad token but good app credentials', async ({
worker,
}) => {
worker.use(
...authApis(),
http.post(MASTODON_APP_URL, () => {
return HttpResponse.text('Should not call this API', { status: 400 })
}),
)
const store = useOAuthStore()
store.appToken = 'bad-app-token'
store.clientId = 'test-id'
store.clientSecret = 'test-secret'
const token = await store.ensureAppToken()
expect(token).to.eql('test-app-token')
expect(store.appToken).to.eql('test-app-token')
})
it('should work if we have no token but good app credentials', async ({
worker,
}) => {
worker.use(
...authApis(),
http.post(MASTODON_APP_URL, () => {
return HttpResponse.text('Should not call this API', { status: 400 })
}),
)
const store = useOAuthStore()
store.clientId = 'test-id'
store.clientSecret = 'test-secret'
const token = await store.ensureAppToken()
expect(token).to.eql('test-app-token')
expect(store.appToken).to.eql('test-app-token')
})
it('should work if we have no token and bad app credentials', async ({
worker,
}) => {
worker.use(...authApis())
const store = useOAuthStore()
store.clientId = 'bad-id'
store.clientSecret = 'bad-secret'
const token = await store.ensureAppToken()
expect(token).to.eql('test-app-token')
expect(store.appToken).to.eql('test-app-token')
expect(store.clientId).to.eql('test-id')
expect(store.clientSecret).to.eql('test-secret')
})
it('should work if we have bad token and bad app credentials', async ({
worker,
}) => {
worker.use(...authApis())
const store = useOAuthStore()
store.appToken = 'bad-app-token'
store.clientId = 'bad-id'
store.clientSecret = 'bad-secret'
const token = await store.ensureAppToken()
expect(token).to.eql('test-app-token')
expect(store.appToken).to.eql('test-app-token')
expect(store.clientId).to.eql('test-id')
expect(store.clientSecret).to.eql('test-secret')
})
it('should throw if we cannot create an app', async ({ worker }) => {
worker.use(
http.post(MASTODON_APP_URL, () => {
return HttpResponse.text('Throttled', { status: 429 })
}),
)
const store = useOAuthStore()
await expect(store.ensureAppToken()).rejects.toThrowError('Throttled')
})
it('should throw if we cannot obtain app token', async ({ worker }) => {
worker.use(
http.post(OAUTH_TOKEN_URL, () => {
return HttpResponse.text('Throttled', { status: 429 })
}),
)
const store = useOAuthStore()
await expect(store.getAppToken()).rejects.toThrowError('Throttled')
})
})
})
diff --git a/test/unit/specs/stores/user_highlight.spec.js b/test/unit/specs/stores/user_highlight.spec.js
index c468522560..f80143b6e0 100644
--- a/test/unit/specs/stores/user_highlight.spec.js
+++ b/test/unit/specs/stores/user_highlight.spec.js
@@ -1,316 +1,314 @@
-import { cloneDeep } from 'lodash'
import { createPinia, setActivePinia } from 'pinia'
import {
_getRecentData,
_mergeHighlights,
- _moveItemInArray,
useUserHighlightStore,
} from 'src/stores/user_highlight.js'
describe('The UserHighlight store', () => {
beforeEach(() => {
setActivePinia(createPinia())
window.vuex = {
state: {
users: {
currentUser: {
fqn: 'foo@bar.tld',
},
},
},
}
})
describe('mutations', () => {
describe('initUserHighlight', () => {
const user = {
created_at: new Date('1999-02-09'),
storage: {},
}
it('should initialize storage if none present', async () => {
const store = useUserHighlightStore()
await store.initUserHighlight({ ...user })
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.highlight).to.eql({ _journal: [] })
})
it('should initialize storage for new users if none present', async () => {
const store = useUserHighlightStore()
await store.initUserHighlight({ ...user, created_at: new Date() })
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.highlight).to.eql({ _journal: [] })
})
it('should use remote version if local missing', async () => {
const store = useUserHighlightStore()
await store.initUserHighlight(store, user)
expect(store.cache._timestamp).to.be.a('number')
})
})
describe('set', () => {
it('should set preference and update journal log accordingly', () => {
const store = useUserHighlightStore()
store.set({ user: 'highlight@testing', value: { type: 'test' } })
expect(store.highlight['highlight@testing']).to.eql({
user: 'highlight@testing',
type: 'test',
})
expect(store.highlight._journal.length).to.eql(1)
expect(store.highlight._journal[0]).to.eql({
user: 'highlight@testing',
operation: 'set',
args: [{ user: 'highlight@testing', type: 'test' }],
// should have A timestamp, we don't really care what it is
timestamp: store.highlight._journal[0].timestamp,
})
})
it('should keep journal to a minimum', () => {
const store = useUserHighlightStore()
store.set({ user: 'highlight@testing.xyz', value: { type: 'test' } })
store.set({ user: 'highlight@testing.xyz', value: { type: 'test' } })
store.updateCache({ username: 'test' })
expect(store.highlight['highlight@testing.xyz']).to.eql({
user: 'highlight@testing.xyz',
type: 'test',
})
expect(store.highlight._journal.length).to.eql(1)
expect(store.highlight._journal[0]).to.eql({
user: 'highlight@testing.xyz',
operation: 'set',
args: [
{
user: 'highlight@testing.xyz',
type: 'test',
},
],
// should have A timestamp, we don't really care what it is
timestamp: store.highlight._journal[0].timestamp,
})
})
it('should remove duplicate entries from journal', () => {
const store = useUserHighlightStore()
store.set({ user: 'a@test.xyz', value: { type: 'foo' } })
store.set({ user: 'a@test.xyz', value: { type: 'foo' } })
store.updateCache({ username: 'test' })
expect(store.highlight['a@test.xyz']).to.eql({
user: 'a@test.xyz',
type: 'foo',
})
expect(store.highlight._journal.length).to.eql(1)
})
})
})
describe('helper functions', () => {
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it("doesn't choke on invalid data", () => {
expect(_getRecentData({ a: 1 }, { b: 2 }, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it('should prefer the valid non-null correctly, needUpload works properly', () => {
const nonNull = { _timestamp: 1 }
expect(_getRecentData(nonNull, null, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: true,
})
expect(_getRecentData(null, nonNull, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: false,
})
})
it('should prefer the one with higher timestamp', () => {
const a = { _timestamp: 1 }
const b = { _timestamp: 2 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
it('case where both are same', () => {
const a = { _timestamp: 3 }
const b = { _timestamp: 3 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
})
describe('_mergeHighlights', () => {
it('should prefer recent and apply journal to it', () => {
expect(
_mergeHighlights(
{
// RECENT
'a@test.xyz': 1,
'b@test.xyz': 0,
'c@test.xyz': true,
_journal: [
{
user: 'b@test.xyz',
operation: 'set',
args: [0],
timestamp: 2,
},
{
user: 'c@test.xyz',
operation: 'set',
args: [true],
timestamp: 4,
},
],
},
{
// STALE
'a@test.xyz': 1,
'b@test.xyz': 1,
'c@test.xyz': false,
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [1],
timestamp: 1,
},
{
user: 'b@test.xyz',
operation: 'set',
args: [1],
timestamp: 3,
},
],
},
),
).to.eql({
'a@test.xyz': 1,
'b@test.xyz': 1,
'c@test.xyz': true,
_journal: [
{ user: 'a@test.xyz', operation: 'set', args: [1], timestamp: 1 },
{ user: 'b@test.xyz', operation: 'set', args: [1], timestamp: 3 },
{
user: 'c@test.xyz',
operation: 'set',
args: [true],
timestamp: 4,
},
],
})
})
it('should work with objects', () => {
expect(
_mergeHighlights(
// RECENT
{
'a@test.xyz': { type: 'foo' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'foo' }],
timestamp: 2,
},
],
},
// STALE
{
'a@test.xyz': { type: 'bar' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'bar' }],
timestamp: 4,
},
],
},
),
).to.eql({
'a@test.xyz': { type: 'bar' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'bar' }],
timestamp: 4,
},
],
})
})
it('should work with unset', () => {
expect(
_mergeHighlights(
// RECENT
{
'a@test.xyz': { type: 'foo' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'foo' }],
timestamp: 2,
},
],
},
// STALE
{
_journal: [
{
user: 'a@test.xyz',
operation: 'unset',
args: [],
timestamp: 4,
},
],
},
),
).to.eql({
_journal: [
{
user: 'a@test.xyz',
operation: 'unset',
args: [],
timestamp: 4,
},
],
})
})
})
})
})
diff --git a/vite.config.js b/vite.config.js
index 64859ebb17..beff5fe762 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,254 +1,253 @@
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
-import { DevTools } from '@vitejs/devtools'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vite'
import eslint from 'vite-plugin-eslint2'
import stylelint from 'vite-plugin-stylelint'
import { configDefaults } from 'vitest/config'
import { getCommitHash } from './build/commit_hash.js'
import copyPlugin from './build/copy_plugin.js'
import emojisPlugin from './build/emojis_plugin.js'
import mswPlugin from './build/msw_plugin.js'
import { buildSwPlugin, swMessagesPlugin } from './build/sw_plugin.js'
const localConfigPath = '<projectRoot>/config/local.json'
const normalizeTarget = (target) => {
if (!target || typeof target !== 'string') return target
return target.endsWith('/') ? target.replace(/\/$/, '') : target
}
const getLocalDevSettings = async () => {
const envTarget = normalizeTarget(process.env.VITE_PROXY_TARGET)
const envOrigin = normalizeTarget(process.env.VITE_PROXY_ORIGIN)
try {
const settings = (await import('./config/local.json')).default
settings.target = normalizeTarget(settings.target)
settings.origin = normalizeTarget(settings.origin)
if (envTarget) settings.target = envTarget
if (envOrigin) settings.origin = envOrigin
console.info(`Using local dev server settings (${localConfigPath}):`)
console.info(JSON.stringify(settings, null, 2))
return settings
} catch (e) {
if (!envTarget && !envOrigin) {
console.info(
`Local dev server settings not found (${localConfigPath}), using default`,
e,
)
return {}
}
const settings = { target: envTarget, origin: envOrigin }
console.info(
'Using dev server settings from VITE_PROXY_TARGET/VITE_PROXY_ORIGIN:',
)
console.info(JSON.stringify(settings, null, 2))
return settings
}
}
const projectRoot = dirname(fileURLToPath(import.meta.url))
const getTransformSWSettings = (settings) => {
if ('transformSW' in settings) {
return settings.transformSW
} else {
console.info(
'`transformSW` is not present in your local settings.\n' +
'This option controls whether the service worker should be bundled and transformed into iife (immediately-invoked function expression) during development.\n' +
'If set to false, the service worker will be served as-is, as an ES Module.\n' +
'Some browsers (e.g. Firefox) does not support ESM service workers.\n' +
'To avoid surprises, it is defaulted to true, but this can be slow.\n' +
'If you are using a browser that supports ESM service workers, you can set this option to false.\n' +
`No matter your choice, you can set the transformSW option in ${localConfigPath} in to disable this message.`,
)
return true
}
}
export default defineConfig(async ({ mode, command }) => {
const settings = await getLocalDevSettings()
const target = settings.target || 'http://localhost:4000/'
const origin = settings.origin || target
const targetSW = target.replace(/^http/, 'ws')
const transformSW = getTransformSWSettings(settings)
const proxy = {
'/api': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost',
ws: true,
rewriteWsOrigin: true,
},
'/auth': {
// Mastodon password reset lives here
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost',
ws: true,
},
'/nodeinfo': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost',
},
'/instance': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost',
},
'/oauth': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost',
},
'/socket': {
target: targetSW,
changeOrigin: true,
cookieDomainRewrite: 'localhost',
rewriteWsOrigin: true,
ws: true,
headers: {
Origin: origin,
},
},
}
const swSrc = 'src/sw.js'
const swDest = 'sw-pleroma.js'
const alias = {
src: '/src',
components: '/src/components',
...(mode === 'test' ? { vue: 'vue/dist/vue.esm-bundler.js' } : {}),
}
return {
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement(tag) {
if (tag === 'pinch-zoom') {
return true
}
if (tag.startsWith('cropper-')) {
return true
}
return false
},
},
},
}),
vueJsx(),
buildSwPlugin({ swSrc, swDest }),
swMessagesPlugin(),
emojisPlugin(),
copyPlugin({
inUrl: '/static/ruffle',
inFs: resolve(projectRoot, 'node_modules/@ruffle-rs/ruffle'),
}),
eslint({
lintInWorker: true,
lintOnStart: true,
cacheLocation: resolve(projectRoot, 'node_modules/.cache/eslintcache'),
}),
stylelint({
lintInWorker: true,
lintOnStart: true,
cacheLocation: resolve(
projectRoot,
'node_modules/.cache/stylelintcache',
),
}),
...(mode === 'test' ? [mswPlugin()] : []),
],
css: {
devSourcemap: true,
},
resolve: {
alias,
},
define: {
'process.env': JSON.stringify({
NODE_ENV:
mode === 'test'
? 'testing'
: command === 'serve'
? 'development'
: 'production',
HAS_MODULE_SERVICE_WORKER: command === 'serve' && !transformSW,
}),
COMMIT_HASH: JSON.stringify(
command === 'serve' ? 'DEV' : getCommitHash(),
),
DEV_OVERRIDES: JSON.stringify(command === 'serve' ? settings : undefined),
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
},
// devtools: { enabled: true },
build: {
sourcemap: true,
rolldownOptions: {
input: {
main: 'index.html',
},
output: {
entryFileNames(chunkInfo) {
const id = chunkInfo.facadeModuleId
if (id.endsWith(swSrc)) {
return swDest
} else {
return 'static/js/[name].[hash].js'
}
},
chunkFileNames(chunkInfo) {
if (chunkInfo.facadeModuleId) {
if (
chunkInfo.facadeModuleId.includes(
'node_modules/@kazvmoe-infra/unicode-emoji-json/annotations/',
)
) {
return 'static/js/emoji-annotations/[name].[hash].js'
} else if (chunkInfo.facadeModuleId.includes('src/i18n/')) {
return 'static/js/i18n/[name].[hash].js'
}
}
return 'static/js/[name].[hash].js'
},
assetFileNames(assetInfo) {
const name = assetInfo.names?.[0] || ''
if (/\.(png|jpe?g|gif|svg)(\?.*)?$/.test(name)) {
return 'static/img/[name].[hash][extname]'
} else if (/\.css$/.test(name)) {
return 'static/css/[name].[hash][extname]'
} else {
return 'static/misc/[name].[hash][extname]'
}
},
},
},
},
server: {
...(mode === 'test' ? {} : { proxy }),
port: Number(process.env.PORT) || 8080,
},
preview: {
proxy,
},
test: {
globals: true,
exclude: [...configDefaults.exclude, 'test/e2e-playwright/**'],
browser: {
enabled: true,
headless: true,
provider: playwright(),
// https://github.com/mswjs/msw/issues/2757
instances: [{ browser: 'chromium' }],
},
},
}
})

File Metadata

Mime Type
text/x-diff
Expires
Mon, Oct 12, 7:29 AM (1 d, 22 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1786081
Default Alt Text
(401 KB)

Event Timeline