Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85650351
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
116 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/package.json b/package.json
index 20c01a0b3f..1d0382d468 100644
--- a/package.json
+++ b/package.json
@@ -1,127 +1,127 @@
{
"name": "pleroma_fe",
"version": "2.10.1",
"description": "Pleroma frontend, the default frontend of Pleroma social network server",
"author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/CONTRIBUTORS.md>",
"private": false,
"scripts": {
"dev": "node build/update-emoji.js && vite dev",
"build": "node build/update-emoji.js && vite build",
"unit": "node build/update-emoji.js && vitest --run",
"unit-ci": "node build/update-emoji.js && vitest --run --browser.headless",
"unit:watch": "node build/update-emoji.js && vitest",
"e2e:pw": "playwright test --config test/e2e-playwright/playwright.config.mjs",
"e2e": "sh ./tools/e2e/run.sh",
"test": "yarn run unit && yarn run e2e",
"ci-biome": "yarn exec biome check",
"ci-eslint": "yarn exec eslint",
"ci-stylelint": "yarn exec stylelint '**/*.scss' '**/*.vue'",
"lint": "yarn ci-biome; yarn ci-eslint; yarn ci-stylelint",
- "lint-fix": "yarn exec eslint --fix; yarn exec stylelint '**/*.scss' '**/*.vue' --fix; biome check --write"
+ "lint-fix": "yarn exec eslint -- --fix; yarn exec stylelint '**/*.scss' '**/*.vue' --fix; biome check --write"
},
"dependencies": {
"@babel/runtime": "7.28.4",
"@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "7.1.0",
"@fortawesome/free-regular-svg-icons": "7.1.0",
"@fortawesome/free-solid-svg-icons": "7.1.0",
"@fortawesome/vue-fontawesome": "3.1.2",
"@kazvmoe-infra/pinch-zoom-element": "1.3.0",
"@kazvmoe-infra/unicode-emoji-json": "0.4.0",
"@ruffle-rs/ruffle": "0.1.0-nightly.2025.6.22",
"@vuelidate/core": "2.0.3",
"@vuelidate/validators": "2.0.4",
"@web3-storage/parse-link-header": "^3.1.0",
"body-scroll-lock": "3.1.5",
"chromatism": "3.0.0",
"click-outside-vue3": "4.0.1",
"cropperjs": "2.0.1",
"escape-html": "1.0.3",
"globals": "^16.0.0",
"hash-sum": "^2.0.0",
"js-cookie": "3.0.5",
"localforage": "1.10.0",
"parse-link-header": "2.0.0",
"phoenix": "1.8.1",
"pinia": "^3.0.0",
"punycode.js": "2.3.1",
"qrcode": "1.5.4",
"querystring-es3": "0.2.1",
"url": "0.11.4",
"utf8": "3.0.0",
"uuid": "11.1.0",
"vue": "3.5.22",
"vue-i18n": "11",
"vue-router": "4.6.4",
"vue-virtual-scroller": "^2.0.0-beta.7",
"vuex": "4.1.0"
},
"devDependencies": {
"@babel/core": "7.28.5",
"@babel/eslint-parser": "7.28.5",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@babel/register": "7.28.3",
"@biomejs/biome": "2.3.11",
"@pinia/testing": "1.0.3",
"@ungap/event-target": "0.2.4",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/browser": "^3.0.7",
"@vitest/ui": "^3.0.7",
"@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.5.0",
"@vue/compiler-sfc": "3.5.22",
"@vue/test-utils": "2.4.6",
"autoprefixer": "10.4.21",
"babel-plugin-lodash": "3.3.4",
"chai": "5.3.3",
"chalk": "5.6.2",
"chromedriver": "135.0.4",
"connect-history-api-fallback": "2.0.0",
"cross-spawn": "7.0.6",
"custom-event-polyfill": "1.0.7",
"eslint": "9.39.2",
"eslint-config-standard": "17.1.0",
"eslint-formatter-friendly": "7.0.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.23.1",
"eslint-plugin-promise": "7.2.1",
"eslint-plugin-vue": "10.6.2",
"eventsource-polyfill": "0.9.6",
"express": "5.1.0",
"function-bind": "1.1.2",
"http-proxy-middleware": "3.0.5",
"iso-639-1": "3.1.5",
"lodash": "4.17.21",
"msw": "2.10.5",
"nightwatch": "3.12.2",
"playwright": "1.57.0",
"postcss": "8.5.6",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"sass": "1.93.2",
"selenium-server": "3.141.59",
"semver": "7.7.3",
"serve-static": "2.2.0",
"shelljs": "0.10.0",
"sinon": "20.0.0",
"sinon-chai": "4.0.1",
"stylelint": "16.25.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recommended": "^16.0.0",
"stylelint-config-recommended-scss": "^14.0.0",
"stylelint-config-recommended-vue": "^1.6.0",
"stylelint-config-standard": "38.0.0",
"vite": "^6.1.0",
"vite-plugin-eslint2": "^5.0.3",
"vite-plugin-stylelint": "^6.0.0",
"vitest": "^3.0.7",
"vue-eslint-parser": "10.2.0"
},
"type": "module",
"engines": {
"node": ">= 16.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index b7e97cf458..97f9b8a426 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -1,606 +1,606 @@
/* 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 { config } from '@fortawesome/fontawesome-svg-core'
import {
FontAwesomeIcon,
FontAwesomeLayers,
} from '@fortawesome/vue-fontawesome'
config.autoAddCss = false
import App from '../App.vue'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import FaviconService from '../services/favicon_service/favicon_service.js'
-import { applyConfig } from '../services/style_setter/style_setter.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 { useOAuthStore } from 'src/stores/oauth'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import {
instanceDefaultConfig,
staticOrApiConfigDefault,
} 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({
- name: 'textlimit',
+ path: 'textlimit',
value: textlimit,
})
useInstanceStore().set({
- name: 'accountApprovalRequired',
+ path: 'accountApprovalRequired',
value: data.approval_required,
})
useInstanceStore().set({
- name: 'birthdayRequired',
+ path: 'birthdayRequired',
value: !!data.pleroma?.metadata.birthday_required,
})
useInstanceStore().set({
- name: 'birthdayMinAge',
+ path: 'birthdayMinAge',
value: data.pleroma?.metadata.birthday_min_age || 0,
})
if (vapidPublicKey) {
useInstanceStore().set({
- name: 'vapidPublicKey',
+ 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.')
console.warn(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)
}
const copyInstanceOption = ({ source, destination }) => {
if (typeof config[source] !== 'undefined') {
useInstanceStore().set({ path: destination, value: config[source] })
}
}
Object.keys(staticOrApiConfigDefault)
.map((k) => ({ source: k, destination: `instanceIdentity.${k}` }))
.forEach(copyInstanceOption)
Object.keys(instanceDefaultConfig)
.map((k) => ({ source: k, destination: `prefsStorage.${k}` }))
.forEach(copyInstanceOption)
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({ name: '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 getAppSecret = async ({ store }) => {
const oauth = useOAuthStore()
if (oauth.userToken) {
store.commit(
'setBackendInteractor',
backendInteractorService(oauth.getToken),
)
}
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map((uri) => uri.split('/').pop())
useInstanceStore().set({
- name: 'staffAccounts',
+ 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: '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({
- name: 'backendVersion',
+ path: 'backendVersion',
value: software.version,
})
useInstanceStore().set({
- name: 'backendRepository',
+ path: 'backendRepository',
value: software.repository,
})
const priv = metadata.private
- useInstanceStore().set({ name: 'privateMode', value: priv })
+ useInstanceStore().set({ path: 'privateMode', value: priv })
const frontendVersion = window.___pleromafe_commit_hash
useInstanceStore().set({
- name: 'frontendVersion',
+ 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')
console.warn(e)
}
}
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([
getBackendProvidedConfig({ store }),
getStaticConfig(),
])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
getAppSecret({ store })
await setSettings({ store, apiConfig, staticConfig })
}
const checkOAuthToken = async ({ store }) => {
const oauth = useOAuthStore()
if (oauth.getUserToken) {
return store.dispatch('loginUser', oauth.getUserToken)
}
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())
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({ name: '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)
}
- applyConfig(store.state.config, i18n.global)
+ applyStyleConfig(store.state.config, 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))
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
store.dispatch('loadDrafts')
useAnnouncementsStore().startFetchingAnnouncements()
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)
// remove after vue 3.3
app.config.unwrapInjectedRef = true
app.mount('#app')
return app
}
export default afterStoreSetup
diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.js
similarity index 95%
copy from src/components/timeago/timeago.vue
copy to src/components/timeago/timeago.js
index 60cf347bf6..efb92d4cd7 100644
--- a/src/components/timeago/timeago.vue
+++ b/src/components/timeago/timeago.js
@@ -1,138 +1,127 @@
-<template>
- <time
- :datetime="time"
- :title="localeDateString"
- >
- {{ relativeOrAbsoluteTimeString }}
- </time>
-</template>
-
-<script>
import * as DateUtils from 'src/services/date_utils/date_utils.js'
import localeService from 'src/services/locale/locale.service.js'
export default {
name: 'Timeago',
props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold', 'templateKey'],
data() {
return {
relativeTimeMs: 0,
relativeTime: { key: 'time.now', num: 0 },
interval: null,
}
},
computed: {
shouldUseAbsoluteTimeFormat() {
if (!this.$store.getters.mergedConfig.useAbsoluteTimeFormat) {
return false
}
return (
DateUtils.durationStrToMs(
this.$store.getters.mergedConfig.absoluteTimeFormatMinAge,
) <= this.relativeTimeMs
)
},
time12hFormat() {
return this.$store.getters.mergedConfig.absoluteTimeFormat12h === '12h'
},
browserLocale() {
return localeService.internalToBrowserLocale(this.$i18n.locale)
},
timeAsDate() {
return typeof this.time === 'string'
? new Date(Date.parse(this.time))
: this.time
},
localeDateString() {
return this.timeAsDate.toLocaleString(this.browserLocale)
},
relativeTimeString() {
const timeString = this.$i18n.t(
this.relativeTime.key,
[this.relativeTime.num],
this.relativeTime.num,
)
if (
typeof this.templateKey === 'string' &&
this.relativeTime.key !== 'time.now'
) {
return this.$i18n.t(this.templateKey, [timeString])
}
return timeString
},
absoluteTimeString() {
if (this.longFormat) {
return this.localeDateString
}
const now = new Date()
const formatter = (() => {
if (DateUtils.isSameDay(this.timeAsDate, now)) {
return new Intl.DateTimeFormat(this.browserLocale, {
minute: 'numeric',
hour: 'numeric',
hour12: this.time12hFormat,
})
} else if (DateUtils.isSameMonth(this.timeAsDate, now)) {
return new Intl.DateTimeFormat(this.browserLocale, {
month: 'short',
day: 'numeric',
hour12: this.time12hFormat,
})
} else if (DateUtils.isSameYear(this.timeAsDate, now)) {
return new Intl.DateTimeFormat(this.browserLocale, {
month: 'short',
day: 'numeric',
hour12: this.time12hFormat,
})
} else {
return new Intl.DateTimeFormat(this.browserLocale, {
year: 'numeric',
month: 'short',
hour12: this.time12hFormat,
})
}
})()
return formatter.format(this.timeAsDate)
},
relativeOrAbsoluteTimeString() {
return this.shouldUseAbsoluteTimeFormat
? this.absoluteTimeString
: this.relativeTimeString
},
},
watch: {
time(newVal, oldVal) {
if (oldVal !== newVal) {
clearTimeout(this.interval)
this.refreshRelativeTimeObject()
}
},
},
created() {
this.refreshRelativeTimeObject()
},
unmounted() {
clearTimeout(this.interval)
},
methods: {
refreshRelativeTimeObject() {
const nowThreshold =
typeof this.nowThreshold === 'number' ? this.nowThreshold : 1
this.relativeTimeMs = DateUtils.relativeTimeMs(this.time)
this.relativeTime = this.longFormat
? DateUtils.relativeTime(this.time, nowThreshold)
: DateUtils.relativeTimeShort(this.time, nowThreshold)
if (this.autoUpdate) {
this.interval = setTimeout(
this.refreshRelativeTimeObject,
1000 * this.autoUpdate,
)
}
},
},
}
-</script>
diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue
index 60cf347bf6..eeb00db8b8 100644
--- a/src/components/timeago/timeago.vue
+++ b/src/components/timeago/timeago.vue
@@ -1,138 +1,10 @@
<template>
<time
:datetime="time"
:title="localeDateString"
>
{{ relativeOrAbsoluteTimeString }}
</time>
</template>
-<script>
-import * as DateUtils from 'src/services/date_utils/date_utils.js'
-import localeService from 'src/services/locale/locale.service.js'
-
-export default {
- name: 'Timeago',
- props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold', 'templateKey'],
- data() {
- return {
- relativeTimeMs: 0,
- relativeTime: { key: 'time.now', num: 0 },
- interval: null,
- }
- },
- computed: {
- shouldUseAbsoluteTimeFormat() {
- if (!this.$store.getters.mergedConfig.useAbsoluteTimeFormat) {
- return false
- }
- return (
- DateUtils.durationStrToMs(
- this.$store.getters.mergedConfig.absoluteTimeFormatMinAge,
- ) <= this.relativeTimeMs
- )
- },
- time12hFormat() {
- return this.$store.getters.mergedConfig.absoluteTimeFormat12h === '12h'
- },
- browserLocale() {
- return localeService.internalToBrowserLocale(this.$i18n.locale)
- },
- timeAsDate() {
- return typeof this.time === 'string'
- ? new Date(Date.parse(this.time))
- : this.time
- },
- localeDateString() {
- return this.timeAsDate.toLocaleString(this.browserLocale)
- },
- relativeTimeString() {
- const timeString = this.$i18n.t(
- this.relativeTime.key,
- [this.relativeTime.num],
- this.relativeTime.num,
- )
-
- if (
- typeof this.templateKey === 'string' &&
- this.relativeTime.key !== 'time.now'
- ) {
- return this.$i18n.t(this.templateKey, [timeString])
- }
-
- return timeString
- },
- absoluteTimeString() {
- if (this.longFormat) {
- return this.localeDateString
- }
- const now = new Date()
- const formatter = (() => {
- if (DateUtils.isSameDay(this.timeAsDate, now)) {
- return new Intl.DateTimeFormat(this.browserLocale, {
- minute: 'numeric',
- hour: 'numeric',
- hour12: this.time12hFormat,
- })
- } else if (DateUtils.isSameMonth(this.timeAsDate, now)) {
- return new Intl.DateTimeFormat(this.browserLocale, {
- month: 'short',
- day: 'numeric',
- hour12: this.time12hFormat,
- })
- } else if (DateUtils.isSameYear(this.timeAsDate, now)) {
- return new Intl.DateTimeFormat(this.browserLocale, {
- month: 'short',
- day: 'numeric',
- hour12: this.time12hFormat,
- })
- } else {
- return new Intl.DateTimeFormat(this.browserLocale, {
- year: 'numeric',
- month: 'short',
- hour12: this.time12hFormat,
- })
- }
- })()
-
- return formatter.format(this.timeAsDate)
- },
- relativeOrAbsoluteTimeString() {
- return this.shouldUseAbsoluteTimeFormat
- ? this.absoluteTimeString
- : this.relativeTimeString
- },
- },
- watch: {
- time(newVal, oldVal) {
- if (oldVal !== newVal) {
- clearTimeout(this.interval)
- this.refreshRelativeTimeObject()
- }
- },
- },
- created() {
- this.refreshRelativeTimeObject()
- },
- unmounted() {
- clearTimeout(this.interval)
- },
- methods: {
- refreshRelativeTimeObject() {
- const nowThreshold =
- typeof this.nowThreshold === 'number' ? this.nowThreshold : 1
- this.relativeTimeMs = DateUtils.relativeTimeMs(this.time)
- this.relativeTime = this.longFormat
- ? DateUtils.relativeTime(this.time, nowThreshold)
- : DateUtils.relativeTimeShort(this.time, nowThreshold)
-
- if (this.autoUpdate) {
- this.interval = setTimeout(
- this.refreshRelativeTimeObject,
- 1000 * this.autoUpdate,
- )
- }
- },
- },
-}
-</script>
+<script src="./timeago.js" />
diff --git a/src/modules/config.js b/src/modules/config.js
index 82acf81625..b68d547df0 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -1,200 +1,200 @@
import Cookies from 'js-cookie'
import { set } from 'lodash'
import messages from '../i18n/messages'
import localeService from '../services/locale/locale.service.js'
-import { applyConfig } from '../services/style_setter/style_setter.js'
+import { applyStyleConfig } from '../services/style_setter/style_setter.js'
import { defaultState, instanceDefaultConfig } from './default_config_state.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
const BACKEND_LANGUAGE_COOKIE_NAME = 'userLanguage'
const APPEARANCE_SETTINGS_KEYS = new Set([
'sidebarColumnWidth',
'contentColumnWidth',
'notifsColumnWidth',
'themeEditorMinWidth',
'textSize',
'navbarSize',
'panelHeaderSize',
'forcedRoundness',
'emojiSize',
'emojiReactionsScale',
])
/* TODO this is a bit messy.
* We need to declare settings with their types and also deal with
* instance-default settings in some way, hopefully try to avoid copy-pasta
* in general.
*/
export const multiChoiceProperties = [
'postContentType',
'subjectLineBehavior',
'conversationDisplay', // tree | linear
'conversationOtherRepliesButton', // below | inside
'mentionLinkDisplay', // short | full_for_remote | full
'userPopoverAvatarAction', // close | zoom | open
'unsavedPostAction', // save | discard | confirm
]
// caching the instance default properties
export const instanceDefaultProperties = Object.keys(instanceDefaultConfig)
const config = {
state: { ...defaultState },
getters: {
defaultConfig() {
return {
...defaultState,
...Object.fromEntries(
instanceDefaultProperties.map((key) => [
key,
useInstanceStore().prefsStorage[key],
]),
),
}
},
mergedConfig(state) {
const instancePrefs = useInstanceStore().prefsStorage
const result = Object.fromEntries(
Object.keys(defaultState).map((key) => [
key,
state[key] ?? instancePrefs[key],
]),
)
return result
},
},
mutations: {
setOptionTemporarily(state, { name, value }) {
set(state, name, value)
- applyConfig(state)
+ applyStyleConfig(state)
},
setOption(state, { name, value }) {
set(state, name, value)
},
setHighlight(state, { user, color, type }) {
const data = this.state.config.highlight[user]
if (color || type) {
state.highlight[user] = {
color: color || data.color,
type: type || data.type,
}
} else {
delete state.highlight[user]
}
},
},
actions: {
loadSettings({ dispatch }, data) {
const knownKeys = new Set(Object.keys(defaultState))
const presentKeys = new Set(Object.keys(data))
const intersection = new Set()
for (const elem of presentKeys) {
if (knownKeys.has(elem)) {
intersection.add(elem)
}
}
intersection.forEach((name) =>
dispatch('setOption', { name, value: data[name] }),
)
},
setHighlight({ commit }, { user, color, type }) {
commit('setHighlight', { user, color, type })
},
setOptionTemporarily({ commit, dispatch, state }, { name, value }) {
if (useInterfaceStore().temporaryChangesTimeoutId !== null) {
console.warn("Can't track more than one temporary change")
return
}
const oldValue = state[name]
commit('setOptionTemporarily', { name, value })
const confirm = () => {
dispatch('setOption', { name, value })
useInterfaceStore().clearTemporaryChanges()
}
const revert = () => {
commit('setOptionTemporarily', { name, value: oldValue })
useInterfaceStore().clearTemporaryChanges()
}
useInterfaceStore().setTemporaryChanges({
confirm,
revert,
})
},
setThemeV2({ commit, dispatch }, { customTheme, customThemeSource }) {
commit('setOption', { name: 'theme', value: 'custom' })
commit('setOption', { name: 'customTheme', value: customTheme })
commit('setOption', {
name: 'customThemeSource',
value: customThemeSource,
})
dispatch('setTheme', { themeData: customThemeSource, recompile: true })
},
setOption({ commit, dispatch, state }, { name, value }) {
const exceptions = new Set(['useStreamingApi'])
if (exceptions.has(name)) {
switch (name) {
case 'useStreamingApi': {
const action = value ? 'enableMastoSockets' : 'disableMastoSockets'
dispatch(action)
.then(() => {
commit('setOption', { name: 'useStreamingApi', value })
})
.catch((e) => {
console.error('Failed starting MastoAPI Streaming socket', e)
dispatch('disableMastoSockets')
dispatch('setOption', { name: 'useStreamingApi', value: false })
})
break
}
}
} else {
commit('setOption', { name, value })
if (APPEARANCE_SETTINGS_KEYS.has(name)) {
- applyConfig(state)
+ applyStyleConfig(state)
}
if (name.startsWith('theme3hacks')) {
dispatch('applyTheme', { recompile: true })
}
switch (name) {
case 'theme':
if (value === 'custom') break
dispatch('setTheme', {
themeName: value,
recompile: true,
saveData: true,
})
break
case 'themeDebug': {
dispatch('setTheme', { recompile: true })
break
}
case 'interfaceLanguage':
messages.setLanguage(useI18nStore().i18n, value)
useEmojiStore().loadUnicodeEmojiData(value)
Cookies.set(
BACKEND_LANGUAGE_COOKIE_NAME,
localeService.internalToBackendLocaleMulti(value),
)
break
case 'thirdColumnMode':
useInterfaceStore().setLayoutWidth(undefined)
break
}
}
},
},
}
export default config
diff --git a/src/modules/default_config_state.js b/src/modules/default_config_state.js
index 3e39fb1fcd..2f18fe9a5d 100644
--- a/src/modules/default_config_state.js
+++ b/src/modules/default_config_state.js
@@ -1,228 +1,229 @@
-const browserLocale = (window.navigator.language || 'en').split('-')[0]
+const browserLocale = (navigator.language || 'en').split('-')[0]
/// 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 staticOrApiConfigDefault = {
+ name: 'PleromaFE',
theme: 'pleroma-dark',
palette: null,
style: null,
defaultAvatar: '/images/avi.png',
defaultBanner: '/images/banner.png',
background: '/static/aurora_borealis.jpg',
embeddedToS: true,
logo: '/static/logo.svg',
logoMargin: '.2em',
logoMask: true,
logoLeft: false,
redirectRootLogin: '/main/friends',
redirectRootNoLogin: '/main/all',
hideSitename: false,
nsfwCensorImage: null,
showFeaturesPanel: true,
showInstanceSpecificPanel: false,
// Html stuff
instanceSpecificPanelContent: '',
tos: '',
}
/// 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 instanceDefaultConfig = {
expertLevel: 0, // used to track which settings to show and hide
hideISP: false,
hideInstanceWallpaper: false,
hideShoutbox: false,
// bad name: actually hides posts of muted USERS
hideMutedPosts: false,
hideMutedThreads: true,
hideWordFilteredPosts: false,
muteBotStatuses: false,
muteSensitiveStatuses: false,
collapseMessageWithSubject: false,
padEmoji: true,
hideAttachments: false,
hideAttachmentsInConv: false,
hideScrobbles: false,
hideScrobblesAfter: '2d',
maxThumbnails: 16,
hideNsfw: true,
preloadImage: true,
loopVideo: true,
loopVideoSilentOnly: 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: false,
emojiReactionsOnTimeline: true,
alwaysShowNewPostButton: false,
autohideFloatingPostButton: false,
pauseOnUnfocused: true,
stopGifs: true,
replyVisibility: 'all',
thirdColumnMode: 'notifications',
notificationVisibility: {
follows: true,
mentions: true,
statuses: true,
likes: true,
repeats: true,
moves: true,
emojiReactions: true,
followRequest: true,
reports: true,
chatMention: true,
polls: true,
},
notificationNative: {
follows: true,
mentions: true,
statuses: true,
likes: false,
repeats: false,
moves: false,
emojiReactions: false,
followRequest: true,
reports: true,
chatMention: true,
polls: true,
},
webPushNotifications: false,
webPushAlwaysShowNotifications: false,
interfaceLanguage: browserLocale,
hideScopeNotice: false,
useStreamingApi: false,
sidebarRight: false,
scopeCopy: true,
subjectLineBehavior: 'email',
alwaysShowSubjectInput: true,
postContentType: 'text/plain',
minimalScopesMode: false,
// This hides statuses filtered via a word filter
hideFilteredStatuses: false,
// Confirmations
modalOnRepeat: false,
modalOnUnfollow: false,
modalOnBlock: true,
modalOnMute: false,
modalOnMuteConversation: false,
modalOnMuteDomain: true,
modalOnDelete: true,
modalOnLogout: true,
modalOnApproveFollow: false,
modalOnDenyFollow: false,
modalOnRemoveUserFromFollowers: false,
// Expiry confirmations/default actions
onMuteDefaultAction: 'ask',
onBlockDefaultAction: 'ask',
modalMobileCenter: false,
playVideosInModal: false,
useOneClickNsfw: false,
useContainFit: true,
disableStickyHeaders: false,
showScrollbars: false,
userPopoverAvatarAction: 'open',
userPopoverOverlay: false,
userCardLeftJustify: false,
userCardHidePersonalMarks: false,
sidebarColumnWidth: '25rem',
contentColumnWidth: '45rem',
notifsColumnWidth: '25rem',
themeEditorMinWidth: '0rem',
emojiReactionsScale: 0.5,
textSize: '1rem',
emojiSize: '2.2rem',
navbarSize: '3.5rem',
panelHeaderSize: '3.2rem',
forcedRoundness: -1,
navbarColumnStretch: false,
greentext: false,
mentionLinkDisplay: 'short',
mentionLinkShowTooltip: true,
mentionLinkShowAvatar: false,
mentionLinkFadeDomain: true,
mentionLinkShowYous: false,
mentionLinkBoldenYou: true,
hidePostStats: false,
hideBotIndication: false,
hideUserStats: false,
virtualScrolling: true,
sensitiveByDefault: false,
conversationDisplay: 'linear',
conversationTreeAdvanced: false,
conversationOtherRepliesButton: 'below',
conversationTreeFadeAncestors: false,
showExtraNotifications: true,
showExtraNotificationsTip: true,
showChatsInExtraNotifications: true,
showAnnouncementsInExtraNotifications: true,
showFollowRequestsInExtraNotifications: true,
maxDepthInThread: 6,
autocompleteSelect: false,
closingDrawerMarksAsSeen: true,
unseenAtTop: false,
ignoreInactionableSeen: false,
unsavedPostAction: 'confirm',
autoSaveDraft: false,
useAbsoluteTimeFormat: false,
absoluteTimeFormatMinAge: '0d',
absoluteTime12h: '24h',
imageCompression: true,
alwaysUseJpeg: false,
}
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 defaultState = {
// Set these to undefined so it does not interfere with default settings check
...makeUndefined(instanceDefaultConfig),
// Special processing
// Theme stuff
theme: undefined, // Very old theme store, stores preset name, still in use
// V1
colors: {}, // VERY old theme store, just colors of V1, probably not even used anymore
// V2
customTheme: undefined, // "snapshot", previously was used as actual theme store for V2 so it's still used in case of PleromaFE downgrade event.
customThemeSource: undefined, // "source", stores original theme data
// V3
style: null,
styleCustomData: null,
palette: null,
paletteCustomData: null,
themeDebug: false, // debug mode that uses computed backgrounds instead of real ones to debug contrast functions
forceThemeRecompilation: false, // flag that forces recompilation on boot even if cache exists
theme3hacks: {
// Hacks, user overrides that are independent of theme used
underlay: 'none',
fonts: {
interface: undefined,
input: undefined,
post: undefined,
monospace: undefined,
},
},
// Special handling: These fields are not of a primitive type, and
// might cause problems with current code because it specifically checks
// them in state.config (not getters.mergedConfig).
// Specifically, muteWords is now deprecated in favour of a server-side configuration.
muteWords: [],
highlight: {},
// If there are any configurations that does not make sense to
// have instance-wide default, put it here and explain why.
}
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 90886b4c52..48f8b9e8ab 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,2353 +1,2351 @@
import { concat, each, get, last, map } from 'lodash'
import {
parseAttachment,
parseChat,
parseLinkHeaderPagination,
parseNotification,
parseSource,
parseStatus,
parseUser,
} from '../entity_normalizer/entity_normalizer.service.js'
import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */
const MUTES_IMPORT_URL = '/api/pleroma/mutes_import'
const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import'
const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
const CHANGE_EMAIL_URL = '/api/pleroma/change_email'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
const MOVE_ACCOUNT_URL = '/api/pleroma/move_account'
const ALIASES_URL = '/api/pleroma/aliases'
const TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PERMISSION_GROUP_URL = (screenName, right) =>
`/api/pleroma/admin/users/${screenName}/permission_group/${right}`
const ACTIVATE_USER_URL = '/api/pleroma/admin/users/activate'
const DEACTIVATE_USER_URL = '/api/pleroma/admin/users/deactivate'
const ADMIN_USERS_URL = '/api/v1/pleroma/admin/users'
const SUGGESTIONS_URL = '/api/v1/suggestions'
const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa'
const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes'
const MFA_SETUP_OTP_URL = '/api/pleroma/accounts/mfa/setup/totp'
const MFA_CONFIRM_OTP_URL = '/api/pleroma/accounts/mfa/confirm/totp'
const MFA_DISABLE_OTP_URL = '/api/pleroma/accounts/mfa/totp'
const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
const MASTODON_REGISTRATION_URL = '/api/v1/accounts'
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications'
const MASTODON_DISMISS_NOTIFICATION_URL = (id) =>
`/api/v1/notifications/${id}/dismiss`
const MASTODON_FAVORITE_URL = (id) => `/api/v1/statuses/${id}/favourite`
const MASTODON_UNFAVORITE_URL = (id) => `/api/v1/statuses/${id}/unfavourite`
const MASTODON_RETWEET_URL = (id) => `/api/v1/statuses/${id}/reblog`
const MASTODON_UNRETWEET_URL = (id) => `/api/v1/statuses/${id}/unreblog`
const MASTODON_DELETE_URL = (id) => `/api/v1/statuses/${id}`
const MASTODON_FOLLOW_URL = (id) => `/api/v1/accounts/${id}/follow`
const MASTODON_UNFOLLOW_URL = (id) => `/api/v1/accounts/${id}/unfollow`
const MASTODON_FOLLOWING_URL = (id) => `/api/v1/accounts/${id}/following`
const MASTODON_FOLLOWERS_URL = (id) => `/api/v1/accounts/${id}/followers`
const MASTODON_FOLLOW_REQUESTS_URL = '/api/v1/follow_requests'
const MASTODON_APPROVE_USER_URL = (id) =>
`/api/v1/follow_requests/${id}/authorize`
const MASTODON_DENY_USER_URL = (id) => `/api/v1/follow_requests/${id}/reject`
const MASTODON_DIRECT_MESSAGES_TIMELINE_URL = '/api/v1/timelines/direct'
const MASTODON_PUBLIC_TIMELINE = '/api/v1/timelines/public'
const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home'
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 = '/api/v1/accounts/lookup'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = (id) => `/api/v1/accounts/${id}/statuses`
const MASTODON_USER_IN_LISTS = (id) => `/api/v1/accounts/${id}/lists`
const MASTODON_LIST_URL = (id) => `/api/v1/lists/${id}`
const MASTODON_LIST_TIMELINE_URL = (id) => `/api/v1/timelines/list/${id}`
const MASTODON_LIST_ACCOUNTS_URL = (id) => `/api/v1/lists/${id}/accounts`
const MASTODON_TAG_TIMELINE_URL = (tag) => `/api/v1/timelines/tag/${tag}`
const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks'
const AKKOMA_BUBBLE_TIMELINE_URL = '/api/v1/timelines/bubble'
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
const MASTODON_USER_MUTES_URL = '/api/v1/mutes/'
const MASTODON_BLOCK_USER_URL = (id) => `/api/v1/accounts/${id}/block`
const MASTODON_UNBLOCK_USER_URL = (id) => `/api/v1/accounts/${id}/unblock`
const MASTODON_MUTE_USER_URL = (id) => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = (id) => `/api/v1/accounts/${id}/unmute`
const MASTODON_REMOVE_USER_FROM_FOLLOWERS = (id) =>
`/api/v1/accounts/${id}/remove_from_followers`
const MASTODON_USER_NOTE_URL = (id) => `/api/v1/accounts/${id}/note`
const MASTODON_BOOKMARK_STATUS_URL = (id) => `/api/v1/statuses/${id}/bookmark`
const MASTODON_UNBOOKMARK_STATUS_URL = (id) =>
`/api/v1/statuses/${id}/unbookmark`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = (id) => `/api/v1/polls/${id}/votes`
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_PROFILE_UPDATE_URL = '/api/v1/accounts/update_credentials'
const MASTODON_REPORT_USER_URL = '/api/v1/reports'
const MASTODON_PIN_OWN_STATUS = (id) => `/api/v1/statuses/${id}/pin`
const MASTODON_UNPIN_OWN_STATUS = (id) => `/api/v1/statuses/${id}/unpin`
const MASTODON_MUTE_CONVERSATION = (id) => `/api/v1/statuses/${id}/mute`
const MASTODON_UNMUTE_CONVERSATION = (id) => `/api/v1/statuses/${id}/unmute`
const MASTODON_SEARCH_2 = '/api/v2/search'
const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search'
const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
const MASTODON_LISTS_URL = '/api/v1/lists'
const MASTODON_STREAMING = '/api/v1/streaming'
const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const MASTODON_ANNOUNCEMENTS_URL = '/api/v1/announcements'
const MASTODON_ANNOUNCEMENTS_DISMISS_URL = (id) =>
`/api/v1/announcements/${id}/dismiss`
const PLEROMA_EMOJI_REACTIONS_URL = (id) =>
`/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) =>
`/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) =>
`/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats'
const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}`
const PLEROMA_CHAT_MESSAGES_URL = (id) => `/api/v1/pleroma/chats/${id}/messages`
const PLEROMA_CHAT_READ_URL = (id) => `/api/v1/pleroma/chats/${id}/read`
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) =>
`/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
const PLEROMA_ADMIN_REPORTS = '/api/v1/pleroma/admin/reports'
const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups'
const PLEROMA_ANNOUNCEMENTS_URL = '/api/v1/pleroma/admin/announcements'
const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
const PLEROMA_EDIT_ANNOUNCEMENT_URL = (id) =>
`/api/v1/pleroma/admin/announcements/${id}`
const PLEROMA_DELETE_ANNOUNCEMENT_URL = (id) =>
`/api/v1/pleroma/admin/announcements/${id}`
const PLEROMA_SCROBBLES_URL = (id) => `/api/v1/pleroma/accounts/${id}/scrobbles`
const PLEROMA_STATUS_QUOTES_URL = (id) =>
`/api/v1/pleroma/statuses/${id}/quotes`
const PLEROMA_USER_FAVORITES_TIMELINE_URL = (id) =>
`/api/v1/pleroma/accounts/${id}/favourites`
const PLEROMA_BOOKMARK_FOLDERS_URL = '/api/v1/pleroma/bookmark_folders'
const PLEROMA_BOOKMARK_FOLDER_URL = (id) =>
`/api/v1/pleroma/bookmark_folders/${id}`
const PLEROMA_ADMIN_CONFIG_URL = '/api/v1/pleroma/admin/config'
const PLEROMA_ADMIN_DESCRIPTIONS_URL =
'/api/v1/pleroma/admin/config/descriptions'
const PLEROMA_ADMIN_FRONTENDS_URL = '/api/v1/pleroma/admin/frontends'
const PLEROMA_ADMIN_FRONTENDS_INSTALL_URL =
'/api/v1/pleroma/admin/frontends/install'
const PLEROMA_EMOJI_RELOAD_URL = '/api/pleroma/admin/reload_emoji'
const PLEROMA_EMOJI_IMPORT_FS_URL = '/api/pleroma/emoji/packs/import'
const PLEROMA_EMOJI_PACKS_URL = (page, pageSize) =>
`/api/v1/pleroma/emoji/packs?page=${page}&page_size=${pageSize}`
const PLEROMA_EMOJI_PACK_URL = (name) =>
`/api/v1/pleroma/emoji/pack?name=${name}`
const PLEROMA_EMOJI_PACKS_DL_REMOTE_URL = '/api/v1/pleroma/emoji/packs/download'
const PLEROMA_EMOJI_PACKS_DL_REMOTE_ZIP_URL =
'/api/v1/pleroma/emoji/packs/download_zip'
const PLEROMA_EMOJI_PACKS_LS_REMOTE_URL = (url, page, pageSize) =>
`/api/v1/pleroma/emoji/packs/remote?url=${url}&page=${page}&page_size=${pageSize}`
const PLEROMA_EMOJI_UPDATE_FILE_URL = (name) =>
`/api/v1/pleroma/emoji/packs/files?name=${name}`
const oldfetch = window.fetch
const fetch = (url, options) => {
options = options || {}
const baseUrl = ''
const fullUrl = baseUrl + url
options.credentials = 'same-origin'
return oldfetch(fullUrl, options)
}
const promisedRequest = ({
method,
url,
params,
payload,
credentials,
headers = {},
}) => {
const options = {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...headers,
},
}
if (params) {
url +=
'?' +
Object.entries(params)
.map(
([key, value]) =>
encodeURIComponent(key) + '=' + encodeURIComponent(value),
)
.join('&')
}
if (payload) {
options.body = JSON.stringify(payload)
}
if (credentials) {
options.headers = {
...options.headers,
...authHeaders(credentials),
}
}
return fetch(url, options).then((response) => {
return new Promise((resolve, reject) =>
response
.json()
.then((json) => {
if (!response.ok) {
return reject(
new StatusCodeError(
response.status,
json,
{ url, options },
response,
),
)
}
return resolve(json)
})
.catch((error) => {
return reject(
new StatusCodeError(
response.status,
error,
{ url, options },
response,
),
)
}),
)
})
}
const updateNotificationSettings = ({ credentials, settings }) => {
const form = new FormData()
each(settings, (value, key) => {
form.append(key, value)
})
return fetch(
`${NOTIFICATION_SETTINGS_URL}?${new URLSearchParams(settings)}`,
{
headers: authHeaders(credentials),
method: 'PUT',
body: form,
},
).then((data) => data.json())
}
const updateProfileImages = ({
credentials,
avatar = null,
avatarName = null,
banner = null,
background = null,
}) => {
const form = new FormData()
if (avatar !== null) {
if (avatarName !== null) {
form.append('avatar', avatar, avatarName)
} else {
form.append('avatar', avatar)
}
}
if (banner !== null) form.append('header', banner)
if (background !== null) form.append('pleroma_background_image', background)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form,
})
.then((data) => data.json())
.then((data) => {
if (data.error) {
throw new Error(data.error)
}
return parseUser(data)
})
}
const updateProfile = ({ credentials, params }) => {
const formData = new FormData()
for (const name in params) {
if (name === 'fields_attributes') {
params[name].forEach((param, i) => {
formData.append(name + `[${i}][name]`, param.name)
formData.append(name + `[${i}][value]`, param.value)
})
} else {
if (typeof params[name] === 'object') {
console.warn(
'Object detected in updateProfile API call. This will not work, use updateProfileJSON instead.',
)
console.warn('Object:\n' + JSON.stringify(params[name], null, 2))
}
formData.append(name, params[name])
}
}
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: formData,
})
.then((data) => data.json())
.then((data) => parseUser(data))
}
const updateProfileJSON = ({ credentials, params }) => {
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
credentials,
payload: params,
method: 'PATCH',
- })
- .then((data) => data.json())
- .then((data) => parseUser(data))
+ }).then((data) => parseUser(data))
}
// Params needed:
// nickname
// email
// fullname
// password
// password_confirm
//
// Optional
// bio
// homepage
// location
// token
// language
const register = ({ params, credentials }) => {
const { nickname, ...rest } = params
return fetch(MASTODON_REGISTRATION_URL, {
method: 'POST',
headers: {
...authHeaders(credentials),
'Content-Type': 'application/json',
},
body: JSON.stringify({
nickname,
locale: 'en_US',
agreement: true,
...rest,
}),
}).then((response) => {
if (response.ok) {
return response.json()
} else {
return response.json().then((error) => {
throw new RegistrationError(error)
})
}
})
}
const getCaptcha = () =>
fetch('/api/pleroma/captcha').then((resp) => resp.json())
const authHeaders = (accessToken) => {
if (accessToken) {
return { Authorization: `Bearer ${accessToken}` }
} else {
return {}
}
}
const followUser = ({ id, credentials, ...options }) => {
const url = MASTODON_FOLLOW_URL(id)
const form = {}
if (options.reblogs !== undefined) {
form.reblogs = options.reblogs
}
if (options.notify !== undefined) {
form.notify = options.notify
}
return fetch(url, {
body: JSON.stringify(form),
headers: {
...authHeaders(credentials),
'Content-Type': 'application/json',
},
method: 'POST',
}).then((data) => data.json())
}
const unfollowUser = ({ id, credentials }) => {
const url = MASTODON_UNFOLLOW_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST',
}).then((data) => data.json())
}
const fetchUserInLists = ({ id, credentials }) => {
const url = MASTODON_USER_IN_LISTS(id)
return fetch(url, {
headers: authHeaders(credentials),
}).then((data) => data.json())
}
const pinOwnStatus = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_PIN_OWN_STATUS(id),
credentials,
method: 'POST',
}).then((data) => parseStatus(data))
}
const unpinOwnStatus = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_UNPIN_OWN_STATUS(id),
credentials,
method: 'POST',
}).then((data) => parseStatus(data))
}
const muteConversation = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_MUTE_CONVERSATION(id),
credentials,
method: 'POST',
}).then((data) => parseStatus(data))
}
const unmuteConversation = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_UNMUTE_CONVERSATION(id),
credentials,
method: 'POST',
}).then((data) => parseStatus(data))
}
const blockUser = ({ id, expiresIn, credentials }) => {
const payload = {}
if (expiresIn) {
payload.duration = expiresIn
}
return promisedRequest({
url: MASTODON_BLOCK_USER_URL(id),
credentials,
method: 'POST',
payload,
})
}
const unblockUser = ({ id, credentials }) => {
return fetch(MASTODON_UNBLOCK_USER_URL(id), {
headers: authHeaders(credentials),
method: 'POST',
}).then((data) => data.json())
}
const removeUserFromFollowers = ({ id, credentials }) => {
return fetch(MASTODON_REMOVE_USER_FROM_FOLLOWERS(id), {
headers: authHeaders(credentials),
method: 'POST',
}).then((data) => data.json())
}
const editUserNote = ({ id, credentials, comment }) => {
return promisedRequest({
url: MASTODON_USER_NOTE_URL(id),
credentials,
payload: {
comment,
},
method: 'POST',
})
}
const approveUser = ({ id, credentials }) => {
const url = MASTODON_APPROVE_USER_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST',
}).then((data) => data.json())
}
const denyUser = ({ id, credentials }) => {
const url = MASTODON_DENY_USER_URL(id)
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST',
}).then((data) => data.json())
}
const fetchUser = ({ id, credentials }) => {
const url = `${MASTODON_USER_URL}/${id}`
return promisedRequest({ url, credentials }).then((data) => parseUser(data))
}
const fetchUserByName = ({ name, credentials }) => {
return promisedRequest({
url: MASTODON_USER_LOOKUP_URL,
credentials,
params: { acct: name },
})
.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 }))
}
const fetchUserRelationship = ({ id, credentials }) => {
const url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}`
return fetch(url, { headers: authHeaders(credentials) }).then((response) => {
return new Promise((resolve, reject) =>
response.json().then((json) => {
if (!response.ok) {
return reject(
new StatusCodeError(response.status, json, { url }, response),
)
}
return resolve(json)
}),
)
})
}
const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => {
let url = MASTODON_FOLLOWING_URL(id)
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`,
'with_relationships=true',
]
.filter((_) => _)
.join('&')
url = url + (args ? '?' + args : '')
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(parseUser))
}
const exportFriends = ({ id, credentials }) => {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO refactor this
return new Promise(async (resolve, reject) => {
try {
let friends = []
let more = true
while (more) {
const maxId = friends.length > 0 ? last(friends).id : undefined
const users = await fetchFriends({ id, maxId, credentials })
friends = concat(friends, users)
if (users.length === 0) {
more = false
}
}
resolve(friends)
} catch (err) {
reject(err)
}
})
}
const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => {
let url = MASTODON_FOLLOWERS_URL(id)
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`,
'with_relationships=true',
]
.filter((_) => _)
.join('&')
url += args ? '?' + args : ''
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(parseUser))
}
const fetchFollowRequests = ({ credentials }) => {
const url = MASTODON_FOLLOW_REQUESTS_URL
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(parseUser))
}
const fetchLists = ({ credentials }) => {
const url = MASTODON_LISTS_URL
return fetch(url, { headers: authHeaders(credentials) }).then((data) =>
data.json(),
)
}
const createList = ({ title, credentials }) => {
const url = MASTODON_LISTS_URL
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'POST',
body: JSON.stringify({ title }),
}).then((data) => data.json())
}
const getList = ({ listId, credentials }) => {
const url = MASTODON_LIST_URL(listId)
return fetch(url, { headers: authHeaders(credentials) }).then((data) =>
data.json(),
)
}
const updateList = ({ listId, title, credentials }) => {
const url = MASTODON_LIST_URL(listId)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'PUT',
body: JSON.stringify({ title }),
})
}
const getListAccounts = ({ listId, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(listId)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(({ id }) => id))
}
const addAccountsToList = ({ listId, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(listId)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'POST',
body: JSON.stringify({ account_ids: accountIds }),
})
}
const removeAccountsFromList = ({ listId, accountIds, credentials }) => {
const url = MASTODON_LIST_ACCOUNTS_URL(listId)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'DELETE',
body: JSON.stringify({ account_ids: accountIds }),
})
}
const deleteList = ({ listId, credentials }) => {
const url = MASTODON_LIST_URL(listId)
return fetch(url, {
method: 'DELETE',
headers: authHeaders(credentials),
})
}
const fetchConversation = ({ id, credentials }) => {
const urlContext = MASTODON_STATUS_CONTEXT_URL(id)
return fetch(urlContext, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching timeline', data)
})
.then((data) => data.json())
.then(({ ancestors, descendants }) => ({
ancestors: ancestors.map(parseStatus),
descendants: descendants.map(parseStatus),
}))
}
const fetchStatus = ({ id, credentials }) => {
const url = MASTODON_STATUS_URL(id)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching timeline', data)
})
.then((data) => data.json())
.then((data) => parseStatus(data))
}
const fetchStatusSource = ({ id, credentials }) => {
const url = MASTODON_STATUS_SOURCE_URL(id)
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching source', data)
})
.then((data) => data.json())
.then((data) => parseSource(data))
}
const fetchStatusHistory = ({ status, credentials }) => {
const url = MASTODON_STATUS_HISTORY_URL(status.id)
return promisedRequest({ url, credentials }).then((data) => {
data.reverse()
return data.map((item) => {
item.originalStatus = status
return parseStatus(item)
})
})
}
const tagUser = ({ tag, credentials, user }) => {
const screenName = user.screen_name
const form = {
nicknames: [screenName],
tags: [tag],
}
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(TAG_USER_URL, {
method: 'PUT',
headers,
body: JSON.stringify(form),
})
}
const untagUser = ({ tag, credentials, user }) => {
const screenName = user.screen_name
const body = {
nicknames: [screenName],
tags: [tag],
}
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(TAG_USER_URL, {
method: 'DELETE',
headers,
body: JSON.stringify(body),
})
}
const addRight = ({ right, credentials, user }) => {
const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), {
method: 'POST',
headers: authHeaders(credentials),
body: {},
})
}
const deleteRight = ({ right, credentials, user }) => {
const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), {
method: 'DELETE',
headers: authHeaders(credentials),
body: {},
})
}
const activateUser = ({ credentials, user: { screen_name: nickname } }) => {
return promisedRequest({
url: ACTIVATE_USER_URL,
method: 'PATCH',
credentials,
payload: {
nicknames: [nickname],
},
}).then((response) => get(response, 'users.0'))
}
const deactivateUser = ({ credentials, user: { screen_name: nickname } }) => {
return promisedRequest({
url: DEACTIVATE_USER_URL,
method: 'PATCH',
credentials,
payload: {
nicknames: [nickname],
},
}).then((response) => get(response, 'users.0'))
}
const deleteUser = ({ credentials, user }) => {
const screenName = user.screen_name
const headers = authHeaders(credentials)
return fetch(`${ADMIN_USERS_URL}?nickname=${screenName}`, {
method: 'DELETE',
headers,
})
}
const fetchTimeline = ({
timeline,
credentials,
since = false,
minId = false,
until = false,
userId = false,
listId = false,
statusId = false,
tag = false,
withMuted = false,
replyVisibility = 'all',
includeTypes = [],
bookmarkFolderId = false,
}) => {
const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE,
friends: MASTODON_USER_HOME_TIMELINE_URL,
dms: MASTODON_DIRECT_MESSAGES_TIMELINE_URL,
notifications: MASTODON_USER_NOTIFICATIONS_URL,
publicAndExternal: MASTODON_PUBLIC_TIMELINE,
user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL,
list: MASTODON_LIST_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
publicFavorites: PLEROMA_USER_FAVORITES_TIMELINE_URL,
tag: MASTODON_TAG_TIMELINE_URL,
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL,
quotes: PLEROMA_STATUS_QUOTES_URL,
bubble: AKKOMA_BUBBLE_TIMELINE_URL,
}
const isNotifications = timeline === 'notifications'
const params = []
let url = timelineUrls[timeline]
if (timeline === 'favorites' && userId) {
url = timelineUrls.publicFavorites(userId)
}
if (timeline === 'user' || timeline === 'media') {
url = url(userId)
}
if (timeline === 'list') {
url = url(listId)
}
if (timeline === 'quotes') {
url = url(statusId)
}
if (minId) {
params.push(['min_id', minId])
}
if (since) {
params.push(['since_id', since])
}
if (until) {
params.push(['max_id', until])
}
if (tag) {
url = url(tag)
}
if (timeline === 'media') {
params.push(['only_media', 1])
}
if (timeline === 'public') {
params.push(['local', true])
}
if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false])
}
if (timeline !== 'favorites' && timeline !== 'bookmarks') {
params.push(['with_muted', withMuted])
}
if (replyVisibility !== 'all') {
params.push(['reply_visibility', replyVisibility])
}
if (includeTypes.size > 0) {
includeTypes.forEach((type) => {
params.push(['include_types[]', type])
})
}
if (timeline === 'bookmarks' && bookmarkFolderId) {
params.push(['folder_id', bookmarkFolderId])
}
params.push(['limit', 20])
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join(
'&',
)
url += `?${queryString}`
return fetch(url, { headers: authHeaders(credentials) }).then(
async (response) => {
const success = response.ok
const data = await response.json()
if (success && !data.errors) {
const pagination = parseLinkHeaderPagination(
response.headers.get('Link'),
{
flakeId: timeline !== 'bookmarks' && timeline !== 'notifications',
},
)
return {
data: data.map(isNotifications ? parseNotification : parseStatus),
pagination,
}
} else {
data.errors ||= []
data.status = response.status
data.statusText = response.statusText
return data
}
},
)
}
const fetchPinnedStatuses = ({ id, credentials }) => {
const url = MASTODON_USER_TIMELINE_URL(id) + '?pinned=true'
return promisedRequest({ url, credentials }).then((data) =>
data.map(parseStatus),
)
}
const verifyCredentials = (user) => {
return fetch(MASTODON_LOGIN_URL, {
headers: authHeaders(user),
})
.then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response,
}
}
})
.then((data) => (data.error ? data : parseUser(data)))
}
const favorite = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_FAVORITE_URL(id),
method: 'POST',
credentials,
}).then((data) => parseStatus(data))
}
const unfavorite = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_UNFAVORITE_URL(id),
method: 'POST',
credentials,
}).then((data) => parseStatus(data))
}
const retweet = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_RETWEET_URL(id),
method: 'POST',
credentials,
}).then((data) => parseStatus(data))
}
const unretweet = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_UNRETWEET_URL(id),
method: 'POST',
credentials,
}).then((data) => parseStatus(data))
}
const bookmarkStatus = ({ id, credentials, ...options }) => {
return promisedRequest({
url: MASTODON_BOOKMARK_STATUS_URL(id),
headers: authHeaders(credentials),
method: 'POST',
payload: {
folder_id: options.folder_id,
},
})
}
const unbookmarkStatus = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_UNBOOKMARK_STATUS_URL(id),
headers: authHeaders(credentials),
method: 'POST',
})
}
const postStatus = ({
credentials,
status,
spoilerText,
visibility,
sensitive,
poll,
mediaIds = [],
inReplyToStatusId,
quoteId,
contentType,
preview,
idempotencyKey,
}) => {
const form = new FormData()
const pollOptions = poll.options || []
form.append('status', status)
form.append('source', 'Pleroma FE')
if (spoilerText) form.append('spoiler_text', spoilerText)
if (visibility) form.append('visibility', visibility)
if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType)
mediaIds.forEach((val) => {
form.append('media_ids[]', val)
})
if (pollOptions.some((option) => option !== '')) {
const normalizedPoll = {
expires_in: parseInt(poll.expiresIn, 10),
multiple: poll.multiple,
}
Object.keys(normalizedPoll).forEach((key) => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach((option) => {
form.append('poll[options][]', option)
})
}
if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId)
}
if (quoteId) {
form.append('quote_id', quoteId)
}
if (preview) {
form.append('preview', 'true')
}
const postHeaders = authHeaders(credentials)
if (idempotencyKey) {
postHeaders['idempotency-key'] = idempotencyKey
}
return fetch(MASTODON_POST_STATUS_URL, {
body: form,
method: 'POST',
headers: postHeaders,
})
.then((response) => {
return response.json()
})
.then((data) => (data.error ? data : parseStatus(data)))
}
const editStatus = ({
id,
credentials,
status,
spoilerText,
sensitive,
poll,
mediaIds = [],
contentType,
}) => {
const form = new FormData()
const pollOptions = poll.options || []
form.append('status', status)
if (spoilerText) form.append('spoiler_text', spoilerText)
if (sensitive) form.append('sensitive', sensitive)
if (contentType) form.append('content_type', contentType)
mediaIds.forEach((val) => {
form.append('media_ids[]', val)
})
if (pollOptions.some((option) => option !== '')) {
const normalizedPoll = {
expires_in: parseInt(poll.expiresIn, 10),
multiple: poll.multiple,
}
Object.keys(normalizedPoll).forEach((key) => {
form.append(`poll[${key}]`, normalizedPoll[key])
})
pollOptions.forEach((option) => {
form.append('poll[options][]', option)
})
}
const putHeaders = authHeaders(credentials)
return fetch(MASTODON_STATUS_URL(id), {
body: form,
method: 'PUT',
headers: putHeaders,
})
.then((response) => {
return response.json()
})
.then((data) => (data.error ? data : parseStatus(data)))
}
const deleteStatus = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_DELETE_URL(id),
credentials,
method: 'DELETE',
})
}
const uploadMedia = ({ formData, credentials }) => {
return fetch(MASTODON_MEDIA_UPLOAD_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials),
})
.then((data) => data.json())
.then((data) => parseAttachment(data))
}
const setMediaDescription = ({ id, description, credentials }) => {
return promisedRequest({
url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`,
method: 'PUT',
headers: authHeaders(credentials),
payload: {
description,
},
}).then((data) => parseAttachment(data))
}
const importMutes = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return fetch(MUTES_IMPORT_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials),
}).then((response) => response.ok)
}
const importBlocks = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return fetch(BLOCKS_IMPORT_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials),
}).then((response) => response.ok)
}
const importFollows = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
return fetch(FOLLOW_IMPORT_URL, {
body: formData,
method: 'POST',
headers: authHeaders(credentials),
}).then((response) => response.ok)
}
const deleteAccount = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
return fetch(DELETE_ACCOUNT_URL, {
body: form,
method: 'POST',
headers: authHeaders(credentials),
}).then((response) => response.json())
}
const changeEmail = ({ credentials, email, password }) => {
const form = new FormData()
form.append('email', email)
form.append('password', password)
return fetch(CHANGE_EMAIL_URL, {
body: form,
method: 'POST',
headers: authHeaders(credentials),
}).then((response) => response.json())
}
const moveAccount = ({ credentials, password, targetAccount }) => {
const form = new FormData()
form.append('password', password)
form.append('target_account', targetAccount)
return fetch(MOVE_ACCOUNT_URL, {
body: form,
method: 'POST',
headers: authHeaders(credentials),
}).then((response) => response.json())
}
const addAlias = ({ credentials, alias }) => {
return promisedRequest({
url: ALIASES_URL,
method: 'PUT',
credentials,
payload: { alias },
})
}
const deleteAlias = ({ credentials, alias }) => {
return promisedRequest({
url: ALIASES_URL,
method: 'DELETE',
credentials,
payload: { alias },
})
}
const listAliases = ({ credentials }) => {
return promisedRequest({
url: ALIASES_URL,
method: 'GET',
credentials,
params: {
_cacheBooster: new Date().getTime(),
},
})
}
const changePassword = ({
credentials,
password,
newPassword,
newPasswordConfirmation,
}) => {
const form = new FormData()
form.append('password', password)
form.append('new_password', newPassword)
form.append('new_password_confirmation', newPasswordConfirmation)
return fetch(CHANGE_PASSWORD_URL, {
body: form,
method: 'POST',
headers: authHeaders(credentials),
}).then((response) => response.json())
}
const settingsMFA = ({ credentials }) => {
return fetch(MFA_SETTINGS_URL, {
headers: authHeaders(credentials),
method: 'GET',
}).then((data) => data.json())
}
const mfaDisableOTP = ({ credentials, password }) => {
const form = new FormData()
form.append('password', password)
return fetch(MFA_DISABLE_OTP_URL, {
body: form,
method: 'DELETE',
headers: authHeaders(credentials),
}).then((response) => response.json())
}
const mfaConfirmOTP = ({ credentials, password, token }) => {
const form = new FormData()
form.append('password', password)
form.append('code', token)
return fetch(MFA_CONFIRM_OTP_URL, {
body: form,
headers: authHeaders(credentials),
method: 'POST',
}).then((data) => data.json())
}
const mfaSetupOTP = ({ credentials }) => {
return fetch(MFA_SETUP_OTP_URL, {
headers: authHeaders(credentials),
method: 'GET',
}).then((data) => data.json())
}
const generateMfaBackupCodes = ({ credentials }) => {
return fetch(MFA_BACKUP_CODES_URL, {
headers: authHeaders(credentials),
method: 'GET',
}).then((data) => data.json())
}
const fetchMutes = ({ maxId, credentials }) => {
const query = new URLSearchParams({ with_relationships: true })
if (maxId) {
query.append('max_id', maxId)
}
return promisedRequest({
url: `${MASTODON_USER_MUTES_URL}?${query.toString()}`,
credentials,
}).then((users) => users.map(parseUser))
}
const muteUser = ({ id, expiresIn, credentials }) => {
const payload = {}
if (expiresIn) {
payload.expires_in = expiresIn
}
return promisedRequest({
url: MASTODON_MUTE_USER_URL(id),
credentials,
method: 'POST',
payload,
})
}
const unmuteUser = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_UNMUTE_USER_URL(id),
credentials,
method: 'POST',
})
}
const fetchBlocks = ({ maxId, credentials }) => {
const query = new URLSearchParams({ with_relationships: true })
if (maxId) {
query.append('max_id', maxId)
}
return promisedRequest({
url: `${MASTODON_USER_BLOCKS_URL}?${query.toString()}`,
credentials,
}).then((users) => users.map(parseUser))
}
const addBackup = ({ credentials }) => {
return promisedRequest({
url: PLEROMA_BACKUP_URL,
method: 'POST',
credentials,
})
}
const listBackups = ({ credentials }) => {
return promisedRequest({
url: PLEROMA_BACKUP_URL,
method: 'GET',
credentials,
params: {
_cacheBooster: new Date().getTime(),
},
})
}
const fetchOAuthTokens = ({ credentials }) => {
const url = '/api/oauth_tokens.json'
return fetch(url, {
headers: authHeaders(credentials),
}).then((data) => {
if (data.ok) {
return data.json()
}
throw new Error('Error fetching auth tokens', data)
})
}
const revokeOAuthToken = ({ id, credentials }) => {
const url = `/api/oauth_tokens/${id}`
return fetch(url, {
headers: authHeaders(credentials),
method: 'DELETE',
})
}
const suggestions = ({ credentials }) => {
return fetch(SUGGESTIONS_URL, {
headers: authHeaders(credentials),
}).then((data) => data.json())
}
const markNotificationsAsSeen = ({ id, credentials, single = false }) => {
const body = new FormData()
if (single) {
body.append('id', id)
} else {
body.append('max_id', id)
}
return fetch(NOTIFICATION_READ_URL, {
body,
headers: authHeaders(credentials),
method: 'POST',
}).then((data) => data.json())
}
const vote = ({ pollId, choices, credentials }) => {
const form = new FormData()
form.append('choices', choices)
return promisedRequest({
url: MASTODON_VOTE_URL(encodeURIComponent(pollId)),
method: 'POST',
credentials,
payload: {
choices,
},
})
}
const fetchPoll = ({ pollId, credentials }) => {
return promisedRequest({
url: MASTODON_POLL_URL(encodeURIComponent(pollId)),
method: 'GET',
credentials,
})
}
const fetchFavoritedByUsers = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_STATUS_FAVORITEDBY_URL(id),
method: 'GET',
credentials,
}).then((users) => users.map(parseUser))
}
const fetchRebloggedByUsers = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_STATUS_REBLOGGEDBY_URL(id),
method: 'GET',
credentials,
}).then((users) => users.map(parseUser))
}
const fetchEmojiReactions = ({ id, credentials }) => {
return promisedRequest({
url: PLEROMA_EMOJI_REACTIONS_URL(id),
credentials,
}).then((reactions) =>
reactions.map((r) => {
r.accounts = r.accounts.map(parseUser)
return r
}),
)
}
const reactWithEmoji = ({ id, emoji, credentials }) => {
return promisedRequest({
url: PLEROMA_EMOJI_REACT_URL(id, emoji),
method: 'PUT',
credentials,
}).then(parseStatus)
}
const unreactWithEmoji = ({ id, emoji, credentials }) => {
return promisedRequest({
url: PLEROMA_EMOJI_UNREACT_URL(id, emoji),
method: 'DELETE',
credentials,
}).then(parseStatus)
}
const reportUser = ({ credentials, userId, statusIds, comment, forward }) => {
return promisedRequest({
url: MASTODON_REPORT_USER_URL,
method: 'POST',
payload: {
account_id: userId,
status_ids: statusIds,
comment,
forward,
},
credentials,
})
}
const searchUsers = ({ credentials, query }) => {
return promisedRequest({
url: MASTODON_USER_SEARCH_URL,
params: {
q: query,
resolve: true,
},
credentials,
}).then((data) => data.map(parseUser))
}
const search2 = ({
credentials,
q,
resolve,
limit,
offset,
following,
type,
}) => {
let url = MASTODON_SEARCH_2
const params = []
if (q) {
params.push(['q', encodeURIComponent(q)])
}
if (resolve) {
params.push(['resolve', resolve])
}
if (limit) {
params.push(['limit', limit])
}
if (offset) {
params.push(['offset', offset])
}
if (following) {
params.push(['following', true])
}
if (type) {
params.push(['type', type])
}
params.push(['with_relationships', true])
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join(
'&',
)
url += `?${queryString}`
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
if (data.ok) {
return data
}
throw new Error('Error fetching search result', data)
})
.then((data) => {
return data.json()
})
.then((data) => {
data.accounts = data.accounts.slice(0, limit).map((u) => parseUser(u))
data.statuses = data.statuses.slice(0, limit).map((s) => parseStatus(s))
return data
})
}
const fetchKnownDomains = ({ credentials }) => {
return promisedRequest({ url: MASTODON_KNOWN_DOMAIN_LIST_URL, credentials })
}
const fetchDomainMutes = ({ credentials }) => {
return promisedRequest({ url: MASTODON_DOMAIN_BLOCKS_URL, credentials })
}
const muteDomain = ({ domain, credentials }) => {
return promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'POST',
payload: { domain },
credentials,
})
}
const unmuteDomain = ({ domain, credentials }) => {
return promisedRequest({
url: MASTODON_DOMAIN_BLOCKS_URL,
method: 'DELETE',
payload: { domain },
credentials,
})
}
const dismissNotification = ({ credentials, id }) => {
return promisedRequest({
url: MASTODON_DISMISS_NOTIFICATION_URL(id),
method: 'POST',
payload: { id },
credentials,
})
}
const adminFetchAnnouncements = ({ credentials }) => {
return promisedRequest({ url: PLEROMA_ANNOUNCEMENTS_URL, credentials })
}
const fetchAnnouncements = ({ credentials }) => {
return promisedRequest({ url: MASTODON_ANNOUNCEMENTS_URL, credentials })
}
const dismissAnnouncement = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_ANNOUNCEMENTS_DISMISS_URL(id),
credentials,
method: 'POST',
})
}
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
}
const postAnnouncement = ({
credentials,
content,
startsAt,
endsAt,
allDay,
}) => {
return promisedRequest({
url: PLEROMA_POST_ANNOUNCEMENT_URL,
credentials,
method: 'POST',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
}
const editAnnouncement = ({
id,
credentials,
content,
startsAt,
endsAt,
allDay,
}) => {
return promisedRequest({
url: PLEROMA_EDIT_ANNOUNCEMENT_URL(id),
credentials,
method: 'PATCH',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
}
const deleteAnnouncement = ({ id, credentials }) => {
return promisedRequest({
url: PLEROMA_DELETE_ANNOUNCEMENT_URL(id),
credentials,
method: 'DELETE',
})
}
export const getMastodonSocketURI = (
{ credentials, stream, args = {} },
base,
) => {
const url = new URL(MASTODON_STREAMING, base)
if (credentials) {
url.searchParams.append('access_token', credentials)
}
if (stream) {
url.searchParams.append('stream', stream)
}
Object.entries(args).forEach(([key, val]) => {
url.searchParams.append(key, val)
})
return url
}
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 {
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,
})
const chats = ({ credentials }) => {
return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => {
return { chats: data.map(parseChat).filter((c) => c) }
})
}
const getOrCreateChat = ({ accountId, credentials }) => {
return promisedRequest({
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials,
})
}
const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
let url = PLEROMA_CHAT_MESSAGES_URL(id)
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`,
]
.filter((_) => _)
.join('&')
url = url + (args ? '?' + args : '')
return promisedRequest({
url,
method: 'GET',
credentials,
})
}
const sendChatMessage = ({
id,
content,
mediaId = null,
idempotencyKey,
credentials,
}) => {
const payload = {
content,
}
if (mediaId) {
payload.media_id = mediaId
}
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id),
method: 'POST',
payload,
credentials,
headers,
})
}
const readChat = ({ id, lastReadId, credentials }) => {
return promisedRequest({
url: PLEROMA_CHAT_READ_URL(id),
method: 'POST',
payload: {
last_read_id: lastReadId,
},
credentials,
})
}
const deleteChatMessage = ({ chatId, messageId, credentials }) => {
return promisedRequest({
url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
method: 'DELETE',
credentials,
})
}
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 fetch(PLEROMA_ADMIN_REPORTS, {
headers: {
...authHeaders(credentials),
Accept: 'application/json',
'Content-Type': 'application/json',
},
method: 'PATCH',
body: JSON.stringify({
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)
}
})
}
// ADMIN STUFF // EXPERIMENTAL
const fetchInstanceDBConfig = ({ credentials }) => {
return fetch(PLEROMA_ADMIN_CONFIG_URL, {
headers: authHeaders(credentials),
}).then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response,
}
}
})
}
const fetchInstanceConfigDescriptions = ({ credentials }) => {
return fetch(PLEROMA_ADMIN_DESCRIPTIONS_URL, {
headers: authHeaders(credentials),
}).then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response,
}
}
})
}
const fetchAvailableFrontends = ({ credentials }) => {
return fetch(PLEROMA_ADMIN_FRONTENDS_URL, {
headers: authHeaders(credentials),
}).then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response,
}
}
})
}
const pushInstanceDBConfig = ({ credentials, payload }) => {
return fetch(PLEROMA_ADMIN_CONFIG_URL, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...authHeaders(credentials),
},
method: 'POST',
body: JSON.stringify(payload),
}).then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response,
}
}
})
}
const installFrontend = ({ credentials, payload }) => {
return fetch(PLEROMA_ADMIN_FRONTENDS_INSTALL_URL, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...authHeaders(credentials),
},
method: 'POST',
body: JSON.stringify(payload),
}).then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response,
}
}
})
}
const fetchScrobbles = ({ accountId, limit = 1 }) => {
let url = PLEROMA_SCROBBLES_URL(accountId)
const params = [['limit', limit]]
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join(
'&',
)
url += `?${queryString}`
return fetch(url, {}).then((response) => {
if (response.ok) {
return response.json()
} else {
return {
error: response,
}
}
})
}
const deleteEmojiPack = ({ name }) => {
return fetch(PLEROMA_EMOJI_PACK_URL(name), { method: 'DELETE' })
}
const reloadEmoji = () => {
return fetch(PLEROMA_EMOJI_RELOAD_URL, { method: 'POST' })
}
const importEmojiFromFS = () => {
return fetch(PLEROMA_EMOJI_IMPORT_FS_URL)
}
const createEmojiPack = ({ name }) => {
return fetch(PLEROMA_EMOJI_PACK_URL(name), { method: 'POST' })
}
const listEmojiPacks = ({ page, pageSize }) => {
return fetch(PLEROMA_EMOJI_PACKS_URL(page, pageSize))
}
const listRemoteEmojiPacks = ({ instance, page, pageSize }) => {
if (!instance.startsWith('http')) {
instance = 'https://' + instance
}
return fetch(PLEROMA_EMOJI_PACKS_LS_REMOTE_URL(instance, page, pageSize), {
headers: { 'Content-Type': 'application/json' },
})
}
const downloadRemoteEmojiPack = ({ instance, packName, as }) => {
return fetch(PLEROMA_EMOJI_PACKS_DL_REMOTE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: instance,
name: packName,
as,
}),
})
}
const downloadRemoteEmojiPackZIP = ({ url, packName, file }) => {
const data = new FormData()
if (file) data.set('file', file)
if (url) data.set('url', url)
data.set('name', packName)
return fetch(PLEROMA_EMOJI_PACKS_DL_REMOTE_ZIP_URL, {
method: 'POST',
body: data,
})
}
const saveEmojiPackMetadata = ({ name, newData }) => {
return fetch(PLEROMA_EMOJI_PACK_URL(name), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: newData }),
})
}
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 fetch(PLEROMA_EMOJI_UPDATE_FILE_URL(packName), {
method: 'POST',
body: data,
})
}
const updateEmojiFile = ({
packName,
shortcode,
newShortcode,
newFilename,
force,
}) => {
return fetch(PLEROMA_EMOJI_UPDATE_FILE_URL(packName), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
shortcode,
new_shortcode: newShortcode,
new_filename: newFilename,
force,
}),
})
}
const deleteEmojiFile = ({ packName, shortcode }) => {
return fetch(
`${PLEROMA_EMOJI_UPDATE_FILE_URL(packName)}&shortcode=${shortcode}`,
{ method: 'DELETE' },
)
}
const fetchBookmarkFolders = ({ credentials }) => {
const url = PLEROMA_BOOKMARK_FOLDERS_URL
return fetch(url, { headers: authHeaders(credentials) }).then((data) =>
data.json(),
)
}
const createBookmarkFolder = ({ name, emoji, credentials }) => {
const url = PLEROMA_BOOKMARK_FOLDERS_URL
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'POST',
body: JSON.stringify({ name, emoji }),
}).then((data) => data.json())
}
const updateBookmarkFolder = ({ folderId, name, emoji, credentials }) => {
const url = PLEROMA_BOOKMARK_FOLDER_URL(folderId)
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(url, {
headers,
method: 'PATCH',
body: JSON.stringify({ name, emoji }),
}).then((data) => data.json())
}
const deleteBookmarkFolder = ({ folderId, credentials }) => {
const url = PLEROMA_BOOKMARK_FOLDER_URL(folderId)
return fetch(url, {
method: 'DELETE',
headers: authHeaders(credentials),
})
}
const apiService = {
verifyCredentials,
fetchTimeline,
fetchPinnedStatuses,
fetchConversation,
fetchStatus,
fetchStatusSource,
fetchStatusHistory,
fetchFriends,
exportFriends,
fetchFollowers,
followUser,
unfollowUser,
pinOwnStatus,
unpinOwnStatus,
muteConversation,
unmuteConversation,
blockUser,
unblockUser,
removeUserFromFollowers,
editUserNote,
fetchUser,
fetchUserByName,
fetchUserRelationship,
favorite,
unfavorite,
retweet,
unretweet,
bookmarkStatus,
unbookmarkStatus,
postStatus,
editStatus,
deleteStatus,
uploadMedia,
setMediaDescription,
fetchMutes,
muteUser,
unmuteUser,
fetchBlocks,
fetchOAuthTokens,
revokeOAuthToken,
tagUser,
untagUser,
deleteUser,
addRight,
deleteRight,
activateUser,
deactivateUser,
register,
getCaptcha,
updateProfileImages,
updateProfile,
updateProfileJSON,
importMutes,
importBlocks,
importFollows,
deleteAccount,
changeEmail,
moveAccount,
addAlias,
deleteAlias,
listAliases,
changePassword,
settingsMFA,
mfaDisableOTP,
generateMfaBackupCodes,
mfaSetupOTP,
mfaConfirmOTP,
addBackup,
listBackups,
fetchFollowRequests,
fetchLists,
createList,
getList,
updateList,
getListAccounts,
addAccountsToList,
removeAccountsFromList,
deleteList,
approveUser,
denyUser,
suggestions,
markNotificationsAsSeen,
dismissNotification,
vote,
fetchPoll,
fetchFavoritedByUsers,
fetchRebloggedByUsers,
fetchEmojiReactions,
reactWithEmoji,
unreactWithEmoji,
reportUser,
updateNotificationSettings,
search2,
searchUsers,
fetchKnownDomains,
fetchDomainMutes,
muteDomain,
unmuteDomain,
chats,
getOrCreateChat,
chatMessages,
sendChatMessage,
readChat,
deleteChatMessage,
setReportState,
fetchUserInLists,
fetchAnnouncements,
dismissAnnouncement,
postAnnouncement,
editAnnouncement,
deleteAnnouncement,
fetchScrobbles,
adminFetchAnnouncements,
fetchInstanceDBConfig,
fetchInstanceConfigDescriptions,
fetchAvailableFrontends,
pushInstanceDBConfig,
installFrontend,
importEmojiFromFS,
reloadEmoji,
listEmojiPacks,
createEmojiPack,
deleteEmojiPack,
saveEmojiPackMetadata,
addNewEmojiFile,
updateEmojiFile,
deleteEmojiFile,
listRemoteEmojiPacks,
downloadRemoteEmojiPack,
downloadRemoteEmojiPackZIP,
fetchBookmarkFolders,
createBookmarkFolder,
updateBookmarkFolder,
deleteBookmarkFolder,
}
export default apiService
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index 08034d4857..495070b0df 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,342 +1,342 @@
import localforage from 'localforage'
import { chunk, throttle } from 'lodash'
import { getCssRules } from '../theme_data/css_utils.js'
import { getEngineChecksum, init } from '../theme_data/theme_data_3.service.js'
import { defaultState } from 'src/modules/default_config_state.js'
// On platforms where this is not supported, it will return undefined
// Otherwise it will return an array
const supportsAdoptedStyleSheets = !!document.adoptedStyleSheets
const stylesheets = {}
export const createStyleSheet = (id, priority = 1000) => {
if (stylesheets[id]) return stylesheets[id]
const newStyleSheet = {
rules: [],
ready: false,
priority,
clear() {
this.rules = []
},
addRule(rule) {
let newRule = rule
if (!CSS.supports?.('backdrop-filter', 'blur()')) {
newRule = newRule.replace(/backdrop-filter:[^;]+;/g, '') // Remove backdrop-filter
}
// firefox doesn't like invalid selectors
if (
!CSS.supports?.('selector(::-webkit-scrollbar)') &&
newRule.startsWith('::-webkit')
) {
return
}
this.rules.push(
newRule.replace(/var\(--shadowFilter\)[^;]*;/g, ''), // Remove shadowFilter references
)
},
}
stylesheets[id] = newStyleSheet
return newStyleSheet
}
export const adoptStyleSheets = throttle(() => {
if (supportsAdoptedStyleSheets) {
document.adoptedStyleSheets = Object.values(stylesheets)
.filter((x) => x.ready)
.sort((a, b) => a.priority - b.priority)
.map((sheet) => {
const css = new CSSStyleSheet()
sheet.rules.forEach((r) => css.insertRule(r))
return css
})
} else {
const holder = document.getElementById('custom-styles-holder')
for (let i = holder.sheet.cssRules.length - 1; i >= 0; --i) {
holder.sheet.deleteRule(i)
}
Object.values(stylesheets)
.filter((x) => x.ready)
.sort((a, b) => a.priority - b.priority)
.forEach((sheet) => {
sheet.rules.forEach((r) => holder.sheet.insertRule(r))
})
}
// Some older browsers do not support document.adoptedStyleSheets.
// In this case, we use the <style> elements.
// Since the <style> elements we need are already in the DOM, there
// is nothing to do here.
}, 500)
const EAGER_STYLE_ID = 'pleroma-eager-styles'
const LAZY_STYLE_ID = 'pleroma-lazy-styles'
export const generateTheme = (inputRuleset, callbacks, debug) => {
const {
onNewRule = () => {
/* no-op */
},
onLazyFinished = () => {
/* no-op */
},
onEagerFinished = () => {
/* no-op */
},
} = callbacks
const themes3 = init({
inputRuleset,
debug,
})
getCssRules(themes3.eager, debug).forEach((rule) => {
// Hacks to support multiple selectors on same component
onNewRule(rule, false)
})
onEagerFinished()
// Optimization - instead of processing all lazy rules in one go, process them in small chunks
// so that UI can do other things and be somewhat responsive while less important rules are being
// processed
let counter = 0
const chunks = chunk(themes3.lazy, 200)
// let t0 = performance.now()
const processChunk = () => {
const chunk = chunks[counter]
Promise.all(chunk.map((x) => x())).then((result) => {
getCssRules(
result.filter((x) => x),
debug,
).forEach((rule) => {
onNewRule(rule, true)
})
// const t1 = performance.now()
// console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms')
// t0 = t1
counter += 1
if (counter < chunks.length) {
setTimeout(processChunk, 0)
} else {
onLazyFinished()
}
})
}
return { lazyProcessFunc: processChunk }
}
export const tryLoadCache = async () => {
console.info('Trying to load compiled theme data from cache')
const cache = await localforage.getItem('pleromafe-theme-cache')
if (!cache) return null
try {
if (cache.engineChecksum === getEngineChecksum()) {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
cache.data[0].forEach((rule) => eagerStyles.addRule(rule))
cache.data[1].forEach((rule) => lazyStyles.addRule(rule))
eagerStyles.ready = true
lazyStyles.ready = true
console.info(`Loaded theme from cache`)
return true
} else {
console.warn("Engine checksum doesn't match, cache not usable, clearing")
localStorage.removeItem('pleroma-fe-theme-cache')
}
} catch (e) {
console.error('Failed to load theme cache:', e)
return false
}
}
export const applyTheme = (
input,
onEagerFinish = () => {
/* no-op */
},
onFinish = () => {
/* no-op */
},
debug,
) => {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
eagerStyles.clear()
lazyStyles.clear()
const { lazyProcessFunc } = generateTheme(
input,
{
onNewRule(rule, isLazy) {
if (isLazy) {
lazyStyles.addRule(rule)
} else {
eagerStyles.addRule(rule)
}
},
onEagerFinished() {
eagerStyles.ready = true
adoptStyleSheets()
onEagerFinish()
console.info(
'Eager part of theme finished, waiting for lazy part to finish to store cache',
)
},
onLazyFinished() {
lazyStyles.ready = true
adoptStyleSheets()
const cache = {
engineChecksum: getEngineChecksum(),
data: [eagerStyles.rules, lazyStyles.rules],
}
onFinish(cache)
localforage.setItem('pleromafe-theme-cache', cache)
console.info('Theme cache stored')
},
},
debug,
)
setTimeout(lazyProcessFunc, 0)
}
const extractStyleConfig = ({
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
themeEditorMinWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
forcedRoundness,
}) => {
const result = {
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
themeEditorMinWidth:
parseInt(themeEditorMinWidth) === 0 ? 'fit-content' : themeEditorMinWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
}
switch (forcedRoundness) {
case 'disable':
break
case '0':
result.forcedRoundness = '0'
break
case '1':
result.forcedRoundness = '1px'
break
case '2':
result.forcedRoundness = '0.4rem'
break
default:
}
return result
}
const defaultStyleConfig = extractStyleConfig(defaultState)
-export const applyConfig = (input) => {
+export const applyStyleConfig = (input) => {
const config = extractStyleConfig(input)
if (config === defaultStyleConfig) {
return
}
const rules = Object.entries(config)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}: ${v}`)
.join(';')
const styleSheet = createStyleSheet('theme-holder', 30)
styleSheet.clear()
styleSheet.addRule(`:root { ${rules} }`)
// TODO find a way to make this not apply to theme previews
if (Object.hasOwn(config, 'forcedRoundness')) {
styleSheet.addRule(` *:not(.preview-block) {
--roundness: var(--forcedRoundness) !important;
}`)
}
styleSheet.ready = true
adoptStyleSheets()
}
export const getResourcesIndex = async (url, parser = JSON.parse) => {
const cache = 'no-store'
const customUrl = url.replace(/\.(\w+)$/, '.custom.$1')
let builtin
let custom
const resourceTransform = (resources) => {
return Object.entries(resources).map(([k, v]) => {
if (typeof v === 'object') {
return [k, () => Promise.resolve(v)]
} else if (typeof v === 'string') {
return [
k,
() =>
window
.fetch(v, { cache })
.then((data) => data.text())
.then((text) => parser(text))
.catch((e) => {
console.error(e)
return null
}),
]
} else {
console.error(`Unknown resource format - ${k} is a ${typeof v}`)
return [k, null]
}
})
}
try {
const builtinData = await window.fetch(url, { cache })
const builtinResources = await builtinData.json()
builtin = resourceTransform(builtinResources)
} catch {
builtin = []
console.warn(`Builtin resources at ${url} unavailable`)
}
try {
const customData = await window.fetch(customUrl, { cache })
const customResources = await customData.json()
custom = resourceTransform(customResources)
} catch {
custom = []
console.warn(`Custom resources at ${customUrl} unavailable`)
}
const total = [...custom, ...builtin]
if (total.length === 0) {
return Promise.reject(
new Error(
`Resource at ${url} and ${customUrl} completely unavailable. Panicking`,
),
)
}
return Promise.resolve(Object.fromEntries(total))
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Aug 30, 1:01 AM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1737658
Default Alt Text
(116 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment