Page MenuHomePhorge

No OneTemporary

Size
288 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/changelog.d/fix-admin-user-list.fix b/changelog.d/fix-admin-user-list.fix
new file mode 100644
index 0000000000..2f7c7ba636
--- /dev/null
+++ b/changelog.d/fix-admin-user-list.fix
@@ -0,0 +1 @@
+Fix admin user list
diff --git a/changelog.d/hosted-mode.change b/changelog.d/hosted-mode.change
new file mode 100644
index 0000000000..01d8b86964
--- /dev/null
+++ b/changelog.d/hosted-mode.change
@@ -0,0 +1 @@
+Hosted mode: the frontend can be served from its own site and log into a chosen Pleroma or Akkoma instance
diff --git a/changelog.d/iss-3567.fix b/changelog.d/iss-3567.fix
new file mode 100644
index 0000000000..19f9b3c2b8
--- /dev/null
+++ b/changelog.d/iss-3567.fix
@@ -0,0 +1 @@
+add payload for frontends install api
diff --git a/changelog.d/vs-performance.skip b/changelog.d/vs-performance.skip
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/api/api_base.js b/src/api/api_base.js
new file mode 100644
index 0000000000..632c04252b
--- /dev/null
+++ b/src/api/api_base.js
@@ -0,0 +1,24 @@
+// Where the instance's API lives. Empty when the page is served by the
+// instance itself (paths stay relative, as always); the chosen instance's
+// origin when this frontend is hosted on its own domain.
+
+let base = ''
+
+export const setApiBase = (url) => {
+ base = url ? url.replace(/\/+$/, '') : ''
+}
+
+const isAbsolute = (url) => /^[a-z][a-z\d+.-]*:/i.test(url)
+
+// a path on the instance
+export const apiUrl = (path) => (base && !isAbsolute(path) ? base + path : path)
+
+// a WebSocket URL on the instance (ws: for http:, wss: for https:),
+// always absolute: WebSocket takes relative URLs only in recent browsers
+export const streamingUrl = (path) =>
+ (base || window.location.origin).replace(/^http/, 'ws') + path
+
+// a file the server provides (default avatar, banner): the server in front
+// of a path, a full URL as it is
+export const serverUrl = (server, path) =>
+ isAbsolute(path) ? path : server + path
diff --git a/src/api/helpers.js b/src/api/helpers.js
index 6afd204684..765615dccf 100644
--- a/src/api/helpers.js
+++ b/src/api/helpers.js
@@ -1,139 +1,140 @@
import { snakeCase } from 'lodash-es'
+import { apiUrl } from 'src/api/api_base.js'
import { StatusCodeError } from 'src/services/errors/errors'
export const paramsString = (params = {}) => {
if (params == null || params === undefined) return ''
if (typeof params !== 'object' || Array.isArray(params)) {
throw new TypeError('Params are not an object!')
}
const entries = (() => {
if (params instanceof Map) {
return [...params.entries()]
} else {
return Object.entries(params)
}
})()
const arrays = []
const nonArrays = []
entries.forEach(([k, v]) => {
if (v == null) return // Drop nulls
if (
(typeof v === 'object' && !Array.isArray(v)) ||
typeof v === 'function'
) {
throw new TypeError('Param cannot be non-primitive!')
}
if (Array.isArray(v)) {
arrays.push([k, v])
} else {
nonArrays.push([k, v])
}
})
arrays.forEach(([k, array]) => {
array.forEach((v) => {
if (typeof v === 'object' || typeof v === 'function' || v === undefined)
throw new TypeError('Array param cannot contain non-primitives!')
})
})
if (nonArrays.length + arrays.length === 0) return ''
return (
'?' +
[
...nonArrays.map(([k, v]) => [snakeCase(k), v]),
// turning [a,[1,2,3]] into [[a[],1],[a[],2],[a[],3]]
...arrays.reduce(
(acc, [k, arrayValue]) => [
...acc,
...arrayValue.map((v) => [snakeCase(k) + '[]', v]),
],
[],
),
]
.map(([k, v]) => `${k}=${window.encodeURIComponent(v)}`)
.join('&')
)
}
export const promisedRequest = async ({
method = 'GET',
url,
payload,
formData,
forceContentType,
cache,
credentials,
headers = {},
}) => {
const options = {
method,
credentials: 'same-origin',
headers: {
Accept: forceContentType ?? 'application/json',
...headers,
},
}
if (!formData) {
options.headers['Content-Type'] = 'application/json'
}
if (cache) {
options.cache = cache
}
if (formData || payload) {
options.body = formData || JSON.stringify(payload)
}
if (credentials) {
options.headers = {
...options.headers,
...authHeaders(credentials),
}
}
- const response = await fetch(url, options)
+ const response = await fetch(apiUrl(url), options)
const data = await (async () => {
const [contentType] = response.headers
.get('content-type')
.split(';')
.map((x) => x.toLowerCase().trim())
const contentLength = Number.parseInt(
response.headers.get('content-length'),
)
if (contentLength === 0) return null
switch (forceContentType ?? contentType) {
case 'text/plain':
return await response.text()
case 'application/json':
return await response.json()
default:
return await response.bytes()
}
})()
const { ok, status } = response
if (ok) {
return { response, status, data, timestamp: Date.now() }
} else {
throw new StatusCodeError(response.status, data, { url, options }, response)
}
}
const authHeaders = (accessToken) => {
if (accessToken) {
return { Authorization: `Bearer ${accessToken}` }
} else {
return {}
}
}
diff --git a/src/api/websocket.js b/src/api/websocket.js
index 3b0e34bd46..fdf0d067a2 100644
--- a/src/api/websocket.js
+++ b/src/api/websocket.js
@@ -1,195 +1,196 @@
+import { streamingUrl } from './api_base.js'
import { paramsString } from './helpers.js'
import {
parseChat,
parseNotification,
parseStatus,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MASTODON_STREAMING = ({ accessToken, stream }) =>
- `/api/v1/streaming${paramsString({ accessToken, stream })}`
+ streamingUrl(`/api/v1/streaming${paramsString({ accessToken, stream })}`)
export const getMastodonSocketURI = ({ credentials, stream }) => {
return MASTODON_STREAMING({ accessToken: credentials, stream })
}
const MASTODON_STREAMING_EVENTS = new Set([
'update',
'notification',
'delete',
'filters_changed',
'status.update',
])
const PLEROMA_STREAMING_EVENTS = new Set([
'pleroma:chat_update',
'pleroma:respond',
])
export const WSConnectionStatus = Object.freeze({
JOINED: 1,
CLOSED: 2,
ERROR: 3,
DISABLED: 4,
STARTING: 5,
STARTING_INITIAL: 6,
})
export class WSEvent extends Event {
data
constructor(name, data, original) {
super(name)
this.data = data
}
}
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a WSEvent 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 WSEvent(eventName, 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 WSEvent('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
}
const prepareEvent = ({ event, stream, payload, socket }) => {
const data = (() => {
// MastoBE and PleromaBE both send payload for delete as a plain string
if (event === 'delete') {
return payload
} else if (payload) {
return JSON.parse(payload)
} else {
return null
}
})()
const result = { event, stream, socket }
if (event === 'delete') {
return { ...result, id: data }
} else if (event === 'update' || event === 'statusUpdate') {
return { ...result, status: parseStatus(data) }
} else if (event === 'notification') {
return { ...result, notification: parseNotification(data) }
} else if (event === 'pleroma:chat_update') {
return { ...result, chatUpdate: parseChat(data) }
} else {
return { ...result, data }
}
}
const handleEvent = ({ event, data, socket }, onAuthenticated) => {
if (event === 'pleroma:respond' && data.type === 'pleroma:authenticate') {
if (data.result === 'success' || data.error === 'already_authenticated') {
console.debug('[WS] Successfully authenticated')
onAuthenticated()
} else {
console.error('[WS] Unable to authenticate:', data.error)
socket.close()
}
}
}
export const handleMastoWS = (
wsEvent,
{
onAuthenticated = () => {
/* no-op */
},
} = {},
) => {
const { data, target: socket } = wsEvent
if (!data) return
const parsedEvent = JSON.parse(data)
const { event, stream, payload } = parsedEvent
if (
!MASTODON_STREAMING_EVENTS.has(event) &&
!PLEROMA_STREAMING_EVENTS.has(event)
) {
console.warn('Unknown event', wsEvent)
return null
}
const result = prepareEvent({ event, stream, payload, socket })
handleEvent(result, onAuthenticated)
return result
}
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index a9f6d50f48..c6774c50c8 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -1,628 +1,669 @@
/* 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 InstancePicker from 'src/components/instance_picker/instance_picker.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import Status from 'src/components/status/status.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import { config } from '@fortawesome/fontawesome-svg-core'
import {
FontAwesomeIcon,
FontAwesomeLayers,
} from '@fortawesome/vue-fontawesome'
config.autoAddCss = false
import App from '../App.vue'
import FaviconService from '../services/favicon_service/favicon_service.js'
import { applyStyleConfig } from '../services/style_setter/style_setter.js'
import { initServiceWorker, updateFocus } from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import { useAuthFlowStore } from 'src/stores/auth_flow'
import { useChatsStore } from 'src/stores/chats.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
+import { apiUrl, setApiBase } from 'src/api/api_base.js'
import { getRoutes } from 'src/boot/routes.js'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import {
INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
INSTANCE_IDENTIY_EXTERNAL,
} from 'src/modules/default_config_state.js'
+import {
+ chosenInstance,
+ instanceConfig,
+ isHosted,
+} from 'src/services/hosted/hosted.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.codePointAt(0)))
const text = new TextDecoder().decode(array)
return text
}
const preloadFetch = async (request) => {
const data = parsedInitialResults()
if (!data?.[request]) {
- return window.fetch(request)
+ return window.fetch(apiUrl(request))
}
const decoded = decodeUTF8Base64(data[request])
const requestData = JSON.parse(decoded)
return {
ok: true,
json: () => requestData,
text: () => requestData,
}
}
const getInstanceConfig = async ({ store }) => {
try {
const res = await preloadFetch('/api/v1/instance')
if (res.ok) {
const data = await res.json()
const textLimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
useInstanceCapabilitiesStore().set(
'pleromaExtensionsAvailable',
data.pleroma,
)
useInstanceStore().set({
path: 'limits.textLimit',
value: textLimit,
})
useInstanceStore().set({
path: 'accountApprovalRequired',
value: data.approval_required,
})
useInstanceStore().set({
path: 'birthdayRequired',
value: !!data.pleroma?.metadata.birthday_required,
})
useInstanceStore().set({
path: 'birthdayMinAge',
value: data.pleroma?.metadata.birthday_min_age || 0,
})
if (vapidPublicKey) {
useInstanceStore().set({
path: 'vapidPublicKey',
value: vapidPublicKey,
})
}
} else {
throw res
}
} catch (error) {
console.error('Could not load instance config, potentially fatal')
console.error(error)
}
// We should check for scrobbles support here but it requires userId
// so instead we check for it where it's fetched (statuses.js)
}
const getBackendProvidedConfig = async () => {
try {
- const res = await window.fetch('/api/pleroma/frontend_configurations')
+ const res = await window.fetch(
+ apiUrl('/api/pleroma/frontend_configurations'),
+ )
if (res.ok) {
const data = await res.json()
return data.pleroma_fe
} else {
throw res
}
} catch (error) {
console.error(
'Could not load backend-provided frontend config, potentially fatal',
)
console.error(error)
}
}
const getStaticConfig = async () => {
try {
const res = await window.fetch('/static/config.json')
if (res.ok) {
return res.json()
} else {
throw res
}
} catch (error) {
console.warn(
'Failed to load static/config.json, continuing without it.',
error,
)
return {}
}
}
const setSettings = async ({ apiConfig, staticConfig, store }) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = { ...apiConfig, ...staticConfig }
} else {
config = { ...staticConfig, ...apiConfig }
}
Object.keys(INSTANCE_IDENTITY_DEFAULT_DEFINITIONS).forEach((source) => {
if (source === 'name') return
if (INSTANCE_IDENTIY_EXTERNAL.has(source)) return
useInstanceStore().set({
value:
config[source] ?? INSTANCE_IDENTITY_DEFAULT_DEFINITIONS[source].default,
path: `instanceIdentity.${source}`,
})
})
Object.keys(INSTANCE_DEFAULT_CONFIG_DEFINITIONS).forEach((source) =>
useInstanceStore().set({
value:
config[source] ?? INSTANCE_DEFAULT_CONFIG_DEFINITIONS[source].default,
path: `prefsStorage.${source}`,
}),
)
useAuthFlowStore().setInitialStrategy(config.loginMethod)
}
const getTOS = async ({ store }) => {
try {
- const res = await window.fetch('/static/terms-of-service.html')
+ const res = await window.fetch(apiUrl('/static/terms-of-service.html'))
if (res.ok) {
const html = await res.text()
useInstanceStore().set({ path: 'instanceIdentity.tos', value: html })
} else {
throw res
}
} catch (e) {
console.warn("Can't load TOS\n", e)
}
}
const getInstancePanel = async ({ store }) => {
try {
const res = await preloadFetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
useInstanceStore().set({
path: 'instanceIdentity.instanceSpecificPanelContent',
value: html,
})
} else {
throw res
}
} catch (e) {
console.warn("Can't load instance panel\n", e)
}
}
const getStickers = async ({ store }) => {
try {
- const res = await window.fetch('/static/stickers.json')
+ // the instance's sticker packs (hosted: on the instance)
+ const res = await window.fetch(apiUrl('/static/stickers.json'))
if (res.ok) {
const values = await res.json()
const stickers = (
await Promise.all(
- Object.entries(values).map(async ([name, path]) => {
+ Object.entries(values).map(async ([name, packPath]) => {
+ const path = apiUrl(packPath)
const resPack = await window.fetch(path + 'pack.json')
let meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta,
}
}),
)
).sort((a, b) => {
return a.meta.title.localeCompare(b.meta.title)
})
useEmojiStore().setStickers(stickers)
} else {
throw res
}
} catch (e) {
console.warn("Can't load stickers\n", e)
}
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map((uri) => uri.split('/').pop())
useInstanceStore().set({
path: 'staffAccounts',
value: nicknames,
})
}
const getNodeInfo = async ({ store }) => {
try {
let res = await preloadFetch('/nodeinfo/2.1.json')
if (!res.ok) res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
useInstanceStore().set({
path: 'instanceIdentity.name',
value: metadata.nodeName,
})
useInstanceStore().set({
path: 'registrationOpen',
value: data.openRegistrations,
})
useInstanceCapabilitiesStore().set(
'mediaProxyAvailable',
features.includes('media_proxy'),
)
useInstanceCapabilitiesStore().set(
'safeDM',
features.includes('safe_dm_mentions'),
)
useInstanceCapabilitiesStore().set(
'shoutAvailable',
features.includes('chat'),
)
useInstanceCapabilitiesStore().set(
'pleromaChatMessagesAvailable',
features.includes('pleroma_chat_messages'),
)
useInstanceCapabilitiesStore().set(
'pleromaCustomEmojiReactionsAvailable',
features.includes('pleroma_custom_emoji_reactions') ||
features.includes('custom_emoji_reactions'),
)
useInstanceCapabilitiesStore().set(
'pleromaBookmarkFoldersAvailable',
features.includes('pleroma:bookmark_folders'),
)
useInstanceCapabilitiesStore().set(
'gopherAvailable',
features.includes('gopher'),
)
useInstanceCapabilitiesStore().set(
'pollsAvailable',
features.includes('polls'),
)
useInstanceCapabilitiesStore().set(
'editingAvailable',
features.includes('editing'),
)
useInstanceCapabilitiesStore().set(
'mailerEnabled',
metadata.mailerEnabled,
)
useInstanceCapabilitiesStore().set(
'quotingAvailable',
features.includes('quote_posting'),
)
useInstanceCapabilitiesStore().set(
'groupActorAvailable',
features.includes('pleroma:group_actors'),
)
useInstanceCapabilitiesStore().set(
'blockExpiration',
features.includes('pleroma:block_expiration'),
)
useInstanceStore().set({
path: 'localBubbleInstances',
value: metadata.localBubbleInstances ?? [],
})
useInstanceCapabilitiesStore().set(
'localBubble',
(metadata.localBubbleInstances ?? []).length > 0,
)
useInstanceStore().set({
path: 'limits.pollLimits',
value: metadata.pollLimits,
})
const uploadLimits = metadata.uploadLimits
useInstanceStore().set({
path: 'limits.uploadlimit',
value: Number.parseInt(uploadLimits.general),
})
useInstanceStore().set({
path: 'limits.avatarlimit',
value: Number.parseInt(uploadLimits.avatar),
})
useInstanceStore().set({
path: 'limits.backgroundlimit',
value: Number.parseInt(uploadLimits.background),
})
useInstanceStore().set({
path: 'limits.bannerlimit',
value: Number.parseInt(uploadLimits.banner),
})
useInstanceStore().set({
path: 'limits.fieldsLimits',
value: metadata.fieldsLimits,
})
useInstanceStore().set({
path: 'restrictedNicknames',
value: metadata.restrictedNicknames,
})
useInstanceCapabilitiesStore().set('postFormats', metadata.postFormats)
const suggestions = metadata.suggestions
useInstanceCapabilitiesStore().set(
'suggestionsEnabled',
suggestions.enabled,
)
// this is unused, why?
useInstanceCapabilitiesStore().set('suggestionsWeb', suggestions.web)
const software = data.software
useInstanceStore().set({
path: 'backendVersion',
value: software.version,
})
useInstanceStore().set({
path: 'backendRepository',
value: software.repository,
})
const priv = metadata.private
useInstanceStore().set({ path: 'privateMode', value: priv })
const frontendVersion = window.___pleromafe_commit_hash
useInstanceStore().set({
path: 'frontendVersion',
value: frontendVersion,
})
const federation = metadata.federation
useInstanceCapabilitiesStore().set(
'tagPolicyAvailable',
federation.mrf_policies === undefined
? false
: metadata.federation.mrf_policies.includes('TagPolicy'),
)
useInstanceStore().set({
path: 'federationPolicy',
value: federation,
})
useInstanceStore().set({
path: 'federating',
value: federation.enabled === undefined ? true : federation.enabled,
})
const accountActivationRequired = metadata.accountActivationRequired
useInstanceStore().set({
path: 'accountActivationRequired',
value: accountActivationRequired,
})
const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts })
} else {
throw res
}
} catch (e) {
console.warn('Could not load nodeinfo', e)
}
}
-const setConfig = async ({ store }) => {
- // apiConfig, staticConfig
- const configInfos = await Promise.all([
- getBackendProvidedConfig(),
- getStaticConfig(),
- ])
- const apiConfig = configInfos[0]
- const staticConfig = configInfos[1]
+const setConfig = async ({ store, staticConfig, backendConfig }) => {
+ // hosted: the instance's paths point at the instance, login redirects
+ const apiConfig = isHosted(staticConfig)
+ ? instanceConfig(await getBackendProvidedConfig(), apiUrl)
+ : backendConfig
await setSettings({ store, apiConfig, staticConfig })
}
+// Hosted mode with no instance chosen yet: ask for one instead of starting
+// the app. The theme is applied first so the question looks like the app.
+const showInstancePicker = async ({ pinia, i18n }) => {
+ try {
+ await useInterfaceStore().applyTheme()
+ } catch (e) {
+ console.warn('No theme for the instance picker', e)
+ }
+ const picker = createApp(InstancePicker)
+ picker.use(pinia)
+ picker.use(i18n)
+ picker.component('FAIcon', FontAwesomeIcon)
+ picker.mount('#app')
+ document.querySelector('#splash')?.remove()
+ document.querySelector('#app').classList.remove('hidden')
+}
+
const checkOAuthToken = async ({ store }) => {
const oauth = useOAuthStore()
if (oauth.userToken) {
return useUsersStore().loginUser(oauth.userToken)
}
}
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)
app.config.errorHandler = (error, instance, info) => {
console.error(
'Global Vue Error Handler caught an error:',
error,
instance,
info,
)
useInterfaceStore().setGlobalError({ error, instance, info })
}
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 TypeError(
'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 TypeError(
'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 TypeError(
`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`,
)
}
await p
} else {
throw new TypeError(
`Store module ${name} does not export a 'use...' function`,
)
}
}),
)
}
let newStorageError
try {
await waitForAllStoresToLoad()
} catch (e) {
console.error('Cannot load stores:', e)
newStorageError = e
}
if (storageError || newStorageError) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'errors.storage_unavailable',
level: 'error',
})
}
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
window.syncConfig = useSyncConfigStore()
window.mergedConfig = useMergedConfigStore()
window.localConfig = useLocalConfigStore()
window.highlightConfig = useUserHighlightStore()
FaviconService.initFaviconService()
initServiceWorker(useNotificationsStore())
window.addEventListener('focus', () => updateFocus())
+ // both at once; a hosted site asks the chosen instance again in setConfig
+ const [staticConfig, backendConfig] = await Promise.all([
+ getStaticConfig(),
+ getBackendProvidedConfig(),
+ ])
+ const hosted = isHosted(staticConfig)
+ if (hosted && !chosenInstance()) {
+ await showInstancePicker({ pinia, i18n })
+ return app
+ }
+ if (hosted) setApiBase(chosenInstance())
+ useInstanceStore().set({ path: 'hosted', value: hosted })
+
const overrides = window.___pleromafe_dev_overrides || {}
- const server =
- overrides.target !== undefined ? overrides.target : window.location.origin
+ const server = hosted
+ ? chosenInstance()
+ : overrides.target !== undefined
+ ? overrides.target
+ : window.location.origin
useInstanceStore().set({ path: 'server', value: server })
- await setConfig({ store })
+ await setConfig({ store, staticConfig, backendConfig })
try {
await useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
} catch (e) {
window.splashError(e)
throw e
}
applyStyleConfig(useMergedConfigStore().mergedConfig, i18n.global)
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([
checkOAuthToken({ store }),
getInstancePanel({ store }),
getNodeInfo({ store }),
getInstanceConfig({ store }),
])
getTOS({ store })
getStickers({ store })
const router = createRouter({
history: createWebHistory(),
routes: getRoutes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some((m) => m.meta.dontScroll)) {
return {}
}
return savedPosition || { left: 0, top: 0 }
},
})
useI18nStore().setI18n(i18n)
// Global WS handlers
useChatsStore().attachSocket()
useInterfaceStore().attachSocket()
useStatusesStore().attachSocket()
app.use(router)
app.use(store)
app.use(i18n)
// Little thing to get out of invalid theme state
window.resetThemes = () => {
useInterfaceStore().resetThemeV3()
useInterfaceStore().resetThemeV3Palette()
useInterfaceStore().resetThemeV2()
}
app.use(vClickOutside)
app.use(VBodyScrollLock)
app.use(VueVirtualScroller)
app.component('FAIcon', FontAwesomeIcon)
app.component('FALayers', FontAwesomeLayers)
app.component('Status', Status)
app.component('RichContent', RichContent)
app.component('StillImage', StillImage)
// remove after vue 3.3
app.config.unwrapInjectedRef = true
app.mount('#app')
return app
}
export default afterStoreSetup
diff --git a/src/components/instance_picker/instance_picker.vue b/src/components/instance_picker/instance_picker.vue
new file mode 100644
index 0000000000..ba7a838bc8
--- /dev/null
+++ b/src/components/instance_picker/instance_picker.vue
@@ -0,0 +1,140 @@
+<template>
+ <main class="InstancePicker">
+ <form
+ class="panel panel-default instance-picker-panel"
+ @submit.prevent="submit"
+ >
+ <h1 class="panel-heading instance-picker-title">
+ {{ $t('hosted.title') }}
+ </h1>
+ <div class="panel-body">
+ <p>{{ $t('hosted.intro') }}</p>
+ <label
+ class="instance-picker-label"
+ for="instance-picker-input"
+ >{{ $t('hosted.instance') }}</label>
+ <div class="instance-picker-row">
+ <input
+ id="instance-picker-input"
+ v-model="input"
+ class="input"
+ type="text"
+ inputmode="url"
+ autocapitalize="off"
+ autocomplete="url"
+ spellcheck="false"
+ placeholder="pleroma.example"
+ :aria-invalid="!!error"
+ :aria-describedby="error ? 'instance-picker-error' : null"
+ :disabled="busy"
+ >
+ <button
+ class="btn button-default"
+ type="submit"
+ :disabled="busy || !input.trim()"
+ >
+ {{ busy ? $t('hosted.checking') : $t('hosted.continue') }}
+ </button>
+ </div>
+ <p
+ v-if="error"
+ id="instance-picker-error"
+ class="alert error instance-picker-error"
+ role="alert"
+ >
+ {{ $t(error) }}
+ </p>
+ <p class="faint instance-picker-note">
+ {{ $t('hosted.note') }}
+ </p>
+ </div>
+ </form>
+ </main>
+</template>
+
+<script>
+import {
+ checkInstance,
+ chooseInstance,
+ instanceOrigin,
+} from 'src/services/hosted/hosted.js'
+
+// Hosted mode, before anything else: which instance to use. Once one
+// answers, it is remembered and the app starts with it.
+export default {
+ props: {
+ check: { type: Function, default: checkInstance },
+ onChosen: { type: Function, default: () => window.location.reload() },
+ },
+ data: () => ({ input: '', error: null, busy: false }),
+ methods: {
+ async submit() {
+ const origin = instanceOrigin(this.input)
+ if (!origin) {
+ this.error = 'hosted.not_a_domain'
+ return
+ }
+ this.busy = true
+ this.error = null
+ const ok = await this.check(origin)
+ this.busy = false
+ if (!ok) {
+ this.error = 'hosted.unreachable'
+ return
+ }
+ chooseInstance(origin)
+ this.onChosen(origin)
+ },
+ },
+}
+</script>
+
+<style lang="scss">
+.InstancePicker {
+ display: grid;
+ grid-template-columns: minmax(0, 28em);
+ place-content: center;
+ min-height: 100vh;
+ padding: 1em;
+ box-sizing: border-box;
+
+ .panel-body {
+ padding: 0 1em 1em;
+
+ p {
+ padding: 0;
+ margin: 1em 0 0;
+ }
+ }
+
+ .instance-picker-title {
+ margin: 0;
+ font-size: 1.2em;
+ }
+
+ .instance-picker-label {
+ display: block;
+ margin: 1em 0 0.3em;
+ font-weight: 600;
+ }
+
+ .instance-picker-row {
+ display: flex;
+ gap: 0.5em;
+
+ .input {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+ }
+
+ .instance-picker-error {
+ margin-top: 0.75em;
+ }
+
+ .instance-picker-note {
+ margin-top: 1em;
+ font-size: 0.9em;
+ }
+}
+</style>
diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js
index 5cfee4fe97..c5d73f5260 100644
--- a/src/components/login_form/login_form.js
+++ b/src/components/login_form/login_form.js
@@ -1,89 +1,108 @@
import { mapActions, mapState } from 'pinia'
import { useAuthFlowStore } from 'src/stores/auth_flow.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useUsersStore } from 'src/stores/users.js'
import { getLoginUrl, getTokenWithCredentials } from 'src/api/oauth.js'
+import { forgetInstance } from 'src/services/hosted/hosted.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes)
const LoginForm = {
+ inject: {
+ reloadPage: { default: () => () => window.location.reload() },
+ },
data: () => ({
user: {},
error: false,
}),
computed: {
...mapState(useUsersStore, ['loggingIn']),
...mapState(useOAuthStore, ['clientId', 'clientSecret']),
- ...mapState(useInstanceStore, ['server', 'registrationOpen']),
+ ...mapState(useInstanceStore, [
+ 'server',
+ 'registrationOpen',
+ 'hosted',
+ 'instanceDomain',
+ ]),
...mapState(useAuthFlowStore, {
isTokenAuth: (store) => store.requiredToken,
isPasswordAuth: (store) => !store.requiredToken,
}),
},
methods: {
...mapActions(useAuthFlowStore, ['requireMFA', 'login']),
...mapActions(useOAuthStore, ['ensureAppToken']),
+ // hosted: back to the instance picker; the app registration and any
+ // login token belong to the instance being left
+ changeInstance() {
+ const oauth = useOAuthStore()
+ oauth.setClientData({ clientId: false, clientSecret: false })
+ oauth.setAppToken(false)
+ oauth.clearToken()
+ forgetInstance()
+ this.reloadPage()
+ },
submit() {
this.isTokenAuth ? this.submitToken() : this.submitPassword()
},
submitToken() {
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.ensureAppToken().then(() => {
window.location.href = getLoginUrl({
clientId: this.clientId,
instance: this.server,
})
})
},
submitPassword() {
this.error = false
// NOTE: we do not really need the app token, but obtaining a token and
// calling verify_credentials is the only way to ensure the app still works.
this.ensureAppToken().then(() => {
getTokenWithCredentials({
clientId: this.clientId,
clientSecret: this.clientSecret,
instance: this.server,
username: this.user.username,
password: this.user.password,
})
.then(({ data: result }) => {
this.login(result).then(() => {
this.$router.push({ name: 'friends' })
})
})
.catch((error) => {
if (error.errorData === 'mfa_required') {
this.requireMFA({ settings: error.error })
} else if (error.identifier === 'password_reset_required') {
this.$router.push({
name: 'password-reset',
params: { passwordResetRequested: true },
})
} else {
this.error = error
this.focusOnPasswordInput()
}
})
})
},
clearError() {
this.error = false
},
focusOnPasswordInput() {
const passwordInput = this.$refs.passwordInput
passwordInput.focus()
passwordInput.setSelectionRange(0, passwordInput.value.length)
},
},
}
export default LoginForm
diff --git a/src/components/login_form/login_form.scss b/src/components/login_form/login_form.scss
index 83bc20fe45..eb464a2f88 100644
--- a/src/components/login_form/login_form.scss
+++ b/src/components/login_form/login_form.scss
@@ -1,44 +1,56 @@
.login-panel {
.login-form {
display: flex;
flex-direction: column;
padding: 0.6em;
}
.btn {
min-height: 2em;
width: 10em;
}
.register {
flex: 1 1;
}
.login-bottom {
margin-top: 1em;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.3em 0.5em 0.6em;
line-height: 24px;
}
.login-error {
display: flex;
line-height: 2;
margin: 0.5em;
animation-name: shakeError;
animation-duration: 0.4s;
animation-timing-function: ease-in-out;
}
.error-message {
flex: 1;
}
}
+
+/* hosted: which instance this logs into, and a way to pick another */
+.login-hosted {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5em;
+ align-items: baseline;
+
+ .login-hosted-instance {
+ font-weight: 600;
+ }
+}
diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue
index eb5b3432db..6ee1ccf572 100644
--- a/src/components/login_form/login_form.vue
+++ b/src/components/login_form/login_form.vue
@@ -1,96 +1,110 @@
<template>
<div class="login-panel panel panel-default">
<!-- Default panel contents -->
<div class="panel-heading">
<h1 class="title">
{{ $t('login.login') }}
</h1>
</div>
<div class="panel-body">
<form
class="login-form"
@submit.prevent="submit"
>
<template v-if="isPasswordAuth">
<div class="form-group">
<label for="username">{{ $t('login.username') }}</label>
<input
id="username"
v-model="user.username"
:disabled="loggingIn"
class="input form-control"
:placeholder="$t('login.placeholder')"
>
</div>
<div class="form-group">
<label for="password">{{ $t('login.password') }}</label>
<input
id="password"
ref="passwordInput"
v-model="user.password"
:disabled="loggingIn"
class="input form-control"
type="password"
>
</div>
<div class="form-group">
<router-link :to="{name: 'password-reset'}">
{{ $t('password_reset.forgot_password') }}
</router-link>
</div>
</template>
<div
v-if="isTokenAuth"
class="form-group"
>
<p>{{ $t('login.description') }}</p>
</div>
+ <div
+ v-if="hosted"
+ class="form-group login-hosted"
+ >
+ <span class="login-hosted-instance">{{ instanceDomain }}</span>
+ <button
+ type="button"
+ class="button-unstyled -link"
+ @click="changeInstance"
+ >
+ {{ $t('hosted.change') }}
+ </button>
+ </div>
+
<div class="form-group">
<div class="login-bottom">
<div>
<router-link
v-if="registrationOpen"
:to="{name: 'registration'}"
class="register"
>
{{ $t('login.register') }}
</router-link>
</div>
<button
:disabled="loggingIn"
type="submit"
class="btn button-default"
>
{{ $t('login.login') }}
</button>
</div>
</div>
</form>
<div
v-if="error"
class="login-error alert error"
>
<span class="error-message">
{{ error }}
</span>
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
</div>
</div>
</div>
</template>
<script src="./login_form.js"></script>
<style src="./login_form.scss" />
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index f2f47a0b66..24c03a63f1 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -1,321 +1,325 @@
<template>
<Modal
v-if="showing"
class="media-modal-view"
@backdrop-clicked="hideIfNotSwiped"
>
<SwipeClick
v-if="currentMedia.type === 'image'"
ref="swipeClick"
class="modal-image-container"
:direction="swipeDirection"
:threshold="swipeThreshold"
:disable-click-threshold="swipeDisableClickThreshold"
@preview-requested="handleSwipePreview"
@swipe-finished="handleSwipeEnd"
@swipeless-clicked="hide"
>
<PinchZoom
ref="pinchZoom"
class="modal-image-container-inner"
selector=".modal-image"
reach-min-scale-strategy="reset"
stop-propagate-handled="stop-propgate-handled"
:allow-pan-min-scale="pinchZoomMinScale"
:min-scale="pinchZoomMinScale"
:reset-to-min-scale-limit="pinchZoomScaleResetLimit"
>
<img
:class="{ loading }"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@load="onImageLoaded"
>
</PinchZoom>
</SwipeClick>
<VideoAttachment
v-if="currentMedia.type === 'video'"
class="modal-image"
:attachment="currentMedia"
:controls="true"
/>
<audio
v-if="currentMedia.type === 'audio'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
controls
/>
<Flash
v-if="currentMedia.type === 'flash'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
/>
<button
v-if="canNavigate"
:title="$t('media_modal.previous')"
class="modal-view-button modal-view-button-arrow modal-view-button-arrow--prev"
@click.stop.prevent="goPrev"
>
<FAIcon
class="button-icon arrow-icon"
icon="chevron-left"
/>
</button>
<button
v-if="canNavigate"
:title="$t('media_modal.next')"
class="modal-view-button modal-view-button-arrow modal-view-button-arrow--next"
@click.stop.prevent="goNext"
>
<FAIcon
class="button-icon arrow-icon"
icon="chevron-right"
/>
</button>
<button
class="modal-view-button modal-view-button-hide"
:title="$t('media_modal.hide')"
@click.stop.prevent="hide"
>
<FAIcon
class="button-icon"
icon="times"
/>
</button>
<details
v-if="description"
open
class="description"
>
<summary>{{ $t('status.attachment_description') }}</summary>
<span>{{ description }}</span>
</details>
<span
v-if="media.length > 1"
class="counter"
>
{{ $t('media_modal.counter', { current: currentIndex + 1, total: media.length }, currentIndex + 1) }}
</span>
<span
v-if="loading"
class="loading-spinner"
>
<FAIcon
spin
icon="circle-notch"
size="5x"
/>
</span>
</Modal>
</template>
<script src="./media_modal.js"></script>
<style lang="scss">
$modal-view-button-icon-height: 3em;
$modal-view-button-icon-half-height: calc(#{$modal-view-button-icon-height} / 2);
$modal-view-button-icon-width: 3em;
$modal-view-button-icon-margin: 0.5em;
.media-modal-view {
@keyframes media-fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-image-container {
display: flex;
overflow: hidden;
align-items: center;
flex-direction: column;
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
flex-grow: 1;
justify-content: center;
&-inner {
width: 100%;
height: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
}
.description,
.counter {
/* Hardcoded since background is also hardcoded */
color: white;
text-shadow: 0 0 1em black, 0 0 1em black, 0 0 1em black;
margin: 1em 2em;
overflow: hidden;
}
.description + .counter {
margin-top: 0;
}
.description {
flex: 0 0 auto;
max-width: 80ch;
max-height: 9.5em;
summary {
margin-bottom: 0.5em;
display: inline-block;
font-weight: bold;
pointer-events: none;
}
span {
display: block;
overflow-y: auto;
min-height: 1em;
text-wrap: pretty;
max-height: 10.5em;
white-space: pre-wrap;
line-height: 1.5;
scrollbar-color: white transparent;
&::-webkit-scrollbar-button,
&::-webkit-scrollbar-thumb {
background-color: white;
}
}
}
.modal-image {
max-width: 100%;
max-height: 100%;
image-orientation: from-image; // NOTE: only FF supports this
animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
&.loading {
opacity: 0.5;
}
+
+ &.still-image img {
+ will-change: transform;
+ }
}
.loading-spinner {
width: 100%;
height: 100%;
position: absolute;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
svg {
color: white;
}
}
.modal-view-button {
border: 0;
padding: 0;
opacity: 0;
box-shadow: none;
background: none;
appearance: none;
overflow: visible;
cursor: pointer;
transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1);
height: $modal-view-button-icon-height;
width: $modal-view-button-icon-width;
.button-icon {
position: absolute;
height: $modal-view-button-icon-height;
width: $modal-view-button-icon-width;
font-size: 1rem;
line-height: $modal-view-button-icon-height;
color: #fff;
text-align: center;
background-color: rgb(0 0 0 / 30%);
}
}
.modal-view-button-arrow {
position: absolute;
display: block;
top: 50%;
margin-top: $modal-view-button-icon-half-height;
width: $modal-view-button-icon-width;
height: $modal-view-button-icon-height;
.arrow-icon {
position: absolute;
top: 0;
line-height: $modal-view-button-icon-height;
color: #fff;
text-align: center;
background-color: rgb(0 0 0 / 30%);
}
&--prev {
left: 0;
.arrow-icon {
left: $modal-view-button-icon-margin;
}
}
&--next {
right: 0;
.arrow-icon {
right: $modal-view-button-icon-margin;
}
}
}
.modal-view-button-hide {
position: absolute;
top: 0;
right: 0;
.button-icon {
top: $modal-view-button-icon-margin;
right: $modal-view-button-icon-margin;
}
}
}
.modal-view.media-modal-view {
z-index: var(--ZI_media_modal);
flex-direction: column;
.modal-view-button-arrow,
.modal-view-button-hide {
opacity: 0.75;
&:focus,
&:hover {
outline: none;
box-shadow: none;
}
&:hover {
opacity: 1;
}
}
overflow: hidden;
}
</style>
diff --git a/src/components/settings_modal/admin_tabs/users_tab.js b/src/components/settings_modal/admin_tabs/users_tab.js
index 68bc46aa2e..994b33398b 100644
--- a/src/components/settings_modal/admin_tabs/users_tab.js
+++ b/src/components/settings_modal/admin_tabs/users_tab.js
@@ -1,117 +1,119 @@
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import List from 'src/components/list/list.vue'
import ModerationTools from 'src/components/moderation_tools/moderation_tools.vue'
import Select from 'src/components/select/select.vue'
import AdminUserCard from 'src/components/settings_modal/admin_tabs/admin_user_card.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
const UsersTab = {
components: {
Checkbox,
Select,
BasicUserCard,
List,
AdminUserCard,
ModerationTools,
},
data() {
return {
filtersOrigin: 'local',
filtersActivity: 'all',
filtersPrivileges: 'all',
filtersNeedApproval: false,
filtersUnconfirmed: false,
filtersQuery: '',
filtersName: '',
filtersEmail: '',
expandedUser: null,
}
},
computed: {
/**
* do we filter for admins?
* @returns {boolean}
*/
filtersIsAdmin() {
return (
this.filtersPrivileges === 'admin' ||
this.filtersPrivileges === 'modsnadmins'
)
},
/**
* do we filter for moderators?
* @returns {boolean}
*/
filtersIsModerator() {
return (
this.filtersPrivileges === 'moderator' ||
this.filtersPrivileges === 'modsnadmins'
)
},
/**
* do we filter for active users?
* @returns {boolean}
*/
filtersActive() {
return this.filtersActivity === 'active'
},
/**
* do we filter for deactivated users?
* @returns {boolean}
*/
filtersDeactivated() {
return this.filtersActivity === 'deactivated'
},
/**
* do we filter for local users?
* @returns {boolean}
*/
filtersLocal() {
return this.filtersOrigin === 'local'
},
/**
* do we filter for external users?
* @return {boolean}
*/
filtersExternal() {
return this.filtersOrigin === 'external'
},
fetchOptions() {
const filters = {
isAdmin: this.filtersIsAdmin,
isModerator: this.filtersIsModerator,
active: this.filtersActive,
deactivated: this.filtersDeactivated,
local: this.filtersLocal,
external: this.filtersExternal,
needApproval: this.filtersNeedApproval,
unconfirmed: this.filtersUnconfirmed,
}
return {
query: this.filtersQuery,
name: this.filtersName,
email: this.filtersEmail,
pageSize: 50,
filters,
}
},
},
methods: {
fetchUsers(page) {
- return useAdminSettingsStore().fetchUsers({
- ...this.fetchOptions,
- page,
- })
+ return useAdminSettingsStore()
+ .fetchUsers({
+ ...this.fetchOptions,
+ page,
+ })
+ .then((k) => k.items)
},
},
watch: {
fetchOptions() {
this.$refs.usersList?.reset()
},
},
}
export default UsersTab
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index c12c91dbb0..fd2b6b272b 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,171 +1,171 @@
<template>
<!-- there is a brief moment during logout when old timeline gets forcibly deactivated -->
<div
v-if="timeline.fetcher"
:class="['Timeline', classes.root]"
>
<div
v-if="!embedded"
:class="classes.header"
>
<TimelineMenu
v-if="!embedded"
:timeline-name="timelineRef.name"
/>
<div
v-if="timeline.fetcher.loadingNewer && !showLoadButton"
class="loadingIndicator"
>
<FAIcon
fixed-width
icon="circle-notch"
spin
/>
</div>
<ScrollTopButton />
<template v-if="mobileLayout">
<div
v-if="showLoadButton"
class="rightside-button"
>
<button
class="button-unstyled loadmore-button"
:title="loadButtonString"
- @click.prevent="showNewStatuses"
+ @click.prevent="showNewStatuses(false)"
>
<FAIcon
fixed-width
icon="circle-plus"
/>
<div class="badge -counter">
{{ mobileLoadButtonString }}
</div>
</button>
</div>
<div
v-else-if="!timeline.fetcher.loadingNewer"
class="loadmore-text faint veryfaint rightside-icon"
:title="$t('timeline.up_to_date')"
:aria-disabled="true"
@click.prevent
>
<FAIcon
fixed-width
icon="check"
/>
</div>
</template>
<template v-else>
<button
v-if="showLoadButton"
class="button-default loadmore-button"
- @click.prevent="showNewStatuses"
+ @click.prevent="showNewStatuses(false)"
>
{{ loadButtonString }}
</button>
<div
v-else
class="loadmore-text faint"
@click.prevent
>
{{ $t('timeline.up_to_date') }}
</div>
</template>
<QuickFilterSettings
v-if="!mobileLayout"
class="rightside-button"
/>
<QuickViewSettings
class="rightside-button"
/>
</div>
<div :class="classes.body">
<div
class="timeline"
ref="timeline"
role="feed"
aria-labelledby="timeline-header"
>
<template
v-for="element in heightChart"
:key="element.id"
>
<Conversation
v-if="element.type === 'item'"
class="timeline-conversation"
:status-id="element.id"
:data-vs-height="element.height"
:data-vs-top="element.top"
:data-vs-id="element.id"
@suspendable-state-change="changeSuspendState"
@height-change="updateVirtualHeight"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
aria-hidden="true"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
<template v-if="!hideEmpty && count === 0">
<div
v-if="timeline.fetcher.loadingNewer || timeline.fetcher.loadingOlder"
class="timeline-placeholder"
>
<FAIcon
icon="circle-notch"
spin
size="4x"
/>
</div>
<div
v-else
class="timeline-placeholder faint"
>
{{ $t('timeline.no_statuses') }}
</div>
</template>
</div>
<div v-if="!embedded || footerSlipgate" :class="classes.footer">
<teleport
:to="footerSlipgate"
:disabled="!embedded || !footerSlipgate"
>
<div
v-if="timeline.fetcher.bottomedOut"
class="new-status-notification text-center faint"
>
{{ $t('timeline.no_more_statuses') }}
</div>
<div
v-else-if="timeline.fetcher.loadingOlder"
class="new-status-notification text-center"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</div>
<button
v-else-if="timeline.minId !== ''"
class="button-unstyled -link"
@click.prevent="fetchOlderStatuses()"
>
<div class="new-status-notification text-center">
{{ $t('timeline.load_older') }}
</div>
</button>
</teleport>
<!-- spacer to avoid having empty shrug -->
<span v-if="embedded && footerSlipgate" />
</div>
</div>
</template>
<script src="./timeline.js"></script>
<style src="./timeline.scss" lang="scss"> </style>
diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js
index 4f15d4926a..bf6881f671 100644
--- a/src/components/user_avatar/user_avatar.js
+++ b/src/components/user_avatar/user_avatar.js
@@ -1,56 +1,62 @@
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useUsersStore } from 'src/stores/users.js'
+import { serverUrl } from 'src/api/api_base.js'
+
import { library } from '@fortawesome/fontawesome-svg-core'
import { faPeopleGroup, faRobot } from '@fortawesome/free-solid-svg-icons'
library.add(faRobot, faPeopleGroup)
const UserAvatar = {
props: {
// UserID of a user to show avatar of
userId: {
required: true,
type: String,
},
// Use less space and use alternative roundness
compact: {
required: false,
type: Boolean,
default: false,
},
// Override avatar image URL, useful for profile editing
url: {
required: false,
type: String,
default: null,
},
},
data() {
return {
showPlaceholder: false,
- defaultAvatar: `${useInstanceStore().server + useInstanceStore().instanceIdentity.defaultAvatar}`,
+ // the instance's file (hosted: a full URL on it already)
+ defaultAvatar: serverUrl(
+ useInstanceStore().server,
+ useInstanceStore().instanceIdentity.defaultAvatar,
+ ),
betterShadow: useInterfaceStore().browserSupport.cssFilter,
}
},
computed: {
user() {
return useUsersStore().findUser(this.userId)
},
showActorTypeIndicator() {
return !useMergedConfigStore().mergedConfig.hideBotIndication
},
},
methods: {
imgSrc(src) {
return !src || this.showPlaceholder ? this.defaultAvatar : src
},
imageLoadError() {
this.showPlaceholder = true
},
},
}
export default UserAvatar
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 09140da654..0662deaa4f 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -1,622 +1,624 @@
import {
isEqual,
escape as ldEscape,
unescape as ldUnescape,
merge,
} from 'lodash-es'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import { suggest } from 'src/components/emoji_input/suggestor.js'
import FollowButton from 'src/components/follow_button/follow_button.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Select from 'src/components/select/select.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMediaViewerStore } from 'src/stores/media_viewer'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { usePostStatusStore } from 'src/stores/post_status'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
+import { serverUrl } from 'src/api/api_base.js'
import { updateProfile } from 'src/api/user.js'
import { propsToNative } from 'src/services/attributes_helper/attributes_helper.service.js'
import localeService from 'src/services/locale/locale.service.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBirthdayCake,
faClockRotateLeft,
faEdit,
faExpandAlt,
faExternalLinkAlt,
faRss,
faSave,
faSearchPlus,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSave,
faRss,
faBell,
faSearchPlus,
faExternalLinkAlt,
faEdit,
faTimes,
faExpandAlt,
faBirthdayCake,
faClockRotateLeft,
)
const KNOWN_TAGS = new Set([
'mrf_tag:media-force-nsfw',
'mrf_tag:media-strip',
'mrf_tag:force-unlisted',
'mrf_tag:sandbox',
'mrf_tag:disable-remote-subscription',
'mrf_tag:disable-any-subscription',
])
export default {
props: {
// Enables all the options for profile editing, used in settings -> profile tab
editable: {
required: false,
default: false,
type: Boolean,
},
// ID of user to show data of
userId: {
required: true,
type: String,
},
// Use a compact layout that hides bio, stats etc.
hideBio: {
required: false,
default: false,
type: Boolean,
},
// Hide action buttons
hideButtons: {
required: false,
default: false,
type: Boolean,
},
// default - open profile, 'zoom' - zoom, function - call function
avatarAction: {
required: false,
type: String,
default: 'default',
},
// Show note editor if supported
hasNoteEditor: {
required: false,
type: Boolean,
default: false,
},
// Show close icon (for popovers)
showClose: {
required: false,
type: Boolean,
default: false,
},
// Show close icon (for popovers)
showExpand: {
required: false,
type: Boolean,
default: false,
},
// Disable forced 3:1 aspect ratio
compact: {
required: false,
type: Boolean,
default: false,
},
},
components: {
DialogModal: defineAsyncComponent(
() => import('src/components/dialog_modal/dialog_modal.vue'),
),
UserAvatar,
Checkbox,
RemoteFollow: defineAsyncComponent(
() => import('src/components/remote_follow/remote_follow.vue'),
),
ModerationTools: defineAsyncComponent(
() => import('src/components/moderation_tools/moderation_tools.vue'),
),
AccountActions: defineAsyncComponent(
() => import('src/components/account_actions/account_actions.vue'),
),
ProgressButton,
FollowButton,
Select,
UserLink,
UserNote: defineAsyncComponent(
() => import('src/components/user_note/user_note.vue'),
),
UserTimedFilterModal: defineAsyncComponent(
() =>
import(
'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
),
),
ColorInput,
EmojiInput,
ImageCropper: defineAsyncComponent(
() => import('src/components/image_cropper/image_cropper.vue'),
),
},
data() {
const user = useUsersStore().findUser(this.userId)
return {
followRequestInProgress: false,
// Editable stuff
editImage: false,
newName: user.name_unescaped,
editingName: false,
newBio: ldUnescape(user.description),
editingBio: false,
newAvatar: null,
newAvatarFile: null,
newBanner: null,
newBannerFile: null,
newActorType: user.actor_type,
newBirthday: user.birthday,
newShowBirthday: user.show_birthday,
newShowRole: user.show_role,
newFields: user.fields?.map((field) => ({
name: field.name,
value: field.value,
})),
editingFields: false,
}
},
created() {
useUsersStore().fetchUserRelationship(this.user.id)
},
computed: {
escapedNewBio() {
return ldEscape(this.newBio).replaceAll('\n', '<br>')
},
somethingToSave() {
if (this.newName !== this.user.name_unescaped) return true
if (this.newBio !== ldUnescape(this.user.description)) return true
if (this.newAvatar !== null) return true
if (this.newBanner !== null) return true
if (this.newActorType !== this.user.actor_type) return true
if (this.newBirthday !== this.user.birthday) return true
if (this.newShowBirthday !== this.user.show_birthday) return true
if (this.newShowRole !== this.user.show_role) return true
if (
!isEqual(
this.newFields,
this.user.fields?.map((field) => ({
name: field.name,
value: field.value,
})),
)
)
return true
return false
},
groupActorAvailable() {
return useInstanceCapabilitiesStore().groupActorAvailable
},
availableActorTypes() {
return this.groupActorAvailable
? ['Person', 'Service', 'Group']
: ['Person', 'Service']
},
user() {
return useUsersStore().findUser(this.userId)
},
role() {
return this.user.role
},
relationship() {
return useUsersStore().relationship(this.userId)
},
isOtherUser() {
return this.user.id !== useUsersStore().currentUser?.id
},
subscribeUrl() {
const serverUrl = new URL(this.user.statusnet_profile_url)
return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus`
},
loggedIn() {
return useUsersStore().currentUser
},
dailyAvg() {
const days = Math.ceil(
(Date.now() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000),
)
return Math.round(this.user.statuses_count / days)
},
emoji() {
return useEmojiStore().customEmoji.map((e) => ({
shortcode: e.displayText,
static_url: e.imageUrl,
url: e.imageUrl,
}))
},
userHighlightType: {
get() {
return useUserHighlightStore().get(this.user.screen_name).type
},
set(type) {
if (type !== 'disabled') {
useUserHighlightStore().setAndSave({
user: this.user.screen_name,
value: { type },
})
} else {
useUserHighlightStore().unsetAndSave({ user: this.user.screen_name })
}
},
},
userHighlightColor: {
get() {
return useUserHighlightStore().get(this.user.screen_name).color
},
set(color) {
useUserHighlightStore().setAndSave({
user: this.user.screen_name,
value: { color },
})
},
},
visibleRole() {
if (!this.user.show_role && !this.user.adminData) {
return
}
const rights = this.user.rights
if (!rights) {
return
}
const validRole = rights.admin || rights.moderator
const roleTitle = rights.admin ? 'admin' : 'moderator'
return validRole && roleTitle
},
hideFollowsCount() {
return this.isOtherUser && this.user.hide_follows_count
},
hideFollowersCount() {
return this.isOtherUser && this.user.hide_followers_count
},
showModerationMenu() {
const privileges = this.loggedIn.privileges
return (
this.loggedIn.role === 'admin' ||
privileges.has('users_manage_activation_state') ||
privileges.has('users_delete') ||
privileges.has('users_manage_tags')
)
},
hasNote() {
return this.relationship.note
},
supportsNote() {
return 'note' in this.relationship
},
muteExpiryAvailable() {
return Object.hasOwn(this.user, 'mute_expires_at')
},
muteExpiry() {
return this.user.mute_expires_at === false
? this.$t('user_card.mute_expires_forever')
: this.$t('user_card.mute_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
blockExpiryAvailable() {
return Object.hasOwn(this.user, 'block_expires_at')
},
blockExpiry() {
return this.user.block_expires_at == null
? this.$t('user_card.block_expires_forever')
: this.$t('user_card.block_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
formattedBirthday() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.birthday &&
new Date(Date.parse(this.user.birthday)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
formattedJoinDate() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.created_at &&
new Date(Date.parse(this.user.created_at)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
// Editable stuff
avatarImgSrc() {
const currentUrl =
this.user.profile_image_url_original || this.defaultAvatar
if (!this.editable) return currentUrl
const newUrl =
this.newAvatar === null ? this.defaultAvatar : this.newAvatar
return this.newAvatar === null ? currentUrl : newUrl
},
bannerImgSrc() {
const currentUrl = this.user.cover_photo || this.defaultBanner
if (!this.editable) return currentUrl
const newUrl =
this.newBanner === null ? this.defaultBanner : this.newBanner
return this.newBanner === null ? currentUrl : newUrl
},
+ // the instance's files (hosted: a full URL on it already)
defaultAvatar() {
- return (
- useInstanceStore().server +
- useInstanceStore().instanceIdentity.defaultAvatar
+ return serverUrl(
+ useInstanceStore().server,
+ useInstanceStore().instanceIdentity.defaultAvatar,
)
},
defaultBanner() {
- return (
- useInstanceStore().server +
- useInstanceStore().instanceIdentity.defaultBanner
+ return serverUrl(
+ useInstanceStore().server,
+ useInstanceStore().instanceIdentity.defaultBanner,
)
},
isDefaultAvatar() {
const baseAvatar = useInstanceStore().instanceIdenitity.defaultAvatar
return (
!useUsersStore().currentUser.profile_image_url ||
useUsersStore().currentUser.profile_image_url.includes(baseAvatar)
)
},
isDefaultBanner() {
const baseBanner = useInstanceStore().instanceIdentity.defaultBanner
return (
!useUsersStore().currentUser.cover_photo ||
useUsersStore().currentUser.cover_photo.includes(baseBanner)
)
},
fieldsLimits() {
return useInstanceStore().limits.fieldsLimits
},
maxFields() {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
},
emojiUserSuggestor() {
return suggest({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
emojiSuggestor() {
return suggest({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
hideUserStats() {
return this.mergedConfig.hideUserStats
},
hideRemarks() {
return this.mergedConfig.userCardHidePersonalMarks
},
...mapState(useMergedConfigStore, ['mergedConfig']),
},
methods: {
isKnownTag(tag) {
return KNOWN_TAGS.has(tag)
},
muteUser() {
this.$refs.timedMuteDialog.optionallyPrompt()
},
unmuteUser() {
return useUsersStore().unmuteUser(this.user.id)
},
subscribeUser() {
return useUsersStore().subscribeUser(this.user.id)
},
unsubscribeUser() {
return useUsersStore().unsubscribeUser(this.user.id)
},
linkClicked({ target }) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
zoomAvatar() {
const attachment = {
url: this.user.profile_image_url_original,
type: 'image',
}
useMediaViewerStore().setMedia([attachment])
useMediaViewerStore().setCurrentMedia(attachment)
},
mentionUser() {
usePostStatusStore().openPostStatusModal({
profileMention: this.user,
})
},
onAvatarClickHandler(e) {
if (this.onAvatarClick) {
e.preventDefault()
this.onAvatarClick()
}
},
// Editable stuff
changeAvatar() {
this.editImage = 'avatar'
},
changeBanner() {
this.editImage = 'banner'
},
submitImage({ canvas, file }) {
if (canvas) {
return canvas.toBlob((data) =>
this.submitImage({ canvas: null, file: data }),
)
}
const reader = new window.FileReader()
reader.onload = (e) => {
const dataUrl = e.target.result
if (this.editImage === 'avatar') {
this.newAvatar = dataUrl
this.newAvatarFile = file
} else {
this.newBanner = dataUrl
this.newBannerFile = file
}
this.editImage = false
}
reader.readAsDataURL(file)
},
resetImage() {
if (this.editImage === 'avatar') {
this.newAvatar = null
this.newAvatarFile = null
} else {
this.newBanner = null
this.newBannerFile = null
}
this.editImage = false
},
addField() {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
}
},
deleteField(index) {
this.newFields.splice(index, 1)
},
propsToNative(props) {
return propsToNative(props)
},
cancelImageText() {
return
},
resetState() {
const user = useUsersStore().currentUser
this.newName = user.name_unescaped
this.newBio = ldUnescape(user.description)
this.newAvatar = null
this.newAvatarFile = null
this.newBanner = null
this.newBannerFile = null
this.newActorType = user.actor_type
this.newBirthday = user.birthday
this.newShowBirthday = user.show_birthday
this.newShowRole = user.show_role
this.newFields = user.fields.map((field) => ({
name: field.name,
value: field.value,
}))
},
updateProfile() {
const params = {
note: this.newBio,
// Backend notation.
display_name: this.newName,
fields_attributes: this.newFields.filter((el) => el != null),
show_role: !!this.newShowRole,
birthday: this.newBirthday || '',
show_birthday: !!this.newShowBirthday,
}
if (this.newActorType) {
params.actor_type = this.newActorType
}
if (this.newAvatarFile !== null) {
params.avatar = this.newAvatarFile
}
if (this.newBannerFile !== null) {
params.header = this.newBannerFile
}
updateProfile({ params })
.then(({ data: user, ...rest }) => {
this.newFields.splice(this.newFields.length)
merge(this.newFields, user.fields)
useUsersStore().addNewUsers({ data: user, ...rest })
this.resetState()
})
.catch((error) => {
this.displayUploadError(error)
})
},
displayUploadError(error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [error.message],
level: 'error',
})
},
},
}
diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index fa6a3de030..e56c3d0170 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,411 +1,434 @@
-import { first, last } from 'lodash-es'
-import { computed, nextTick, ref, toValue, watch } from 'vue'
+import { debounce, first, last, throttle } from 'lodash-es'
+import { computed, nextTick, onUnmounted, ref, toValue, watch } from 'vue'
export function useVirtualScrolling({
// For debugging
name = 'Generic',
// Master toggle
enabled,
// List of items
list,
// Container of items, used for measuring scroll position
body,
// vertical offset to account for fixed and sticky headers
offset,
// useScrollPosition composable, used to prevent dupicating instances
scrollPositionInstance,
// buffer zone, the amount of placeholder heights to include
buffer,
// whether to use scroll compensation when elements above anchor change
scrollCompensation,
// How to handle collapse/expansion (going from 0 elements to full and back)
// - false - don't do scroll compensation at all
// - 'height' - compensate scroll according to list's height
// - 'item' - same as height but uses anchor element's top offset
// instead of whole height
collapseMode,
// Placeholder height specification. Must be a function.
// function will be called either:
// - without arguments (for generic placeholder, i.e. buffer zone size)
// - with id (for specific item placeholders)
// function must return ref pointing to height
getPlaceholderHeight,
// Invert grow direction. Useful for chat views. Makes scroll compensation
// work in reverse - adding items to bottom scrolls down, adding items to
// top doesn't scroll up.
invertDirection = false,
}) {
// # Suspension
const unsuspendibleIds = ref(new Set())
const changeSuspendState = ({ id, suspendable }) => {
if (suspendable) {
unsuspendibleIds.value.delete(id)
} else {
unsuspendibleIds.value.add(id)
}
}
// # Heights mapping.
const heights = ref(new Map())
- const heightChart = computed(() => {
+ const heightChart = ref([])
+ const updateHeightChart = debounce(() => {
// Map every height and suspendable state
const chart = list.value.map((item) => {
const { id } = item
const height = (() => {
if (heights.value.has(id)) {
return heights.value.get(id)
} else {
return getPlaceholderHeight(id).value
}
})()
const suspendable = !unsuspendibleIds.value.has(id)
const real = heights.value.has(id)
return { id, height, suspendable, real, visible: true }
})
// Walk over the list to set top offsets
chart.reduce((sum, item) => {
item.top = sum
return sum + item.height
}, 0)
- return chart
+ heightChart.value = chart
+ }, 32) // 32ms = ~30fps
+ watch([list, heights, unsuspendibleIds], updateHeightChart, {
+ immediate: true,
+ deep: true,
})
+ onUnmounted(() => {
+ updateHeightChart.cancel()
+ })
+
const updateVirtualHeight = ({ id, height }) => {
heights.value.set(id, height)
}
const {
top: scrollY,
scrollBy,
vHeight,
hasReachedTop,
hasReachedBottom,
} = scrollPositionInstance
// Real scroll boundary, relative to body's bounds
const topScrollBoundary = ref(0)
const bottomScrollBoundary = ref(0)
const updateBoundaries = () => {
if (!toValue(enabled)) return
if (!body.value) return // Not mounted yet
const { top } = body.value.getBoundingClientRect()
const distanceItemTopToWindowTop = 0 - top + (toValue(offset) ?? 0)
const distanceItemTopToWindowBottom = vHeight.value - top
// Technically, bottom scroll boundary should be distance
// from element's top border to window's bottom border,
// but it just so happens that it is equal to this.
// You can verify it by drawing the boxes and measuring
// distances yourself, I know I did. Geometry, man...
topScrollBoundary.value = distanceItemTopToWindowTop
bottomScrollBoundary.value = distanceItemTopToWindowBottom
}
const windowWatcher = watch(vHeight, updateBoundaries)
const scrollWatcher = watch(scrollY, updateBoundaries)
const heightWatcher = watch(heightChart, updateBoundaries)
const bodyWatcher = watch(body, updateBoundaries)
watch(vHeight, (neu, old) => {
if (toValue(invertDirection)) {
// Only compensate (stick to bottom) when shrinking
scrollBy(0, Math.max(old - neu, 0))
// compensating in other direction causes overscrolling
}
})
const pauseWatchers = () => {
windowWatcher.pause()
scrollWatcher.pause()
heightWatcher.pause()
bodyWatcher.pause()
}
const resumeWatchers = (skipUpdate = false) => {
windowWatcher.resume()
scrollWatcher.resume()
heightWatcher.resume()
bodyWatcher.resume()
if (skipUpdate) return
updateBoundaries()
}
watch(enabled, (val) => {
if (val) {
resumeWatchers()
} else {
pauseWatchers()
}
})
// # Visiblity
- // Add buffer zone to boundary, equal to approx 3 items heights
+ // Add buffer zone to boundary, equal to approx 10 items heights
const bufferZone = computed(
- () => getPlaceholderHeight().value * (toValue(buffer) ?? 3),
+ () => getPlaceholderHeight().value * (toValue(buffer) ?? 10),
)
const checkVisible = ({ top, height }) => {
const itemTopBoundary = top
const itemBottomBoundary = top + height
// Include buffer zone
const finalTopScrollBoundary = topScrollBoundary.value - bufferZone.value
const finalBottomScrollBoundary =
bottomScrollBoundary.value + bufferZone.value
// To be visible, item's bottom boundary shoud be below top scroll boundary)
const isBelowTopBoundary = itemBottomBoundary > finalTopScrollBoundary
// To be visible, item's top boundary shoud be above bottom scroll boundary)
const isAboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
// This accounts for the case where item's boundaries exceed scroll boundary
return isBelowTopBoundary && isAboveBottomBoundary
}
- const heightChartVisibility = computed(() =>
- heightChart.value.map((heightChartItem) => ({
- ...heightChartItem,
- visible: checkVisible(heightChartItem),
- })),
+ const heightChartVisibility = ref(null)
+
+ const recalculateChartVisibility = throttle(
+ (newVal, oldVal) => {
+ heightChartVisibility.value = heightChart.value.map(
+ (heightChartItem) => ({
+ ...heightChartItem,
+ visible: checkVisible(heightChartItem),
+ }),
+ )
+ },
+ 32, // 32ms = ~30fps
+ { leading: true, trailing: true },
)
+ watch(
+ [heightChart, topScrollBoundary, bottomScrollBoundary, bufferZone],
+ recalculateChartVisibility,
+ { immediate: true },
+ )
+
const heightChartGrouped = computed(() => {
const chart = enabled.value ? heightChartVisibility : heightChart
// Group invisible items into spacers
return chart.value.reduce((acc, heightChartItem, index) => {
const { suspendable, visible, height, top, id } = heightChartItem
const present = visible || !suspendable
if (present) {
return [...acc, { type: 'item', height, top, id }]
} else {
// Reusing previous item if possible
const previousItem = acc[acc.length - 1]
const usingPreviousItem = previousItem?.type === 'spacer'
// We only really care for height and id of spacer, everything else
// is just for debugging
const spacer = usingPreviousItem
? previousItem
: {
type: 'spacer',
top: Number.POSITIVE_INFINITY,
height: 0,
id: `i${index}`, // used for v-for key attribute
ids: new Set(),
}
spacer.ids.add(id)
spacer.height += height
if (top < spacer.top) spacer.top = top
// If we used previous item there is no need to push it to array
if (usingPreviousItem) {
return acc
} else {
return [...acc, spacer]
}
}
}, [])
})
// ## Scroll compensation
watch(
heightChart,
async (newVal, oldVal) => {
if (newVal.length === 0 && oldVal.length === 0) return 0
const explosion = oldVal.length === 0 && newVal.length !== 0
const implosion = oldVal.length !== 0 && newVal.length === 0
const compensation = toValue(scrollCompensation)
const diff = (() => {
if (explosion) {
if (!compensation) return 0
if (toValue(collapseMode) === 'height' || toValue(invertDirection)) {
const newBottomElement = last(newVal)
return newBottomElement.top + newBottomElement.height
} else {
return 0
}
} else if (implosion) {
if (!compensation) return 0
const oldBottomElement = last(oldVal)
return 0 - oldBottomElement.top - oldBottomElement.height
} else {
const contextChange = (() => {
// HACK ignore date separators and fake messages in chat view
const oldIds = new Set(
oldVal
.map(({ id }) => id)
.filter((id) => !id.startsWith('fake-')),
)
const newIds = new Set(
newVal
.map(({ id }) => id)
.filter((id) => !id.startsWith('fake-')),
)
if (oldVal.length <= newVal.length) {
return [...oldIds].some((id) => !newIds.has(id))
} else {
return [...newIds].some((id) => !oldIds.has(id))
}
})()
if (contextChange) {
return 0
}
/* Ok, here's a thing. Both Timeline and Conversation have virtual scrolling.
* But since Conversation can be inside Timeline (in fact it's chock-full of
* them) it creates a problem. Virtual scrolling involves a lot of scroll
* compensation. Elements appearing, disappearing, changing sizes etc. What
* happens if a single conversation gets expanded? Conversation sees influx
* of new posts and compensates for them appearing above to keep old items
* in screen. Next, timeline sees that Conversation changed height and ALSO
* compensates for that changed height. We get double the compensation and
* result ends up being all wrong, we scrolled way past the conversation.
*
* Easy, just don't compensate in Timeline, right? Wrong. We still have two
* cases where we do want compensation in Timeline - all the cases that
* aren't handled by Conversation. Namely:
* - Change in amount of Conversations
* - Change in height of freshly-rendered Conversations.
*
* First one is simple. We scrolled down and made a new post - it appeared
* at the top but we don't want screen to scroll.
*
* Second one is tricky. Here's a real situation:
* 1. We scroll down in Timeline.
* 2. Open a thread (navigate to it, not expand!)
* 3. Press "back".
* Now we have a situation: vue-router restored our scroll position but
* since Timeline was removed and re-created its heightChart is full of
* fake placeholders. This naturally means our actual scroll position is
* all wrong. TODO: Store heightchart in timelines store?
* But it gets worse - when you start scrolling up elements begin getting
* their real heights back, and since there's no compensation timeline gets
* all jumpy! We can somewhat live with wrong scroll position but timeline
* being jumpy is unacceptable.
*
* TODO: Probably a good way to avoid it all is just to:
* - Don't do scroll compensation when nested
* - For expanded conversations use animated priority scrollTo
* something to do for Virtual Scrolling 2.2
*
* So, basically - if amount of elements changes, or amount of REAL elements
* changes - compensate anyway even if compensation is disabled.
*/
const isSticking = toValue(invertDirection)
? hasReachedBottom.value
: hasReachedTop.value
const compensateBecauseLengthChange =
!isSticking && newVal.length !== oldVal.length
const newReal = newVal.filter(({ real }) => real).length
const oldReal = oldVal.filter(({ real }) => real).length
const compensateBecauseReal =
oldReal > 0 && newReal > 0 && oldReal !== newReal
if (
!(
compensation ||
compensateBecauseReal ||
compensateBecauseLengthChange
)
) {
return 0
}
// Section is used for debugging only
const getShift = (section, a, b, find, getter) => {
const aItem = find(a)
if (!aItem) return 0
const bItem = b.find(({ id }) => id === aItem.id)
if (!bItem) return 0
return getter(bItem) - getter(aItem)
}
const expansion = (() => {
if (newVal.length < oldVal.length) return 0
return getShift(
'expansion',
oldVal.filter((item) => checkVisible(item)),
newVal,
(list) =>
list.find(
({ top, height }) => top + height < topScrollBoundary.value,
) ?? first(list),
({ top, height }) => top + height,
)
})()
const inverseCompensation = (() => {
if (!toValue(invertDirection)) return 0
if (newVal.length < oldVal.length) return 0
const shift = getShift(
'inverse',
oldVal.filter((item) => checkVisible(item)),
newVal,
(list) =>
list.findLast(({ top }) => top > bottomScrollBoundary.value) ??
last(list),
({ top }) => top,
)
if (isSticking) {
// Don't do 99999 here, it breaks virtual scrolling
const { height } = body.value.getBoundingClientRect()
return height // keep sticking
} else {
return shift
}
})()
const collapsing = (() => {
if (newVal.length >= oldVal.length) return 0
return -getShift(
'collapse',
newVal,
oldVal,
(list) => first(list),
({ top, height }) => top + height,
)
})()
return (
(toValue(invertDirection) ? inverseCompensation : expansion) +
collapsing
)
}
})()
if (diff !== 0) {
// Scroll by amount offset changed to keep it in view
topScrollBoundary.value += diff
bottomScrollBoundary.value += diff
await nextTick()
await scrollBy(0, diff)
}
resumeWatchers()
},
{ flush: 'post' },
)
// Misc
const reset = () => {
unsuspendibleIds.value = new Set()
heights.value = new Map()
}
return {
heightChart: heightChartGrouped,
changeSuspendState,
updateVirtualHeight,
pauseWatchers,
resumeWatchers,
updateBoundaries,
reset,
}
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 6b95fb98ac..d21c11b549 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1,2155 +1,2166 @@
{
"about": {
"mrf": {
"federation": "Federation",
"keyword": {
"keyword_policies": "Keyword policies",
"ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"reject": "Reject",
"replace": "Replace",
"is_replaced_by": "→"
},
"mrf_policies": "Enabled MRF policies",
"mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:",
"simple": {
"simple_policies": "Instance-specific policies",
"instance": "Instance",
"reason": "Reason",
"not_applicable": "N/A",
"accept": "Accept",
"accept_desc": "This instance only accepts messages from the following instances:",
"reject": "Reject",
"reject_desc": "This instance will not accept messages from the following instances:",
"quarantine": "Quarantine",
"quarantine_desc": "This instance will send only public posts to the following instances:",
"ftl_removal": "Removal from \"Known Network\" Timeline",
"ftl_removal_desc": "This instance removes these instances from \"Known Network\" timeline:",
"media_removal": "Media Removal",
"media_removal_desc": "This instance removes media from posts on the following instances:",
"media_nsfw": "Media force-set as sensitive",
"media_nsfw_desc": "This instance forces media to be set sensitive in posts on the following instances:"
}
},
"staff": "Staff",
"terms": "Terms of Service"
},
"announcements": {
"page_header": "Announcements",
"title": "Announcement",
"mark_as_read_action": "Mark as read",
"post_form_header": "Post announcement",
"post_placeholder": "Type your announcement content here...",
"post_action": "Post",
"post_error": "Error: {error}",
"close_error": "Close",
"delete_action": "Delete",
"start_time_prompt": "Start time: ",
"end_time_prompt": "End time: ",
"all_day_prompt": "This is an all-day event",
"published_time_display": "Published at {time}",
"start_time_display": "Starts at {time}",
"end_time_display": "Ends at {time}",
"edit_action": "Edit",
"submit_edit_action": "Submit",
"cancel_edit_action": "Cancel",
"inactive_message": "This announcement is inactive"
},
"shoutbox": {
"title": "Shoutbox"
},
"domain_mute_card": {
"mute": "Mute",
"mute_progress": "Muting…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…"
},
"exporter": {
"export": "Export",
"processing": "Processing, you'll soon be asked to download your file"
},
"features_panel": {
"shout": "Shoutbox",
"pleroma_chat_messages": "Pleroma Chat",
"gopher": "Gopher",
"media_proxy": "Media proxy",
"scope_options": "Scope options",
"text_limit": "Text limit",
"title": "Features",
"who_to_follow": "Who to follow",
"upload_limit": "Upload limit"
},
"finder": {
"error_fetching_user": "Error fetching user",
"find_user": "Find user"
},
+ "hosted": {
+ "title": "Pleroma-FE",
+ "intro": "Log into your fediverse account with this frontend. Which instance is it on?",
+ "instance": "Your instance",
+ "continue": "Continue",
+ "checking": "Checking…",
+ "not_a_domain": "That does not look like an instance name. Type its domain, like pleroma.example.",
+ "unreachable": "That instance did not answer, or does not let other sites use its API.",
+ "note": "Works with Pleroma and Akkoma. Nothing is stored here except the name of your instance; your login goes through the instance itself.",
+ "change": "Change instance"
+ },
"general": {
"apply": "Apply",
"submit": "Submit",
"more": "More",
"logo": "Logo",
"select_file": "Select file",
"no_more": "No more items",
"loading": "Loading…",
"preview": "Preview",
"generic_error": "An error occured",
"generic_error_message": "An error occured: {0}",
"generic_error_details": "Technical info:",
"error_retry": "Please try again",
"refresh_required": "Refresh required",
"refresh_required_content": "Failed to load UI code. Most likely frontend was updated on server, you'll need to refresh the page.",
"refresh_required_refresh": "Refresh page",
"retry": "Try again",
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
"never_show_again": "Never show again",
"dismiss": "Dismiss",
"cancel": "Cancel",
"disable": "Disable",
"enable": "Enable",
"confirm": "Confirm",
"verify": "Verify",
"close": "Close",
"undo": "Undo",
"yes": "Yes",
"no": "No",
"none": "None",
"add": "Add",
"suggestions": "Autocomplete suggestions",
"remove": "Remove",
"move_dn": "Move downwards",
"move_up": "Move upwards",
"not_applicable": "N/A",
"not_available": "N/A",
"peek": "Peek",
"scroll_to_top": "Scroll to top",
"role": {
"admin": "Admin",
"moderator": "Moderator"
},
"unpin": "Unpin item",
"pin": "Pin item",
"flash_content": "Click to show Flash content using Ruffle (Experimental, may not work).",
"flash_security": "Note that this can be potentially dangerous since Flash content is still arbitrary code.",
"flash_fail": "Failed to load flash content, see console for details.",
"scope_in_timeline": {
"local": "Non-federated",
"direct": "Direct",
"private": "Followers-only",
"public": "Public",
"unlisted": "Unlisted"
}
},
"image_cropper": {
"crop_picture": "Crop picture",
"save": "Save",
"save_without_cropping": "Save without cropping",
"cancel": "Cancel"
},
"importer": {
"submit": "Submit",
"import": "Import",
"success": "Imported successfully.",
"error": "An error occured while importing this file."
},
"login": {
"login": "Log in",
"description": "Log in with OAuth",
"logout": "Log out",
"logout_confirm_title": "Logout confirmation",
"logout_confirm": "Do you really want to logout?",
"logout_confirm_accept_button": "Logout",
"logout_confirm_cancel_button": "Do not logout",
"password": "Password",
"placeholder": "e.g. lain",
"register": "Register",
"username": "Username",
"hint": "Log in to join the discussion",
"authentication_code": "Authentication code",
"enter_recovery_code": "Enter a recovery code",
"enter_two_factor_code": "Enter a two-factor code",
"recovery_code": "Recovery code",
"heading": {
"totp": "Two-factor authentication",
"recovery": "Two-factor recovery"
}
},
"media_modal": {
"previous": "Previous",
"next": "Next",
"counter": "{current} / {total}",
"hide": "Close media viewer"
},
"nav": {
"about": "About",
"administration": "Administration",
"back": "Back",
"friend_requests": "Follow requests",
"mentions": "Mentions",
"interactions": "Interactions",
"dms": "Direct messages",
"public_tl": "Public timeline",
"bubble": "Bubble timeline",
"timeline": "Timeline",
"home_timeline": "Home timeline",
"twkn": "Known Network",
"bookmarks": "Bookmarks",
"all_bookmarks": "All bookmarks",
"bookmark_folders": "Bookmark folders",
"user_search": "User Search",
"search": "Search",
"search_close": "Close search bar",
"who_to_follow": "Who to follow",
"preferences": "Preferences",
"timelines": "Timelines",
"chats": "Chats",
"lists": "Lists",
"edit_nav_mobile": "Customize navigation bar",
"edit_pinned": "Edit pinned items",
"edit_finish": "Done editing",
"mobile_sidebar": "Toggle mobile sidebar",
"mobile_notifications": "Open notifications",
"mobile_notifications_unread_active": "Open notifications (there are unread ones)",
"mobile_notifications_close": "Close notifications",
"mobile_notifications_mark_as_seen": "Mark all as seen",
"announcements": "Announcements",
"quotes": "Quotes",
"drafts": "Drafts",
"navigation": "Navigation",
"quick_items": "Quick Navigation",
"all_navigation": "All pages",
"expand_navigation": "Expand navigation",
"user_panel": "User panel",
"instance-specific-panel": "Instance-specific Panel",
"content": "Content"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it…",
"error": "Error fetching notifications: {0}",
"favorited_you": "favorited your status",
"followed_you": "followed you",
"follow_request": "wants to follow you",
"load_older": "Load older notifications",
"notifications": "Notifications",
"read": "Read!",
"repeated_you": "repeated your status",
"no_more_notifications": "No more notifications",
"migrated_to": "migrated to",
"reacted_with": "reacted with {0}",
"submitted_report": "submitted a report",
"poll_ended": "poll has ended",
"unread_announcements": "{num} unread announcement | {num} unread announcements",
"unread_chats": "{num} unread chat | {num} unread chats",
"unread_follow_requests": "{num} new follow request | {num} new follow requests",
"configuration_tip": "You can customize what to display here in {theSettings}. {dismiss}",
"configuration_tip_settings": "the settings",
"configuration_tip_dismiss": "Do not show again",
"subscribed_status": "posted"
},
"polls": {
"add_poll": "Add poll",
"add_option": "Add option",
"option": "Option",
"votes": "votes",
"people_voted_count": "{count} person voted | {count} people voted",
"votes_count": "{count} vote | {count} votes",
"vote": "Vote",
"type": "Poll type",
"single_choice": "Single choice",
"multiple_choices": "Multiple choices",
"expiry": "Poll age",
"expiry_unit": "Expiry unit",
"expiry_amount": "Expiry amount",
"expires_at": "Poll ends {0}",
"expires_in": "Poll ends in {0}",
"expired": "Poll ended {0} ago",
"expired_at": "Poll ended {0}",
"not_enough_options": "Too few unique options in poll",
"non_anonymous": "Public poll",
"non_anonymous_title": "Other instances may display the options you voted for"
},
"emoji": {
"stickers": "Stickers",
"emoji": "Emoji",
"picker": "Emoji Picker",
"keep_open": "Keep picker open",
"search_emoji": "Search for an emoji",
"add_emoji": "Insert emoji",
"custom": "Custom emoji",
"hide_custom_emoji": "Hide custom emojis",
"unpacked": "Unpacked emoji",
"unicode": "Unicode emoji",
"unicode_groups": {
"activities": "Activities",
"animals-and-nature": "Animals & Nature",
"flags": "Flags",
"food-and-drink": "Food & Drink",
"objects": "Objects",
"people-and-body": "People & Body",
"smileys-and-emotion": "Smileys & Emotion",
"symbols": "Symbols",
"travel-and-places": "Travel & Places"
},
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"load_all": "Loading all {emojiAmount} emoji",
"regional_indicator": "Regional indicator {letter}"
},
"errors": {
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
},
"interactions": {
"favs_repeats": "Repeats and favorites",
"follows": "New follows",
"emoji_reactions": "Emoji Reactions",
"reports": "Reports",
"moves": "User migrates",
"load_older": "Load older interactions",
"statuses": "Subscriptions"
},
"post_status": {
"edit_status": "Edit status",
"new_status": "Post new status",
"reply_option": "Reply to this status",
"quote_option": "Quote this status",
"quote_url": "Link to quoted post",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive",
"media_description": "Media description",
"content_type": {
"text/plain": "Plain text",
"text/html": "HTML",
"text/markdown": "Markdown",
"text/bbcode": "BBCode",
"text/x.misskeymarkdown": "MFM"
},
"content_type_selection": "Post format",
"content_warning": "Subject (optional)",
"mentions_line": "Mentioned users",
"enter_submits": "Enter key sends the post",
"enter_newline": "Enter key adds a newline",
"default": "Just landed in L.A.",
"post_content": "Post content",
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
"edit_remote_warning": "Other remote instances may not support editing and unable to receive the latest version of your post.",
"edit_unsupported_warning": "Pleroma does not support editing mentions or polls.",
"posting": "Posting",
"post": "Post",
"preview": "Preview",
"preview_empty": "Empty",
"empty_status_error": "Can't post an empty status with no files",
"media_description_error": "Failed to update media, try again",
"scope_notice": {
"public": "This post will be visible to everyone",
"private": "This post will be visible to your followers only",
"unlisted": "This post will not be visible in Public Timeline and The Whole Known Network"
},
"scope_notice_dismiss": "Close this notice",
"scope": {
"direct": "Direct - post to mentioned users only",
"private": "Followers-only - post to followers only",
"public": "Public - post to public timelines",
"unlisted": "Unlisted - do not post to public timelines"
},
"close_confirm_title": "Closing post form",
"close_confirm": "What do you want to do with your current writing?",
"close_confirm_save_button": "Save",
"close_confirm_discard_button": "Discard",
"close_confirm_continue_composing_button": "Continue composing",
"auto_save_nothing_new": "Nothing new to save.",
"auto_save_saved": "Saved.",
"auto_save_saving": "Saving...",
"save_to_drafts_button": "Save to drafts",
"save_to_drafts_and_close_button": "Save to drafts and close",
"more_post_actions": "More post actions..."
},
"registration": {
"bio_optional": "Bio (optional)",
"email": "Email",
"email_optional": "Email (optional)",
"fullname": "Display name",
"password_confirm": "Password confirmation",
"registration": "Registration",
"token": "Invite token",
"captcha": "CAPTCHA",
"new_captcha": "Click the image to get a new captcha",
"username_placeholder": "e.g. lain",
"fullname_placeholder": "e.g. Lain Iwakura",
"bio_placeholder": "e.g.\nHi, I'm Lain.\nI’m an anime girl living in suburban Japan. You may know me from the Wired.",
"reason": "Reason to register",
"reason_placeholder": "This instance approves registrations manually.\nLet the administration know why you want to register.",
"register": "Register",
"validations": {
"username_required": "cannot be left blank",
"fullname_required": "cannot be left blank",
"email_required": "cannot be left blank",
"password_required": "cannot be left blank",
"password_confirmation_required": "cannot be left blank",
"password_confirmation_match": "should be the same as password",
"birthday_required": "cannot be left blank",
"birthday_min_age": "must be on or before {date}"
},
"email_language": "In which language do you want to receive emails from the server?",
"birthday": "Birthday:",
"birthday_optional": "Birthday (optional):"
},
"remote_user_resolver": {
"remote_user_resolver": "Remote user resolver",
"searching_for": "Searching for",
"error": "Not found."
},
"report": {
"reporter": "Reporter:",
"reported_user": "Reported user:",
"reported_statuses": "Reported statuses:",
"notes": "Notes:",
"state": "State:",
"state_open": "Open",
"state_closed": "Closed",
"state_resolved": "Resolved"
},
"selectable_list": {
"select_all": "Select all"
},
"settings": {
"invalid_settings_imported": "Error importing settings",
"add_language": "Add fallback language",
"remove_language": "Remove",
"primary_language": "Primary language:",
"fallback_language": "Fallback language {index}:",
"actor_type": "This account is:",
"actor_type_description": "Marking your account as a group will make it automatically repeat statuses that mention it.",
"actor_type_Person": "a normal user",
"actor_type_person_proper": "a person",
"actor_type_Service": "a bot",
"actor_type_Group": "a group",
"mobile_center_dialog": "Vertically center dialogs on mobile",
"app_name": "App name",
"expert_mode": "Show advanced",
"save": "Save changes",
"reset": "Reset changes",
"security": "Security",
"toggle_edit": "Edit",
"change_banner": "Change banner",
"change_avatar": "Change avatar",
"setting_changed": "Setting is different from default",
"setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
"setting_local_side": "This setting is tied to current session and doesn't affects other devices and browsers",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
"post_look_feel": "Posts Look & Feel",
"posts": "Posts",
"developer": "Developer",
"debug": "Debug",
"mention_links": "Mention Links",
"appearance": "Appearance",
"confirm_new_setting": "Confirm new setting?",
"confirm_new_question": "Does this look ok? Setting will be reverted in 10 seconds.",
"confirm_new_question_countdown": "Does this look ok? Setting will be reverted in 1 second. | Does this look ok? Setting will be reverted in {count} seconds.",
"revert": "Revert",
"confirm": "Confirm",
"text_size": "Text and interface size",
"text_size_tip": "Use {0} for absolute values, {1} will scale with browser default text size.",
"text_size_tip2": "Values other than {0} might break some things and themes",
"emoji_size": "Emoji size",
"navbar_size": "Top bar size",
"panel_header_size": "Panel header size",
"visual_tweaks": "Minor visual tweaks",
"theme_debug": "Show what background theme engine assumes when dealing with transparancy",
"scale_and_layout": "Interface scale and layout",
"timelines": "Timelines",
"format_and_language": "Format and Language",
"confirmations": "Confirmations",
"layout": "Layout",
"enabled": "Enabled",
"clutter": "Clutter",
"filter": {
"clutter": "Remove clutter",
"mute_filter": "Mute Filters",
"type": "Filter type",
"regexp": "RegExp",
"plain": "Simple",
"user": "User (Simple)",
"user_regexp": "User (RegExp)",
"case_sensitive": "Case-sensitive",
"hide": "Hide completely",
"name": "Name",
"value": "Value",
"expires": "Expires",
"expired": "Expired",
"copy": "Duplicate",
"save": "Save",
"delete": "Remove",
"new": "Create new",
"import": "Import",
"export": "Export",
"regexp_error": "Invalid Regular Expression",
"never_expires": "Never",
"total_count": "Total {count} custom filter|Total {count} custom filters",
"expired_count": "{count} expired filter|{count} expired filters",
"custom_filters": "Custom filters",
"purge_expired": "Remove expired filters",
"import_failure": "The selected file is not a supported Pleroma filter.",
"help": {
"word": "Simple and RegExp filters test against post's content and subject.",
"user": "User filter matches full user handle (user{'@'}domain) in the following: author, reply-to and mentions",
"regexp": "Regex variants are more advanced and use {link} to match instead of simple substring search.",
"regexp_link": "Regular Expressions",
"regexp_url": "https://en.wikipedia.org/wiki/Regular_expression"
}
},
"mfa": {
"otp": "OTP",
"setup_otp": "Setup OTP",
"wait_pre_setup_otp": "presetting OTP",
"confirm_and_enable": "Confirm & enable OTP",
"title": "Two-factor Authentication",
"generate_new_recovery_codes": "Generate new recovery codes",
"warning_of_generate_new_codes": "When you generate new recovery codes, your old codes won’t work anymore.",
"recovery_codes": "Recovery codes.",
"waiting_a_recovery_codes": "Receiving backup codes…",
"recovery_codes_warning": "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.",
"authentication_methods": "Authentication methods",
"scan": {
"title": "Scan",
"desc": "Using your two-factor app, scan this QR code or enter text key:",
"secret_code": "Key"
},
"verify": {
"desc": "To enable two-factor authentication, enter the code from your two-factor app:",
"label": "One-time code"
}
},
"units": {
"time": {
"m": "minutes",
"s": "seconds",
"h": "hours",
"d": "days"
}
},
"lists_navigation": "Show lists in navigation",
"allow_following_move": "Allow auto-follow when following account moves",
"attachmentRadius": "Attachments",
"attachments": "Attachments",
"image_compression": "Compress images before uploading",
"always_use_jpeg": "Always convert images to JPEG format",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (notifications)",
"avatarRadius": "Avatars",
"background": "Background",
"bio": "Bio",
"profile_other": "Other",
"email_language": "Language for receiving emails from the server",
"block_export": "Block export",
"block_export_button": "Export your blocks to a csv file",
"block_import": "Block import",
"block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.",
"mute_export": "Mute export",
"mute_export_button": "Export your mutes to a csv file",
"mute_import": "Mute import",
"mute_import_error": "Error importing mutes",
"mutes_imported": "Mutes imported! Processing them will take a while.",
"import_mutes_from_a_csv_file": "Import mutes from a csv file",
"account_backup": "Account backup",
"account_backup_description": "This allows you to download an archive of your account information and your posts, but they cannot yet be imported into a Pleroma account.",
"account_backup_table_head": "Backup",
"download_backup": "Download",
"backup_not_ready": "This backup is not ready yet.",
"backup_running": "This backup is in progress, processed {number} record. | This backup is in progress, processed {number} records.",
"backup_failed": "This backup has failed.",
"remove_backup": "Remove",
"list_backups_error": "Error fetching backup list: {error}",
"add_backup": "Create a new backup",
"added_backup": "Added a new backup.",
"add_backup_error": "Error adding a new backup: {error}",
"blocks_tab": "Blocks",
"btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
"cOrange": "Orange (Favorite)",
"cRed": "Red (Cancel)",
"change_email": "Change email",
"change_email_error": "There was an issue changing your email.",
"changed_email": "Email changed successfully!",
"change_password": "Change password",
"change_password_error": "There was an issue changing your password.",
"changed_password": "Password changed successfully!",
"chatMessageRadius": "Chat message",
"collapse_subject": "Collapse posts with subjects",
"composing": "Composing",
"replies": "Replying",
"confirm_new_password": "Confirm new password",
"current_password": "Current password",
"confirm_dialogs": "Ask for confirmation when",
"confirm_dialogs_repeat": "repeating a status",
"confirm_dialogs_unfollow": "unfollowing a user",
"confirm_dialogs_block": "blocking a user",
"confirm_dialogs_mute": "muting a user",
"confirm_dialogs_mute_domain": "muting domains",
"confirm_dialogs_mute_conversation": "muting conversations",
"confirm_dialogs_delete": "deleting a status",
"confirm_dialogs_logout": "logging out",
"confirm_dialogs_approve_follow": "approving a follower",
"confirm_dialogs_deny_follow": "denying a follower",
"confirm_dialogs_remove_follower": "removing a follower",
"mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data import / export",
"default_vis": "Default visibility scope",
"delete_account": "Delete account",
"delete_account_description": "Permanently delete your data and deactivate your account.",
"delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.",
"delete_account_instructions": "Type your password in the input below to confirm account deletion.",
"account_alias": "Account aliases",
"account_alias_table_head": "Alias",
"list_aliases_error": "Error fetching aliases: {error}",
"hide_list_aliases_error_action": "Close",
"remove_alias": "Remove this alias",
"new_alias_target": "Add a new alias (e.g. {example})",
"added_alias": "Alias is added.",
"add_alias_error": "Error adding alias: {error}",
"move_account": "Move account",
"move_account_notes": "If you want to move the account somewhere else, you must go to your target account and add an alias pointing here.",
"move_account_target": "Target account (e.g. {example})",
"moved_account": "Account is moved.",
"move_account_error": "Error moving account: {error}",
"discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"domain_mutes2": "Excluded domains",
"user_mutes2": "Muted users",
"user_blocks": "Blocked users",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels. Recommended aspect ratio is 1:1",
"banner_size_instruction": "The recommended minimum size for banner images is 450x150 pixels. Recommended aspect ratio is 3:1",
"pad_emoji": "Pad emoji with spaces when adding from picker",
"autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available",
"unsaved_post_action": "When you try to close an unsaved posting form",
"unsaved_post_action_save": "Save it to drafts",
"unsaved_post_action_discard": "Discard it",
"unsaved_post_action_confirm": "Ask every time",
"auto_save_draft": "Save drafts as you compose",
"emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"emoji_reactions_scale": "Reactions scale factor",
"absolute_time_format": "Use absolute time format",
"absolute_time_format_min_age": "Only use for time older than this amount of time",
"absolute_time_format_12h": "Time format",
"absolute_time_format_12h_12h": "12 hour format (i.e. 10:00 PM)",
"absolute_time_format_12h_24h": "24 hour format (i.e. 22:00)",
"export_theme": "Save preset",
"filtering": "Filtering",
"wordfilter": "Wordfilter",
"filtering_explanation": "All statuses containing these words will be muted, one per line",
"word_filter_and_more": "Word filter and more...",
"follow_export": "Follow export",
"follow_export_button": "Export your follows to a csv file",
"follow_import": "Follow import",
"follow_import_error": "Error importing followers",
"import_export": {
"title": "Import / Export",
"follows": "List of users you follow",
"blocks": "List of users you block",
"mutes": "List of users you mute"
},
"follows_imported": "Follows imported! Processing them will take a while.",
"accent": "Accent",
"foreground": "Foreground",
"general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations",
"hide_attachments_in_tl": "Hide attachments in timeline",
"hide_media_previews": "Hide media previews",
"hide_muted_posts": "Hide posts of muted users",
"mute_bot_posts": "Mute bot posts",
"hide_actor_type_indication": "Hide actor type (bots, groups, etc.) indication in posts",
"hide_scrobbles": "Hide scrobbles",
"hide_scrobbles_after": "Hide scrobbles older than",
"mute_sensitive_posts": "Mute sensitive posts",
"hide_all_muted_posts": "Hide muted posts",
"max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
"hide_isp": "Hide instance-specific panel",
"hide_shoutbox": "Hide instance shoutbox",
"right_sidebar": "Reverse order of columns",
"navbar_column_stretch": "Stretch navbar to columns width",
"always_show_post_button": "Always show floating New Post button",
"hide_wallpaper": "Hide instance wallpaper",
"foreign_user_background": "Allow other user's profiles to override wallpaper",
"preload_images": "Preload images",
"use_one_click_nsfw": "Open NSFW attachments with just one click",
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)",
"hide_filtered_statuses": "Hide all filtered posts",
"hide_muted_statuses": "Completely hide all muted posts",
"hide_wordfiltered_statuses": "Hide word-filtered statuses",
"hide_muted_threads": "Hide muted threads",
"import_blocks_from_a_csv_file": "Import blocks from a csv file",
"import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset",
"inputRadius": "Input fields",
"checkboxRadius": "Checkboxes",
"instance_default": "(default: {value})",
"instance_default_simple": "(default)",
"interface": "Interface",
"interfaceLanguage": "Interface language",
"invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.",
"limited_availability": "Unavailable in your browser",
"links": "Links",
"lock_account_description": "Restrict your account to approved followers only",
"loop_video": "Loop videos",
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame",
"url": "URL",
"preview": "Preview",
"file_export_import": {
"backup_restore": "Settings backup",
"backup_settings": "Backup settings to file",
"backup_settings_theme": "Backup settings and theme to file",
"restore_settings": "Restore settings from file",
"errors": {
"invalid_file": "The selected file is not a supported Pleroma settings backup. No changes were made.",
"file_too_new": "Incompatile major version: {fileMajor}, this PleromaFE (settings ver {feMajor}) is too old to handle it",
"file_too_old": "Incompatile major version: {fileMajor}, file version is too old and not supported (min. set. ver. {feMajor})",
"file_slightly_new": "File minor version is different, some settings might not load"
}
},
"profile_fields": {
"label": "Profile metadata",
"add_field": "Add field",
"name": "Label",
"value": "Content"
},
"birthday": {
"label": "Birthday",
"show_birthday": "Show my birthday"
},
"account_profile_edit": "Edit Profile",
"account_privacy": "Privacy",
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & bio",
"new_email": "New email",
"new_password": "New password",
"user_profiles": "User Profiles",
"notification_visibility": "Types of notifications to show",
"notification_visibility_in_column": "Show in notifications column/drawer",
"notification_visibility_native_notifications": "Show a native notification",
"notification_visibility_follows": "Follows",
"notification_visibility_follow_requests": "Follow requests",
"notification_visibility_likes": "Favorites",
"notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats",
"notification_visibility_reports": "Reports",
"notification_visibility_moves": "User Migrates",
"notification_visibility_emoji_reactions": "Reactions",
"notification_visibility_polls": "Ends of polls you voted in",
"notification_visibility_statuses": "Subscriptions",
"notification_show_extra": "Show extra notifications in the notifications column",
"notification_extra_chats": "Show unread chats",
"notification_extra_announcements": "Show unread announcements",
"notification_extra_follow_requests": "Show new follow requests",
"notification_extra_tip": "Show the customization tip for extra notifications",
"no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks",
"no_mutes": "No mutes",
"hide_favorites_description": "Don't show list of my favorites (people still get notified)",
"hide_follows_description": "Don't show who I'm following",
"hide_followers_description": "Don't show who's following me",
"hide_follows_count_description": "Don't show follow count",
"hide_followers_count_description": "Don't show follower count",
"show_admin_badge": "Show \"Admin\" badge in my profile",
"show_moderator_badge": "Show \"Moderator\" badge in my profile",
"nsfw_clickthrough": "Hide sensitive/NSFW media",
"oauth_tokens": "OAuth tokens",
"token": "Token",
"refresh_token": "Refresh token",
"valid_until": "Valid until",
"revoke_token": "Revoke",
"panelRadius": "Panels",
"pause_on_unfocused": "Pause when tab is not focused",
"presets": "Presets",
"profile_background": "Profile background",
"profile_banner": "Profile banner",
"profile_tab": "Profile",
"radii_help": "Set up interface edge rounding (in pixels)",
"replies_in_timeline": "Replies in timeline",
"reply_visibility_all": "Show all replies",
"reply_visibility_following": "Only show replies directed at me or users I'm following",
"reply_visibility_self": "Only show replies directed at me",
"reply_visibility_following_short": "Show replies to my follows",
"reply_visibility_self_short": "Show replies to self only",
"autohide_floating_post_button": "Automatically hide New Post button (mobile)",
"saving_err": "Error saving settings",
"saving_ok": "Settings saved",
"search_user_to_block": "Search whom you want to block",
"search_user_to_mute": "Search whom you want to mute",
"security_tab": "Security",
"scope_copy": "Copy scope when replying (DMs are always copied)",
"minimal_scopes_mode": "Minimize post scope selection options",
"set_new_avatar": "Set new avatar",
"set_new_profile_background": "Set new profile background",
"set_new_background": "Set new background",
"set_new_profile_banner": "Set new profile banner",
"reset_avatar": "Reset avatar",
"reset_banner": "Reset banner",
"reset_profile_background": "Reset profile background",
"reset_profile_banner": "Reset profile banner",
"reset_avatar_confirm": "Do you really want to reset the avatar?",
"reset_banner_confirm": "Do you really want to reset the banner?",
"reset_background_confirm": "Do you really want to reset the background?",
"settings": "Settings",
"subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying",
"subject_line_email": "Like email: \"re: subject\"",
"subject_line_mastodon": "Like mastodon: copy as is",
"subject_line_noop": "Do not copy",
"submit_on_enter_in_chats": "Send message on Enter in chats",
"force_theme_recompilation_debug": "Disable theme cahe, force recompile on each boot",
"conversation_display": "Conversation display style",
"conversation_display_tree": "Tree-style",
"conversation_display_tree_quick": "Tree view",
"disable_sticky_headers": "Don't stick column headers to top of the screen",
"show_scrollbars": "Show side column's scrollbars",
"third_column_mode": "When there's enough space, show third column containing",
"third_column_mode_none": "Don't show third column at all",
"third_column_mode_notifications": "Notifications column",
"third_column_mode_postform": "Main post form and navigation",
"columns": "Columns",
"column_sizes": "Column sizes",
"column_sizes_sidebar": "Sidebar",
"column_sizes_content": "Content",
"column_sizes_notifs": "Notifications",
"scale_and_font": "Scale and Font",
"theme_editor_min_width": "Minimum width of theme editor (0 for \"fit-content\")",
"tree_advanced": "Allow more flexible navigation in tree view",
"tree_fade_ancestors": "Display ancestors of the current status in faint text",
"conversation_display_linear": "Linear-style",
"conversation_display_linear_quick": "Linear view",
"conversation_other_replies_button": "Show the \"other replies\" button",
"conversation_other_replies_button_below": "Below statuses",
"conversation_other_replies_button_inside": "Inside statuses",
"max_depth_in_thread": "Maximum number of levels in thread to display by default",
"post_status_content_type": "Post status content type",
"default_post_status_content_type": "Default post status content type",
"sensitive_by_default": "Mark posts as sensitive by default",
"stop_gifs": "Pause animated images until you hover on them",
"non_square_emoji": "Allow non-square emoji",
"pause_mfm": "Pause MFM animations until you hover on them",
"scale_mfm": "Scale MFM animations with emoji size",
"streaming": "Automatically show new posts when scrolled to the top",
"auto_update": "Show new posts automatically",
"user_mutes": "Users",
"useStreamingApi": "Receive posts and notifications real-time",
"use_websockets": "Use websockets (Realtime updates)",
"text": "Text",
"theme": "Theme",
"theme_old": "Theme editor (old)",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
"theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Search domains to mute",
"upload_a_photo": "Upload a photo",
"upload_picture": "Upload picture",
"select_picture": "Select picture",
"user_settings": "User Settings",
"values": {
"false": "no",
"true": "yes"
},
"virtual_scrolling": "Optimize timeline rendering",
"use_at_icon": "Display {'@'} symbol as an icon instead of text",
"mention_link_display": "Display mention links",
"mention_link_display_short": "always as short names (e.g. {'@'}foo)",
"mention_link_display_full_for_remote": "as full names only for remote users (e.g. {'@'}foo{'@'}example.org)",
"mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)",
"mention_link_use_tooltip": "Show user card when clicking mention links",
"mention_link_show_avatar": "Show user avatar beside the link",
"mention_link_show_avatar_quick": "Show user avatar next to mentions",
"mention_link_fade_domain": "Fade domains (e.g. {'@'}example.org in {'@'}foo{'@'}example.org)",
"mention_link_bolden_you": "Highlight mention of you when you are mentioned",
"user_popover_avatar_action": "Popover avatar click action",
"user_popover_avatar_action_zoom": "Zoom the avatar",
"user_popover_avatar_action_close": "Close the popover",
"user_popover_avatar_action_open": "Open profile",
"user_popover_avatar_overlay": "Show user popover over user avatar",
"user_card_left_justify": "Justify user bio to the left",
"user_card_hide_personal_marks": "Hide personal marks (highlight/note) in user profiles",
"posts_appearance": "Posts Appearance",
"fun": "Fun",
"greentext": "Meme arrows",
"plaintext_quotes": "Highlight plaintext {0}",
"greentext_quotes": ">quotes",
"show_yous": "Show (You)s",
"notifications": "Notifications",
"notification_setting_annoyance": "Annoyance",
"notification_setting_drawer_marks_as_seen": "Closing drawer (mobile) marks all notifications as read",
"notification_setting_ignore_inactionable_seen": "Ignore read state of inactionable notifications (likes, repeats etc)",
"notification_setting_ignore_inactionable_seen_tip": "This will not actually mark those notifications as read, and you'll still get desktop notifications about them if you chose so",
"notification_setting_unseen_at_top": "Show unread notifications above others",
"notification_setting_filters": "Filters",
"notification_setting_filters_chrome_push": "On some browsers (chrome) it might be impossible to completely filter out notifications by type when they arrive by Push",
"notification_setting_block_from_strangers": "Block notifications from users who you do not follow",
"notification_setting_privacy": "Privacy",
"notification_setting_hide_notification_contents": "Hide the sender and contents of push notifications",
"notification_mutes": "To stop receiving notifications from a specific user, use a mute.",
"notification_blocks": "Blocking a user stops all notifications as well as unsubscribes them.",
"enable_web_push_notifications": "Enable web push notifications",
"enable_web_push_always_show": "Always show web push notifications",
"enable_web_push_always_show_tip": "Some browsers (Chromium, Chrome) require that push messages always result in a notification, otherwise generic 'Website was updated in background' is shown, enable this to prevent this notification from showing, as Chrome seem to hide push notifications if tab is in focus. Can result in showing duplicate notifications on other browsers.",
"more_settings": "More settings",
"style": {
"style_section": "Style",
"custom_theme_used": "(Custom theme)",
"custom_style_used": "(Custom style)",
"stock_theme_used": "(Stock theme)",
"themes2_outdated": "Editor for Themes V2 is being phased out and will eventually be replaced with a new one that takes advantage of new Themes V3 engine. It should still work but experience might be degraded and inconsistent.",
"appearance_tab_note": "Changes on this tab do not affect the theme used, so exported theme will be different from what seen in the UI",
"visual_tweaks_section_note": "Changes in this section do not affect the theme used, exported theme will be different from what seen in the UI",
"update_preview": "Update preview",
"themes3": {
"define": "Override",
"styles": "Styles",
"palette": {
"label": "Color schemes",
"name_label": "Color scheme name",
"import": "Import palette",
"export": "Export palette",
"apply": "Apply palette",
"bg": "Panel background",
"fg": "Buttons etc.",
"text": "Text",
"link": "Links",
"accent": "Accent color",
"cRed": "Red color",
"cBlue": "Blue color",
"cGreen": "Green color",
"cOrange": "Orange color",
"wallpaper": "Wallpaper",
"v2_unsupported": "Older v2 themes don't support palettes. Switch to v3 theme to make use of palettes",
"bundled": "Bundled palettes",
"style": "Palettes provided by selected style",
"user": "Custom palette",
"imported": "Imported"
},
"editor": {
"title": "Style editor",
"reset_style": "Reset",
"load_style": "Open from file",
"save_style": "Save",
"style_name": "Stylesheet name",
"style_author": "Made by",
"style_license": "License",
"style_website": "Website",
"component_selector": "Component",
"variant_selector": "Variant",
"states_selector": "States",
"main_tab": "Main",
"shadows_tab": "Shadows",
"background": "Background color",
"text_color": "Text color",
"icon_color": "Icon color",
"link_color": "Link color",
"contrast": "Text contrast",
"roundness": "Roundness",
"opacity": "Opacity",
"border_color": "Border color",
"include_in_rule": "Add to rule",
"test_string": "TEST",
"invalid": "Invalid",
"refresh_preview": "Refresh preview",
"apply_preview": "Apply",
"text_auto": {
"label": "Auto-contrast",
"no-preserve": "Black or White",
"preserve": "Keep color",
"no-auto": "Disabled"
},
"component_tab": "Components style",
"palette_tab": "Color schemes",
"variables_tab": "Variables (Advanced)",
"variables": {
"label": "Variables",
"name_label": "Name:",
"type_label": "Type:",
"type_shadow": "Shadow",
"type_color": "Color",
"type_generic": "Generic",
"virtual_color": "Variable color value"
}
},
"hacks": {
"underlay_overrides": "Change underlay",
"underlay_override_mode_none": "Theme default",
"underlay_override_mode_opaque": "Replace with solid color",
"underlay_override_mode_transparent": "Remove entirely (might break some themes)",
"force_interface_roundness": "Override interface roundness/sharpness",
"forced_roundness_mode_disabled": "Use theme defaults",
"forced_roundness_mode_sharp": "Force sharp edges",
"forced_roundness_mode_nonsharp": "Force not-so-sharp (1px roundness) edges",
"forced_roundness_mode_round": "Force round edges"
},
"font": {
"group-builtin": "Browser default fonts",
"builtin": {
"serif": "Serif",
"sans-serif": "Sans-serif",
"monospace": "Monospace",
"inherit": "Unchanged"
},
"group-local": "Locally installed fonts",
"local-unavailable1": "List of locally installed fonts unavailable",
"local-unavailable2": "Use manual entry to specify custom font",
"font_list_unavailable": "Couldn't get locally installed fonts: {error}",
"lookup_local_fonts": "Load list of fonts installed on this computer",
"enter_manually": "Enter font name family manually",
"entry": "Enter {fontFamily}",
"select": "Select font",
"label": "{label} font"
}
},
"interface_font_user_override": "Override theme/browser font used",
"switcher": {
"keep_color": "Keep colors",
"keep_shadows": "Keep shadows",
"keep_opacity": "Keep opacity",
"keep_roundness": "Keep roundness",
"keep_fonts": "Keep fonts",
"save_load_hint": "\"Keep\" options preserve currently set options when selecting or loading themes, it also stores said options when exporting a theme. When all checkboxes unset, exporting theme will save everything.",
"reset": "Reset",
"clear_all": "Clear all",
"clear_opacity": "Clear opacity",
"load_theme": "Load theme",
"keep_as_is": "Keep as is",
"use_snapshot": "Old version",
"use_source": "New version",
"help": {
"upgraded_from_v2": "PleromaFE has been upgraded, theme could look a little bit different than you remember.",
"v2_imported": "File you imported was made for older FE. We try to maximize compatibility but there still could be inconsistencies.",
"future_version_imported": "File you imported was made in newer version of FE.",
"older_version_imported": "File you imported was made in older version of FE.",
"snapshot_present": "Theme snapshot is loaded, so all values are overriden. You can load theme's actual data instead.",
"snapshot_missing": "No theme snapshot was in the file so it could look different than originally envisioned.",
"fe_upgraded": "PleromaFE's theme engine upgraded after version update.",
"fe_downgraded": "PleromaFE's version rolled back.",
"migration_snapshot_ok": "Just to be safe, theme snapshot loaded. You can try loading theme data.",
"migration_napshot_gone": "For whatever reason snapshot was missing, some stuff could look different than you remember.",
"snapshot_source_mismatch": "Versions conflict: most likely FE was rolled back and updated again, if you changed theme using older version of FE you most likely want to use old version, otherwise use new version."
}
},
"common": {
"color": "Color",
"picker": "Color picker",
"hex": "Hex code #RRGGBB",
"unit": "Unit",
"opacity": "Opacity",
"contrast": {
"label": "Contrast indicator",
"hint": "Contrast ratio is {ratio}, it {level} {context}",
"level": {
"aa": "meets Level AA guideline (minimal)",
"aaa": "meets Level AAA guideline (recommended)",
"bad": "doesn't meet any accessibility guidelines"
},
"context": {
"18pt": "for large (18pt+) text",
"text": "for text"
}
}
},
"common_colors": {
"_tab_label": "Common",
"main": "Common colors",
"foreground_hint": "See \"Advanced\" tab for more detailed control",
"rgbo": "Icons, accents, badges"
},
"advanced_colors": {
"_tab_label": "Advanced",
"alert": "Alert background",
"alert_error": "Error",
"alert_warning": "Warning",
"alert_neutral": "Neutral",
"post": "Posts/User bios",
"badge": "Badge background",
"popover": "Tooltips, menus, popovers",
"badge_notification": "Notification",
"panel_header": "Panel header",
"top_bar": "Top bar",
"borders": "Borders",
"buttons": "Buttons",
"inputs": "Input fields",
"faint_text": "Faded text",
"underlay": "Underlay",
"wallpaper": "Wallpaper",
"poll": "Poll graph",
"icons": "Icons",
"highlight": "Highlighted elements",
"pressed": "Pressed",
"selectedPost": "Selected post",
"selectedMenu": "Selected menu item",
"disabled": "Disabled",
"toggled": "Toggled",
"tabs": "Tabs",
"chat": {
"incoming": "Incoming",
"outgoing": "Outgoing",
"border": "Border"
}
},
"radii": {
"_tab_label": "Roundness"
},
"shadows": {
"_tab_label": "Shadow and lighting",
"shadows_list": "Shadows list",
"component": "Component",
"override": "Override",
"shadow_id": "Shadow #{value}",
"offset": "Shadow offset",
"zoom": "Zoom",
"offset-x": "x:",
"offset-y": "y:",
"light_grid": "Use light checkerboard",
"color_override": "Use different color",
"name": "Name",
"blur": "Blur",
"spread": "Spread",
"inset": "Inset",
"raw": "Plain shadow",
"expression": "Expression (advanced)",
"empty_expression": "Empty expression",
"hintV3": "For shadows you can also use the {0} notation to use other color slot.",
"filter_hint": {
"always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
"drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.",
"avatar_inset_short": "Separate inset shadow",
"avatar_inset": "Please note that combining both inset and non-inset shadows on avatars might give unexpected results with transparent avatars.",
"spread_zero": "Shadows with spread > 0 will appear as if it was set to zero",
"inset_classic": "Inset shadows will be using {0}"
},
"components": {
"panel": "Panel",
"panelHeader": "Panel header",
"topBar": "Top bar",
"avatar": "User avatar (in profile view)",
"avatarStatus": "User avatar (in post display)",
"popup": "Popups and tooltips",
"button": "Button",
"buttonHover": "Button (hover)",
"buttonPressed": "Button (pressed)",
"buttonPressedHover": "Button (pressed+hover)",
"input": "Input field"
}
},
"fonts": {
"_tab_label": "Fonts",
"help": "Select font to use for elements of UI. For \"custom\" you have to enter exact font name as it appears in system.",
"components": {
"interface": "Interface",
"input": "Input fields",
"post": "Post text",
"monospace": "Monospaced text"
},
"components_inline": {
"interface": "interface",
"input": "input fields",
"post": "post text",
"monospace": "monospaced text"
},
"override": "Override {0} font",
"family": "Font name",
"size": "Size (in px)",
"weight": "Weight (boldness)",
"custom": "Custom"
},
"preview": {
"header": "Preview",
"content": "Content",
"error": "Example error",
"button": "Button",
"text": "A bunch of more {0} and {1}",
"mono": "content",
"input": "Just landed in L.A.",
"faint_link": "helpful manual",
"fine_print": "Read our {0} to learn nothing useful!",
"header_faint": "This is fine",
"checkbox": "I have skimmed over terms and conditions",
"link": "a nice lil' link"
}
},
"version": {
"title": "Version",
"backend_version": "Backend version",
"frontend_version": "Frontend version"
},
"commit_value": "Save",
"commit_value_tooltip": "Value is not saved, press this button to commit your changes",
"reset_value": "Reset",
"reset_value_tooltip": "Reset draft",
"hard_reset_value": "Hard reset",
"hard_reset_value_tooltip": "Remove setting from storage, forcing use of default value",
"cache": "Cache",
"clear_asset_cache": "Clear asset cache",
"clear_emoji_cache": "Clear emoji cache",
"underline_links": "Make all links always underlined",
"width_efficient_post_layout": "Make post content always stretch the whole width",
"compact_profiles": "Reduce profile height on user pages"
},
"admin_dash": {
"window_title": "Administration",
"wip_notice": "This admin dashboard is experimental and WIP, {adminFeLink}.",
"old_ui_link": "old admin UI available here",
"reset_all": "Reset all",
"commit_all": "Save all",
"tabs": {
"nodb": "No DB Config",
"instance": "Instance",
"users": "Users",
"limits": "Limits",
"frontends": "Front-ends",
"mailer": "EMails",
"media_proxy": "Media Proxy",
"emoji": "Emoji",
"uploads": "Uploads",
"monitoring": "Monitoring",
"registrations": "Registrations",
"links": "Links",
"job_queues": "Job Queues",
"auth": "Auth",
"posts": "Posts",
"rate_limit": "Rate Limits",
"http": "HTTP",
"federation": "Federation",
"other": "Other"
},
"posts": {
"global": "Global settings",
"local": "Local posts",
"remote": "Remote posts"
},
"other": {
"uncategorized": "Uncategorized",
"user_backup": "User Backup",
"reports": "Reports",
"privileges": "Privileges"
},
"monitoring": {
"builtins": "Built-in Tools",
"prometheus": "Prometheus Exporter"
},
"federation": {
"global": "Global settings",
"restrictions": "Restrictions",
"activitypub": "ActivityPub"
},
"auth": {
"MFA": "Multi-factor Authentication",
"LDAP": "LDAP Settings",
"OAuth": "Oauth2 settings",
"TOTP": "One-time Passwords (TOTP)",
"backup_codes": "Backup codes"
},
"job_queues": {
"Gun": {
"title": "Gun queues",
"connections_pools": "Gun connections pool",
"pools": {
"title": "Gun worker pools",
"default": "Default pool",
"federation": "Federation pool",
"media": "Media pool",
"rich_media": "Rich media pool",
"upload": "Upload pool"
}
},
"Hackney": {
"title": "Hackney pools",
"federation": "Federation",
"media": "Media",
"rich_media": "Rich media",
"upload": "Upload"
},
"queues": "Queues"
},
"rate_limit": {
"global": "Global settings",
"rate_limit": "Rate Limit",
"amount": "Amount",
"all": "Everyone",
"unauthenticated": "Unauthenticated",
"authenticated": "Authenticated",
"period": "Time period",
"separate": "Separate rate limits for authenticated/unauthenticated users"
},
"nodb": {
"heading": "Database config is disabled",
"text": "You need to change backend config files so that {property} is set to {value}, see more in {documentation}.",
"documentation": "documentation",
"text2": "Most configuration options will be unavailable."
},
"captcha": {
"native": "Native",
"kocaptcha": "KoCaptcha"
},
"http": {
"outbound": "Outgoing connections",
"incoming": "Incoming connections",
"security": "HTTP Security",
"web_push": "Web Push",
"web_push_description": "Web Push VAPID settings. You can use the mix task web_push.gen.keypair to generate it."
},
"registrations": {
"welcome": {
"title": "Welcome message",
"description": "Send new users a message when they sign up",
"direct_message": "Via direct message",
"chat_message": "Via chat",
"email_message": "Via email"
},
"restrictions": "Restrictions",
"autofollow": "Autofollow"
},
"links": {
"no_scheme": "No scheme",
"link_previews": "Link previews",
"link_formatter": "Link formatter"
},
"uploads": {
"attachments": "Attachments settings",
"upload": "Upload",
"local_uploader": "Local files",
"filenames": "Filenames, Titles and Descriptions",
"uploader_settings": "Uploader settings"
},
"media_proxy": {
"basic": "Basic Settings",
"invalidation": "Cache Invalidation",
"limits": "Limits",
"thumbnails": "Thumbnail Generation",
"invalidation_settings": "Cache Invalidation"
},
"mailer": {
"styling": "Styling",
"assets": "Assets",
"colors": "Color palette",
"adapter": "Mailing Adapter",
"auth": "Authentication"
},
"users": {
"title": "Users",
"local_id": "Local ID",
"no_users_found": "No users found",
"labels": {
"query": "Search",
"nickname": "{'@'}handle",
"name": "Display Name",
"name_colon": "Name:",
"email": "Email",
"email_colon": "Email:",
"handle_colon": "Handle:",
"origin": "Origin",
"activity": "Activity",
"privileges": "Privileges"
},
"tags": {
"add_new": "Add New Tag",
"new_title": "Enter New Tag And Confirm",
"yes": "Add",
"no": "Abort"
},
"options": {
"all": "All",
"only_local": "Only Local",
"only_external": "Only External",
"only_active": "Only Active",
"only_deactivated": "Only Deactivated",
"only_admins": "Only Admins",
"only_privileged": "Only Privileged",
"only_moderators": "Only Moderators",
"only_unapproved": "Exclude Approved",
"only_unconfirmed": "Exclude Confirmed"
},
"filters": {
"show_direct": "Show Direct Messages",
"show_reblogs": "Show Reblogs"
},
"indicator": {
"admin": "Admin",
"moderator": "Moderator",
"active": "Active",
"deactivated": "Deactivated",
"confirmed": "Confirmed",
"unconfirmed": "Pending confirmation",
"approved": "Approved",
"suggested": "Suggested",
"unapproved": "Pending approval"
}
},
"limits": {
"arbitrary_limits": "Arbitrary limits",
"posts": "Post limits",
"other": "Misc. limits",
"uploads": "Attachments limits",
"users": "User profile limits",
"profile_fields": "Profile fields limits",
"user_uploads": "Profile media limits"
},
"frontend": {
"title": "Frontend management",
"repository": "Repository link",
"versions": "Available versions",
"build_url": "Build URL",
"reinstall": "Reinstall",
"is_default": "(Default)",
"is_default_custom": "(Default, version: {version})",
"install": "Install",
"install_version": "Install version {version}",
"more_install_options": "More install options",
"more_default_options": "More default setting options",
"set_default": "Set default",
"set_default_version": "Set version {version} as default",
"wip_notice": "Please note that this section is a WIP and lacks certain features as backend implementation of front-end management is incomplete.",
"default_frontend": "Default frontend",
"default_frontend_tip": "Default frontend will be shown to all users. Currently there's no way to for a user to select personal frontend. If you switch away from PleromaFE you'll most likely have to use old and buggy AdminFE to do instance configuration until we replace it.",
"default_frontend_unavail": "Default frontend settings are not available, as this requires configuration in the database",
"available_frontends": "Available for install",
"failure_installing_frontend": "Failed to install frontend {version}: {reason}",
"success_installing_frontend": "Frontend {version} successfully installed"
},
"emoji": {
"global_actions": "Global actions",
"reload": "Reload emoji",
"reload_short": "Refresh",
"advanced": "Advanced",
"importFS": "Import emoji from filesystem",
"import_pack": "Upload emoji pack",
"import_pack_short": "Import",
"error": "Error: {0}",
"create_pack": "Create pack",
"delete_pack": "Delete pack",
"new_pack_name": "New pack name",
"create": "Create",
"emoji_packs": "Emoji packs",
"remote_packs": "Remote packs",
"remote_packs_short": "Remote",
"do_list": "List",
"remote_pack_instance": "Remote pack instance",
"remote_pack_url": "Pack .zip URL",
"import_pack_url": "Import pack from URL",
"import_pack_file": "Import pack from a file",
"emoji_pack": "Emoji pack",
"edit_pack": "Edit pack",
"metadata": "Metadata",
"description": "Description",
"homepage": "Homepage",
"fallback_src": "Fallback source",
"fallback_sha256": "Fallback SHA256",
"share": "Share",
"save": "Save",
"save_meta": "Save metadata",
"revert_meta": "Revert metadata",
"delete": "Delete",
"revert": "Revert",
"add_file": "Add file",
"adding_new": "Adding new emoji",
"shortcode": "Shortcode",
"filename": "Filename",
"emoji_source": "Emoji file source",
"upload_url": "Upload from URL",
"new_shortcode": "Shortcode, leave blank to infer",
"new_filename": "Filename, leave blank to infer",
"delete_confirm": "Are you sure you want to delete {0}?",
"download_pack": "Download pack",
"downloading_pack": "Downloading {0}",
"download": "Download",
"download_as_name": "New name",
"download_as_name_full": "New name, leave blank to reuse",
"files": "Files",
"editing": "Editing {0}",
"copying": "Copying {0}",
"copy_to": "Copy to",
"copy_to_pack": "Copy to local pack",
"delete_title": "Delete?",
"metadata_changed": "Metadata different from saved",
"emoji_changed": "Unsaved emoji file changes, check highlighted emoji",
"replace_warning": "This will REPLACE the local pack of the same name",
"copied_successfully": "Successfully copied emoji \"{0}\" to pack \"{1}\""
},
"generic_enforcement": {
"if_available": "If available",
"always": "Always",
"never": "Never"
},
"instance": {
"instance": "Instance information",
"registrations": "User sign-ups",
"captcha_header": "CAPTCHA",
"kocaptcha": "KoCaptcha settings",
"pwa": {
"manifest": "PWA Manifest",
"optional": "(optional)",
"no_icons": "No icons defined",
"icon": {
"purpose": "Icon purpose",
"any": "Any",
"monochrome": "Monochrome",
"maskable": "Maskable"
}
},
"misc_brand": "Miscellaneous elements",
"branding": "Branding",
"access": "Instance access",
"rich_metadata": "Metadata",
"restrict": {
"header": "Restrict access for anonymous visitors",
"description": "Detailed setting for allowing/disallowing access to certain aspects of API. By default (indeterminate state) it will disallow if instance is not public, ticked checkbox means disallow access even if instance is public, unticked means allow access even if instance is private. Please note that unexpected behavior might happen if some settings are set, i.e. if profile access is disabled posts will show without profile information.",
"timelines": "Timelines access",
"profiles": "User profiles access",
"activities": "Statuses/activities access"
},
":unauthenticated": "Unauthenticated",
":all": "Everyone"
},
"temp_overrides": {
":pleroma": {
":connections_pool": {
":max_idle_time": {
"label": "Maximum idle time",
"description": "Maximum idle time before CONFIRM"
},
":retry": {
"label": "Retry",
"description": "Number of retries for making a connection CONFIRM"
}
},
":hackney_pools": {
":rich_media": {
"label": "Rich media",
"description": "idk",
":max_connections": {
"label": "Max connections",
"description": "Number workers in the pool."
},
":timeout": {
"label": "Timeout",
"description": "Timeout while `hackney` will wait for response."
}
}
},
":pools": {
":rich_media": {
"label": "Rich media",
"description": "idk",
":size": {
"label": "Size",
"description": "Maximum number of concurrent requests in the pool."
},
":max_waiting": {
"label": "Max waiting",
"description": "Maximum number of requests waiting for other requests to finish. After this number is reached, the pool will start returning errors when a new request is made"
},
":recv_timeout": {
"label": "Recv timeout",
"description": "Timeout for the pool while gun will wait for response"
}
}
},
":rate_limit": {
":oauth_app_creation": {
"label": "OAuth app creation",
"description": "For registering new OAuth App ID"
},
":ap_routes": {
"label": "ActivityPub",
"description": "Federation endpoints"
},
":account_confirmation_resend": {
"label": "Account confirmation resend",
"description": "How often user can resend confirmation mail"
}
},
"Pleroma_DOT_Formatter": {
":attribute_toggle": {
"label": "Set {attr} attribute"
},
":truncate_toggle": {
"label": "Truncate"
},
":class": {
"label": "Value"
},
":rel": {
"label": "Value"
}
},
"Pleroma_DOT_Uploaders_DOT_Uploader": {
":timeout": {
"label": "Timeout",
"description": "Amount of milliseconds before dropping connection"
}
},
"Pleroma_DOT_Upload": {
":default_description": {
"label": "Default description",
"description": "Default description to give to a file. Setting it to ':filename' will use file's filename as description."
}
},
":instance": {
":public": {
"label": "Instance is public",
"description": "Disabling this will make all API accessible only for logged-in users, this will make Public and Federated timelines inaccessible to anonymous visitors."
},
":limit_to_local_content": {
"label": "Limit search to local content",
"description": "Disables global network search for unauthenticated (default), all users or none"
},
":description_limit": {
"label": "Limit",
"description": "Character limit for attachment descriptions"
},
":background_image": {
"label": "Background image",
"description": "Background image (primarily used by PleromaFE)"
}
},
":http_security": {
":allow_unsafe_eval": {
"label": "Allow unsafe-eval",
"description": "Allow unsafe evaluation of scripts (required for Flash support)"
},
":report_url": {
"label": "Report URL",
"description": "URL to report security violations to"
}
}
}
}
},
"time": {
"unit": {
"days": "{0} day | {0} days",
"days_short": "{0}d",
"days_suffix": "day(s)",
"hours": "{0} hour | {0} hours",
"hours_short": "{0}h",
"hours_suffix": "hour(s)",
"minutes": "{0} minute | {0} minutes",
"minutes_short": "{0}min",
"minutes_suffix": "minute(s)",
"months": "{0} month | {0} months",
"months_short": "{0}mo",
"months_suffix": "month(s)",
"seconds": "{0} second | {0} seconds",
"seconds_short": "{0}s",
"seconds_suffix": "second(s)",
"weeks": "{0} week | {0} weeks",
"weeks_short": "{0}w",
"weeks_suffix": "week(s)",
"years": "{0} year | {0} years",
"years_short": "{0}y",
"years_suffix": "year(s)"
},
"in_future": "in {0}",
"in_past": "{0} ago",
"now": "just now",
"now_short": "now"
},
"timeline": {
"collapse": "Collapse",
"conversation": "Conversation",
"unsubjected_conversation": "Untitled conversation",
"error": "Error fetching timeline: {0}",
"load_older": "Load older statuses",
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated",
"show_new": "Show new",
"reload": "Reload",
"up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses",
"no_statuses": "No statuses",
"socket_reconnected": "Realtime connection established",
"socket_disconnected": "Realtime connection unavaialable",
"socket_broke": "Realtime connection lost: CloseEvent code {0}",
"quick_view_settings": "Quick view settings",
"quick_filter_settings": "Quick filter settings",
"filter_settings": "Filter"
},
"status": {
"favorites": "Favorites",
"repeats": "Repeats",
"quotes": "Quotes",
"unsubjected": "Untitled status",
"favicon": "Instance favicon",
"status_actions": "Post actions",
"repeat_confirm": "Do you really want to repeat this status?",
"repeat_confirm_title": "Repeat confirmation",
"repeat_confirm_accept_button": "Repeat",
"repeat_confirm_cancel_button": "Do not repeat",
"delete": "Delete status",
"delete_error": "Error deleting status: {0}",
"edit": "Edit status",
"edited_at": "(last edited {time})",
"open_in_chat_view": "Open in chat view",
"open_in_thread_view": "Open in thread view",
"pin": "Pin on profile",
"unpin": "Unpin from profile",
"pinned": "Pinned",
"bookmark": "Bookmark",
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?",
"delete_confirm_title": "Delete confirmation",
"delete_confirm_accept_button": "Delete",
"delete_confirm_cancel_button": "Keep",
"reply_to": "Reply to",
"reply_to_selected": "Replying to selected message",
"reply_to_last": "Replying to last message",
"reply_to_with_icon": "{icon} {replyTo}",
"broken_reply": "Message belongs to the thread but is not a reply",
"reply_to_with_arg": "{replyToWithIcon} {user}",
"mentions": "Mentions",
"replies_list": "Replies:",
"replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):",
"replies_counter": "1 reply | {0} replies",
"favorites_counter": "1 favorite | {0} favorites",
"repeats_counter": "1 repeat | {0} repeats",
"reacts_counter": "1 react | {0} reacts",
"show_muted_status": "Show muted status",
"mute_ellipsis": "Mute…",
"mute_user": "Mute user",
"unmute_user": "Unmute user",
"mute_domain": "Mute domain",
"unmute_domain": "Unmute domain",
"mute_conversation": "Mute conversation",
"unmute_conversation": "Unmute conversation",
"status_unavailable": "Status unavailable",
"copy_link": "Copy link to status",
"external_source": "External source",
"muted_words": "Wordfiltered: {word} | Wordfiltered: {word} and {numWordsMore} more words",
"muted_filters": "Filtered: {name} | Wordfiltered: {name} and {filtersMore} more words",
"multi_reason_mute": "{main} + one more reason | {main} + {numReasonsMore} more reasons",
"muted_user": "User muted",
"thread_muted": "Thread muted",
"thread_muted_and_words": ", has words:",
"sensitive_muted": "Muting sensitive content",
"bot_muted": "Muting bot content",
"show_full_subject": "Show full subject",
"hide_full_subject": "Hide full subject",
"show_content": "Show content",
"hide_content": "Hide content",
"status_deleted": "This post was deleted",
"unknown_user": "unknown user",
"unknown_user_info": "Unable to fetch information about this user",
"nsfw": "NSFW",
"expand": "Expand",
"you": "(You)",
"plus_more": "+{number} more",
"attachment": "Attachment",
"attachment_actions": "Attachment actions",
"attachments": "Attachments",
"many_attachments": "Post has {number} attachment(s)",
"collapse_attachments": "Collapse attachments",
"show_all_attachments": "Show all attachments",
"show_attachment_in_modal": "Show in media modal",
"show_attachment_description": "Preview description (open attachment for full description)",
"attachment_description": "Attachment description",
"hide_attachment": "Hide attachment",
"remove_attachment": "Remove attachment",
"attachment_stop_flash": "Stop Flash player",
"move_up": "Shift attachment left",
"move_down": "Shift attachment right",
"nsfw_placeholder": "Sensitive attachment placeholder",
"open_gallery": "Open gallery",
"thread_hide": "Hide this thread",
"thread_show": "Show this thread",
"thread_show_full": "Show everything under this thread ({numStatus} status in total, max depth {depth}) | Show everything under this thread ({numStatus} statuses in total, max depth {depth})",
"thread_show_full_with_icon": "{icon} {text}",
"thread_follow": "See the remaining part of this thread ({numStatus} status in total) | See the remaining part of this thread ({numStatus} statuses in total)",
"thread_follow_with_icon": "{icon} {text}",
"ancestor_follow": "See {numReplies} other reply under this status | See {numReplies} other replies under this status",
"ancestor_follow_with_icon": "{icon} {text}",
"show_all_conversation_with_icon": "{icon} {text}",
"show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)",
"show_only_conversation_under_this": "Only show replies to this status",
"status_history": "Status history",
"reaction_count_label": "{num} person reacted | {num} people reacted",
"hide_quote": "Hide the quoted status",
"display_quote": "Display the quoted status",
"invisible_quote": "Quoted status unavailable: {link}",
"more_actions": "More actions on this status",
"loading": "Loading...",
"load_error": "Unable to load status: {error}",
"interact_error": "Error interacting with status: {error}",
"admin_change_scope": "Change visibility",
"mark_as_sensitive": "Sensitive",
"mark_as_non-sensitive": "Non-sensitive"
},
"user_card": {
"banner": "User banner image",
"avatar": "User avatar",
"edit": "Edit profile",
"approve": "Approve",
"make_post": "Make a new post",
"external_view": "Open external profile",
"open_profile": "Open profile",
"actions": "Actions",
"interactions": "Interactions",
"bio": "User description/bio",
"other": "Other",
"approve_confirm_title": "Approve confirmation",
"approve_confirm_accept_button": "Approve",
"approve_confirm_cancel_button": "Do not approve",
"approve_confirm": "Do you want to approve {user}'s follow request?",
"approve_error": "Failure approving follow request: {error}",
"block": "Block",
"blocked": "Blocked!",
"block_confirm_title": "Block confirmation",
"block_confirm": "Do you really want to block {user}?",
"block_confirm_accept_button": "Block",
"block_confirm_cancel_button": "Do not block",
"deactivated": "Deactivated",
"deny": "Deny",
"deny_confirm_title": "Deny confirmation",
"deny_confirm_accept_button": "Deny",
"deny_confirm_cancel_button": "Do not deny",
"deny_confirm": "Do you want to deny {user}'s follow request?",
"deny_error": "Failure denying follow request: {error}",
"edit_profile": "Edit profile",
"favorites": "Favorites",
"follow": "Follow",
"follow_cancel": "Cancel request",
"follow_sent": "Request sent!",
"follow_progress": "Requesting…",
"follow_unfollow": "Unfollow",
"unfollow_confirm_title": "Unfollow confirmation",
"unfollow_confirm": "Do you really want to unfollow {user}?",
"unfollow_confirm_accept_button": "Unfollow",
"unfollow_confirm_cancel_button": "Do not unfollow",
"followees": "Following",
"followers": "Followers",
"following": "Following!",
"follows_you": "Follows you!",
"hidden": "Hidden",
"its_you": "It's you!",
"media": "Media",
"mention": "Mention",
"message": "Message",
"mute": "Mute",
"muted": "Muted",
"mute_confirm_title": "Mute confirmation",
"mute_confirm": "Do you really want to mute {user}?",
"mute_domain_confirm": "Do you really want to mute entire {domain}?",
"mute_confirm_accept_button": "Mute",
"mute_confirm_cancel_button": "Do not mute",
"mute_or": "or",
"expire_in": "Expire in",
"expire_mute_message": "Are you sure you want to mute {0}?",
"expire_block_message": "Are you sure you want to block {0}?",
"dont_ask_again_mute": "Always mute users this way",
"dont_ask_again_block": "Always block users this way",
"mute_block_temporarily": "Temporarily",
"mute_block_forever": "Forever",
"mute_block_never": "Never",
"mute_block_ask": "Ask",
"default_mute_expiration": "Always mute users",
"default_block_expiration": "Always block users",
"default_expiration_time": "Expire in",
"mute_expires_forever": "Muted forever",
"mute_expires_at": "Muted until {0}",
"block_expires_forever": "Blocked forever",
"block_expires_at": "Blocked until {0}",
"mute_duration_prompt": "Mute this user for (0 for indefinite time):",
"statuses_per_day": "Statuses per day",
"remote_follow": "Remote follow",
"remove_follower": "Remove follower",
"remove_follower_confirm_title": "Remove follower confirmation",
"remove_follower_confirm_accept_button": "Remove",
"remove_follower_confirm_cancel_button": "Keep",
"remove_follower_confirm": "Do you really want to remove {user} from your followers?",
"report": "Report",
"statuses": "Statuses",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"unblock": "Unblock",
"unblock_progress": "Unblocking…",
"block_progress": "Blocking…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…",
"mute_progress": "Muting…",
"hide_repeats": "Hide repeats",
"show_repeats": "Show repeats",
"bot": "Bot",
"group": "Group",
"birthday": "Born {birthday}",
"joined": "Joined",
"admin_data": {
"data": "Administrative info",
"registration_reason": "Registration reason",
"tags": "Tags"
},
"admin_menu": {
"moderation": "Moderation",
"grant_admin": "Grant Admin",
"revoke_admin": "Revoke Admin",
"grant_moderator": "Grant Moderator",
"revoke_moderator": "Revoke Moderator",
"activate_account": "Activate",
"deactivate_account": "Deactivate",
"delete_account": "Delete",
"suggest_account": "Add to suggested",
"remove_suggested_account": "Remove from suggested",
"approve_account": "Approve",
"confirm_account": "Confirm",
"show_statuses": "Show all posts",
"disable_mfa": "Disable MFA",
"force_nsfw": "Mark all posts as NSFW",
"strip_media": "Remove media from posts",
"force_unlisted": "Force posts to be unlisted",
"sandbox": "Force posts to be followers-only",
"disable_remote_subscription": "Disallow following user from remote instances",
"disable_any_subscription": "Disallow following user at all",
"quarantine": "Disallow user posts from federating",
"require_password_change": "Require Password Change",
"resend_confirmation": "Resend Confirmation Email",
"confirm_modal": {
"delete_title": "User deletion",
"delete_content": "Delete user {user}? | Delete {count} users?",
"delete_content_2": "This will permanently delete the data from this accounts and deactivate it. Are you absolutely sure?",
"activate_title": "User activation",
"activate_content": "Activate user {user}? | Activate {count} users?",
"deactivate_content": "Dectivate user {user}? | Dectivate {count} users?",
"approval_title": "Approve users",
"approval_content": "Approve user {user}? | Approve {count} users?",
"confirm_title": "Confirm users",
"confirm_content": "Approve user {user}? | Approve {count} users?",
"suggest_title": "Suggest users",
"add_suggest_content": "Add user {user} to suggested users list? | Add {count} users to suggested users list?",
"remove_suggest_content": "Remove user {user} from suggested users list? | Add {count} users to suggested users list?",
"rights_title": "Promote users",
"grant_rights_content": "Grant user {user} {name} role? | Grant {count} users {name} role?",
"revoke_rights_content": "Revoke {name} role from user {user}? | Revoke {name} from {count} users?",
"tag_title": "Assign user policy",
"assign_tag_content": "Assign {user} a {name} policy? | Assign {name} policy to {count} users?",
"unassign_tag_content": "Unassign policy {name} from {user}? | Unassign policy {name} from {count} users?",
"resend_confirmation_title": "Email confirmation resend",
"resend_confirmation_content": "Resend confirmation email to {count} users?",
"disable_mfa_title": "Disable MFA",
"disable_mfa_content": "Disable Mult-Factor Authentication for {count} users?",
"require_password_change_title": "Force password change",
"require_password_change_content": "Force {count} users to change password on next login?",
"add": "Add",
"remove": "Remove",
"delete": "Delete",
"activate": "Activate",
"deactivate": "Deactivate",
"grant": "Grant",
"revoke": "Revoke",
"approve": "Approve",
"confirm": "Confirm",
"assign": "Assign",
"unassign": "Unassign",
"send": "Send"
}
},
"highlight_new": {
"disabled": "Don't highlight",
"solid": "Solid background",
"striped": "Striped background",
"side": "Side stripe"
},
"personal_note": "Personal note",
"note_blank_click": "Click to add note",
"highlight_header": "Highlight user's posts and mentions",
"tags": {
"mrf_tag:media-force-nsfw": "Mark as sensitive",
"mrf_tag:media-strip": "Remove attachments",
"mrf_tag:force-unlisted": "Force unlisted",
"mrf_tag:sandbox": "Remove from public timelines",
"mrf_tag:disable-remote-subscription": "Reject non-local follow requests",
"mrf_tag:disable-any-subscription": "Reject any follow requests"
}
},
"user_profile": {
"timeline_title": "User timeline",
"profile_does_not_exist": "Sorry, this profile does not exist.",
"profile_loading_error": "Sorry, there was an error loading this profile."
},
"user_reporting": {
"title": "Reporting {0}",
"add_comment_description": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
"additional_comments": "Additional comments",
"forward_description": "The account is from another server. Send a copy of the report there as well?",
"forward_to": "Forward to {0}",
"submit": "Submit",
"generic_error": "An error occurred while processing your request."
},
"who_to_follow": {
"more": "More",
"who_to_follow": "Who to follow"
},
"tool_tip": {
"media_upload": "Upload media",
"mentions": "Mentions",
"repeat": "Repeat",
"unrepeat": "Unrepeat",
"reply": "Reply",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"add_reaction": "Add Reaction",
"add_quote": "Add quote",
"user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request",
"bookmark": "Bookmark",
"toggle_expand": "Expand or collapse notification to show post in full",
"toggle_mute": "Expand or collapse notification to reveal muted content",
"autocomplete_available": "{number} result is available. Use up and down keys to navigate through them. | {number} results are available. Use up and down keys to navigate through them."
},
"upload": {
"error": {
"base": "Upload failed.",
"message": "Upload failed: {0}",
"file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Try again later"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
},
"search": {
"people": "People",
"hashtags": "Hashtags",
"person_talking": "{count} person talking",
"people_talking": "{count} people talking",
"no_results": "No results",
"no_more_results": "No more results",
"load_more": "Load more results",
"advanced": "Show advanced search",
"by_author": "Author:"
},
"user_selector": {
"select": "Select",
"username_input_placeholder": "Username, e.g. foo{'@'}example.org",
"edit": "Edit",
"clear": "Clear",
"none": "Not selected"
},
"password_reset": {
"forgot_password": "Forgot password?",
"password_reset": "Password reset",
"email": "Email",
"instruction": "Enter your email address or username. We will send you a link to reset your password.",
"placeholder": "Your email or username",
"check_email": "Check your email for a link to reset your password.",
"return_home": "Return to the home page",
"too_many_requests": "You have reached the limit of attempts, try again later.",
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
"password_reset_required": "You must reset your password to log in.",
"password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
},
"chats": {
"you": "You:",
"message_user": "Message {nickname}",
"delete": "Delete",
"chats": "Chats",
"new": "New Chat",
"empty_message_error": "Cannot post empty message",
"more": "More",
"delete_confirm": "Do you really want to delete this message?",
"error_loading_chat": "Something went wrong when loading the chat.",
"error_sending_message": "Something went wrong when sending the message.",
"empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
},
"bookmarks": {
"manage_bookmark_folders": "Manage bookmark folders"
},
"lists": {
"lists": "Lists",
"new": "New List",
"title": "List title",
"search": "Search users",
"create": "Create",
"save": "Save changes",
"delete": "Delete list",
"following_only": "Limit to Following",
"manage_lists": "Manage lists",
"manage_members": "Manage list members",
"add_members": "Search for more users",
"remove_from_list": "Remove from list",
"add_to_list": "Add to list",
"is_in_list": "Already in list",
"editing_list": "Editing list {listTitle}",
"creating_list": "Creating new list",
"update_title": "Save Title",
"really_delete": "Really delete list?",
"error": "Error manipulating lists: {0}"
},
"file_type": {
"audio": "Audio",
"video": "Video",
"image": "Image",
"file": "File"
},
"display_date": {
"today": "Today"
},
"update": {
"big_update_title": "Please bear with us",
"big_update_content": "We haven't had a release in a while, so things might look and feel different than what you're used to.",
"big_update_content2": "We implemented synchronized settings! This means (nearly) all your settings are now properly synchronized between devices and sessions. We try to migrate settings from old config but migration is performed only once, so some settings might be incorrect.",
"update_bugs": "Please report any issues and bugs on {pleromaForgejo}, as we have changed a lot, and although we test thoroughly and use development versions ourselves, we may have missed some things. We welcome your feedback and suggestions on issues you might encounter, or how to improve Pleroma and Pleroma-FE.",
"update_bugs2": "Let us know if you don't want certain setting synchronized on {pleromaForgejo}.",
"update_bugs_gitlab": "Pleroma GitLab",
"update_bugs_forgejo": "Pleroma Forgejo",
"update_changelog": "For more details on what's changed, see {theFullChangelog}.",
"update_changelog_here": "the full changelog",
"art_by": "Art by {linkToArtist}"
},
"unicode_domain_indicator": {
"tooltip": "This domain contains non-ascii characters."
},
"drafts": {
"drafts": "Drafts",
"no_drafts": "You have no drafts",
"clean_drafts": "Remove all drafts",
"empty": "(No content)",
"poll_tooltip": "Draft contains a poll",
"continue": "Continue composing",
"save": "Save without posting",
"abandon": "Abandon draft",
"abandon_confirm_title": "Abandon confirmation",
"abandon_confirm": "Do you really want to abandon this draft?",
"abandon_confirm_accept_button": "Abandon",
"abandon_confirm_cancel_button": "Keep",
"abandon_all_confirm": "Do you really want to abandon all drafts?",
"replying": "Replying to {statusLink}",
"editing": "Editing {statusLink}",
"unavailable": "(unavailable)"
},
"splash": {
"loading": "Loading...",
"theme": "Applying theme, please wait warmly...",
"fun_1": "Drink more water",
"fun_2": "Take it easy!",
"fun_3": "Suya...",
"fun_4": "My Pleroma machine is full power!",
"error": "Something went wrong"
},
"bookmark_folders": {
"select_folder": "Select bookmark folder",
"creating_folder": "Creating bookmark folder",
"editing_folder": "Editing folder {folderName}",
"emoji": "Emoji",
"name": "Folder name",
"new": "New Folder",
"create": "Create folder",
"delete": "Delete folder",
"update_folder": "Save changes",
"really_delete": "Do you really want to delete the folder?",
"error": "Error manipulating bookmark folders: {0}"
}
}
diff --git a/src/services/hosted/hosted.js b/src/services/hosted/hosted.js
new file mode 100644
index 0000000000..323d72821a
--- /dev/null
+++ b/src/services/hosted/hosted.js
@@ -0,0 +1,57 @@
+// Hosted mode: this frontend served from its own domain, logging into an
+// instance the user picks. The site turns it on in its static/config.json
+// ("hosted": true); an instance serving the frontend itself never does.
+
+const INSTANCE_KEY = 'pleroma-fe-hosted-instance'
+
+export const isHosted = (staticConfig) => staticConfig?.hosted === true
+
+// "pleroma.example", "@me@pleroma.example" or a URL on it -> its origin
+export const instanceOrigin = (input) => {
+ const text = (input ?? '').trim().replace(/^@?[^@\s/]+@/, '')
+ if (!text) return null
+ try {
+ const url = new URL(/^https?:\/\//i.test(text) ? text : `https://${text}`)
+ const local = url.hostname === 'localhost'
+ if (!url.hostname.includes('.') && !local) return null
+ // the login token never goes over plain http to a real server
+ if (!local) url.protocol = 'https:'
+ return url.origin
+ } catch {
+ return null
+ }
+}
+
+export const chosenInstance = () => localStorage.getItem(INSTANCE_KEY)
+export const chooseInstance = (origin) =>
+ localStorage.setItem(INSTANCE_KEY, origin)
+export const forgetInstance = () => localStorage.removeItem(INSTANCE_KEY)
+
+// an instance answers /api/v1/instance with JSON, and lets this site ask
+export const checkInstance = async (origin, fetch = window.fetch) => {
+ try {
+ const res = await fetch(`${origin}/api/v1/instance`)
+ if (!res.ok) return false
+ const data = await res.json()
+ return typeof data?.uri === 'string'
+ } catch {
+ return false
+ }
+}
+
+// The instance's frontend configuration, for use on this site: its paths
+// (logo, background, default avatar…) point at the instance, app routes
+// stay routes, and login goes through the instance's own login page.
+export const instanceConfig = (config, resolve) => ({
+ ...Object.fromEntries(
+ Object.entries(config ?? {}).map(([key, value]) => [
+ key,
+ typeof value === 'string' &&
+ value.startsWith('/') &&
+ !key.startsWith('redirect')
+ ? resolve(value)
+ : value,
+ ]),
+ ),
+ loginMethod: 'token',
+})
diff --git a/src/stores/admin_settings.js b/src/stores/admin_settings.js
index 10efdbfce8..a8aaecf520 100644
--- a/src/stores/admin_settings.js
+++ b/src/stores/admin_settings.js
@@ -1,619 +1,620 @@
import { cloneDeep, differenceWith, get, isEqual, set } from 'lodash-es'
import { defineStore } from 'pinia'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useUsersStore } from 'src/stores/users.js'
import {
addNewEmojiFile,
changeStatusScope,
createEmojiPack,
deleteAccounts,
deleteEmojiPack,
disableMFA,
downloadRemoteEmojiPack,
downloadRemoteEmojiPackZIP,
getAvailableFrontends,
getInstanceConfigDescriptions,
getInstanceDBConfig,
getUserData,
importEmojiFromFS,
installFrontend,
listRemoteEmojiPacks,
listStatuses,
listUsers,
pushInstanceDBConfig,
reloadEmoji,
requirePasswordChange,
resendConfirmationEmail,
setUsersActivationStatus,
setUsersApprovalStatus,
setUsersConfirmationStatus,
setUsersRight,
setUsersSuggestionStatus,
setUsersTags,
} from 'src/api/admin.js'
import { listEmojiPacks } from 'src/api/public.js'
import { parseStatus } from 'src/services/entity_normalizer/entity_normalizer.service.js'
export const defaultState = {
frontends: [],
loaded: false,
needsReboot: null,
config: null,
modifiedPaths: null,
descriptions: null,
draft: null,
dbConfigEnabled: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
}
export const useAdminSettingsStore = defineStore('adminSettings', {
state: () => ({
...cloneDeep(defaultState),
}),
actions: {
// Configuration Stuff
setInstanceAdminNoDbConfig() {
this.loaded = false
this.dbConfigEnabled = false
},
updateAdminSettings({ config, modifiedPaths }) {
this.loaded = true
this.dbConfigEnabled = true
this.config = config
this.modifiedPaths = modifiedPaths
},
updateAdminDescriptions({ descriptions }) {
this.descriptions = descriptions
},
updateAdminDraft({ path, value }) {
const [group, key, subkey] = path
const parent = [group, key, subkey]
set(this.draft, path, value)
// force-updating grouped draft to trigger refresh of group settings
if (path.length > parent.length) {
set(this.draft, parent, cloneDeep(get(this.draft, parent)))
}
},
resetAdminDraft() {
this.draft = cloneDeep(this.config)
},
loadAdminStuff() {
getInstanceDBConfig({
credentials: useOAuthStore().token,
})
.then(({ data: backendDbConfig }) =>
this.setInstanceAdminSettings({
credentials: useOAuthStore().token,
backendDbConfig,
}),
)
.catch(({ statusCode, statusText }) => {
if (statusCode === 400) {
if (/configurable_from_database/.test(statusText)) {
this.setInstanceAdminNoDbConfig()
}
}
})
if (this.descriptions === null) {
getInstanceConfigDescriptions({
credentials: useOAuthStore().token,
}).then(({ data: backendDescriptions }) =>
this.setInstanceAdminDescriptions({
credentials: useOAuthStore().token,
backendDescriptions,
}),
)
}
},
setInstanceAdminSettings({ backendDbConfig }) {
const config = this.config || {}
const modifiedPaths = new Set()
backendDbConfig.configs.forEach((c) => {
const path = [c.group, c.key]
if (c.db) {
// Path elements can contain dot, therefore we use ' -> ' as a separator instead
// Using strings for modified paths for easier searching
c.db.forEach((x) => modifiedPaths.add([...path, x].join(' -> ')))
}
// we need to preserve tuples on second level only, possibly third
// but it's not a case right now.
const convert = (value, preserveTuples, preserveTuplesLv2) => {
if (Array.isArray(value) && value.length > 0 && value[0].tuple) {
if (!preserveTuples) {
return value.reduce((acc, c) => {
if (c.tuple == null) {
return {
...acc,
[c]: c,
}
}
return {
...acc,
[c.tuple[0]]: convert(c.tuple[1], preserveTuplesLv2),
}
}, {})
} else {
return value.map((x) => x.tuple)
}
} else {
if (!preserveTuples) {
return value
} else {
return value.tuple
}
}
}
// for most stuff we want maps since those are more convenient
// however this doesn't allow for multiple values per same key
// so for those cases we want to preserve tuples as-is
// right now it's made exclusively for :pleroma.:rate_limit
// so it might not work properly elsewhere
const preserveTuples = path.find((x) => x === ':rate_limit')
set(config, path, convert(c.value, false, preserveTuples))
})
// patching http adapter config to be easier to handle
const adapter = config[':pleroma'][':http'][':adapter']
if (Array.isArray(adapter)) {
config[':pleroma'][':http'][':adapter'] = {
[':ssl_options']: {
[':versions']: [],
},
}
}
this.updateAdminSettings({ config, modifiedPaths })
this.resetAdminDraft()
},
setInstanceAdminDescriptions({ backendDescriptions }) {
const convert = (
{ children, description, label, key = '<ROOT>', group, suggestions },
path,
acc,
) => {
const newPath = group ? [group, key] : [key]
const obj = { description, label, suggestions }
if (Array.isArray(children)) {
children.forEach((c) => {
convert(c, newPath, obj)
})
}
set(acc, newPath, obj)
}
const descriptions = {}
backendDescriptions.forEach((d) => convert(d, '', descriptions))
this.updateAdminDescriptions({ descriptions })
},
// This action takes draft state, diffs it with live config state and then pushes
// only differences between the two. Difference detection only work up to subkey (third) level.
pushAdminDraft() {
// TODO cleanup paths in modifiedPaths
const convert = (value) => {
if (typeof value !== 'object') {
return value
} else if (Array.isArray(value)) {
return value.map(convert)
} else {
return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] }))
}
}
// Getting all group-keys used in config
const allGroupKeys = Object.entries(this.config)
.map(([group, lv1data]) =>
Object.keys(lv1data).map((key) => ({ group, key })),
)
.flat()
// Only using group-keys where there are changes detected
const changedGroupKeys = allGroupKeys.filter(({ group, key }) => {
return !isEqual(this.config[group][key], this.draft[group][key])
})
// Here we take all changed group-keys and get all changed subkeys
const changed = changedGroupKeys.map(({ group, key }) => {
const config = this.config[group][key]
const draft = this.draft[group][key]
// We convert group-key value into entries arrays
const eConfig = Object.entries(config)
const eDraft = Object.entries(draft)
// Then those entries array we diff so only changed subkey entries remain
// We use the diffed array to reconstruct the object and then shove it into convert()
return {
group,
key,
value: convert(
Object.fromEntries(differenceWith(eDraft, eConfig, isEqual)),
),
}
})
pushInstanceDBConfig({
credentials: useOAuthStore().token,
payload: {
configs: changed,
},
})
.then(() =>
getInstanceDBConfig({
credentials: useOAuthStore().token,
}).then(({ data }) => data),
)
.then((backendDbConfig) =>
this.setInstanceAdminSettings({
credentials: useOAuthStore().token,
backendDbConfig,
}),
)
},
pushAdminSetting({ path, value }) {
const [group, key, ...rest] = Array.isArray(path)
? path
: path.split(/\./g)
const clone = {} // not actually cloning the entire thing to avoid excessive writes
set(clone, rest, value)
// TODO cleanup paths in modifiedPaths
const convert = (value) => {
if (typeof value !== 'object') {
return value
} else if (Array.isArray(value)) {
return value.map(convert)
} else {
return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] }))
}
}
pushInstanceDBConfig({
credentials: useOAuthStore().token,
payload: {
configs: [
{
group,
key,
value: convert(clone),
},
],
},
})
.then(() =>
getInstanceDBConfig({
credentials: useOAuthStore().token,
}).then(({ data }) => data),
)
.then((backendDbConfig) =>
this.setInstanceAdminSettings({
credentials: useOAuthStore().token,
backendDbConfig,
}),
)
},
resetAdminSetting({ path }) {
const [group, key, subkey] = Array.isArray(path)
? path
: path.split(/\./g)
this.modifiedPaths.delete(path)
return pushInstanceDBConfig({
credentials: useOAuthStore().token,
payload: {
configs: [
{
group,
key,
delete: true,
subkeys: [subkey],
},
],
},
})
.then(() =>
getInstanceDBConfig({
credentials: useOAuthStore().token,
}).then(({ data }) => data),
)
.then((backendDbConfig) =>
this.setInstanceAdminSettings({ backendDbConfig }),
)
},
// Frontends Stuff
loadFrontendsStuff() {
getAvailableFrontends({
credentials: useOAuthStore().token,
}).then(({ data: frontends }) =>
this.setAvailableFrontends({ frontends }),
)
},
setAvailableFrontends({ frontends }) {
this.frontends = frontends.map((f) => {
f.installedRefs = f.installed_refs
if (f.name === 'pleroma-fe') {
f.refs = ['master', 'develop']
} else {
f.refs = [f.ref]
}
return f
})
},
- installFrontend() {
+ installFrontend({ payload }) {
return installFrontend({
credentials: useOAuthStore().token,
+ payload,
}).then(({ data }) => data)
},
// Statuses stuff
async fetchStatuses(opts) {
const {
data: { total, activities },
timestamp,
} = await listStatuses({
credentials: useOAuthStore().token,
opts,
})
const statuses = activities.map(parseStatus)
useStatusesStore().addNewStatuses({ statuses, timestamp })
return {
items: statuses,
count: total,
}
},
async changeStatusScope(opts) {
const { data, timestamp } = await changeStatusScope({
credentials: useOAuthStore().token,
opts,
})
const status = parseStatus(data)
useStatusesStore().addNewStatuses({ statuses: [status], timestamp })
},
// Users stuff
async fetchUsers(opts) {
const {
data: { users, count },
} = await listUsers({
credentials: useOAuthStore().token,
opts,
})
return {
items: await Promise.all(
users.map(async (user) => {
const fullUser = await useUsersStore().fetchUserIfMissing({
id: user.id,
})
if (fullUser) useUsersStore().updateUserAdminData(user.id, user)
return fullUser
}),
),
count,
}
},
async getUserData({ user }) {
const api = getUserData
const { screen_name } = user
const result = await api({
credentials: useOAuthStore().token,
screen_name,
})
const { data } = result
useUsersStore().updateUserAdminData(data.id, data)
},
async deleteUsers({ users }) {
const screen_names = users.map((u) => u.screen_name)
const api = deleteAccounts
const resultUserIds = await api({
credentials: useOAuthStore().token,
screen_names,
})
resultUserIds.data.forEach((userId) => {
useStatusesStore().wipeUserStatuses(userId)
// Users are technically never deleted, just deactivated
// so there's no real need to delete them from store.
})
return resultUserIds
},
resendConfirmationEmail({ users }) {
const screen_names = users.map((u) => u.screen_name)
return resendConfirmationEmail({
credentials: useOAuthStore().token,
screen_names,
}).then(({ data }) => data)
},
requirePasswordChange({ users }) {
const screen_names = users.map((u) => u.screen_name)
return requirePasswordChange({
credentials: useOAuthStore().token,
screen_names,
}).then(({ data }) => data)
},
// Singular only!
disableMFA({ user }) {
const { screen_name } = user
return disableMFA({
credentials: useOAuthStore().token,
screen_name,
}).then(({ data }) => data)
},
async setUsersTags({ users, tags, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersTags
await api({
credentials: useOAuthStore().token,
screen_names,
tags,
value,
})
users.forEach((user) => {
this.getUserData({ user })
})
},
async setUsersRight({ users, right, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersRight
await api({
credentials: useOAuthStore().token,
screen_names,
right,
value,
})
users.forEach((user) => {
useUsersStore().updateRight(user.id, right, value)
})
},
async setUsersActivationStatus({ users, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersActivationStatus
const resultUsers = await api({
credentials: useOAuthStore().token,
screen_names,
value,
})
resultUsers.data.forEach((user) => {
useUsersStore().updateUserAdminData(user.id, user)
})
},
async setUsersSuggestionStatus({ users, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersSuggestionStatus
const resultUsers = await api({
credentials: useOAuthStore().token,
screen_names,
value,
})
resultUsers.data.forEach((user) => {
useUsersStore().updateUserAdminData(user.id, user)
})
},
async setUsersConfirmationStatus({ users }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersConfirmationStatus
await api({
credentials: useOAuthStore().token,
screen_names,
})
users.forEach((user) => {
this.getUserData({ user })
})
},
async setUsersApprovalStatus({ users }) {
const screen_names = users.map((u) => u.screen_name)
const api = setUsersApprovalStatus
const resultUsers = await api({
credentials: useOAuthStore().token,
screen_names,
})
resultUsers.data.forEach((user) => {
useUsersStore().updateUserAdminData(user.id, user)
})
},
reloadEmoji() {
return reloadEmoji({ credentials: useOAuthStore().token }).then(
({ data }) => data,
)
},
importEmojiFromFS() {
return importEmojiFromFS({ credentials: useOAuthStore().token }).then(
({ data }) => data,
)
},
listEmojiPacks(params) {
return listEmojiPacks({
...params,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
listRemoteEmojiPacks(params) {
return listRemoteEmojiPacks({
...params,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
addNewEmojiFile({ packName, file, shortcode, filename }) {
return addNewEmojiFile({
packName,
file,
shortcode,
filename,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
downloadRemoteEmojiPack({ instance, packName, as }) {
return downloadRemoteEmojiPack({
instance,
packName,
as,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
downloadRemoteEmojiPackZIP({ url, packName }) {
return downloadRemoteEmojiPackZIP({
url,
packName,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
createEmojiPack({ name }) {
return createEmojiPack({
name,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
deleteEmojiPack({ name }) {
return deleteEmojiPack({
name,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
saveEmojiPackMetadata({ name, newData }) {
return createEmojiPack({
name,
newData,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
},
})
diff --git a/src/stores/emoji.js b/src/stores/emoji.js
index b893198d78..bdcd145041 100644
--- a/src/stores/emoji.js
+++ b/src/stores/emoji.js
@@ -1,318 +1,319 @@
import { merge } from 'lodash-es'
import { defineStore } from 'pinia'
import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useUsersStore } from 'src/stores/users.js'
+import { apiUrl } from 'src/api/api_base.js'
import { listEmojiPacks } from 'src/api/public.js'
import { ensureFinalFallback } from 'src/i18n/languages.js'
import { annotationsLoader } from 'virtual:pleroma-fe/emoji-annotations'
const defaultState = {
// Custom emoji from server
customEmoji: [],
customEmojiFetched: false,
adminPacksLocal: null,
adminPacksLocalLoading: true,
// Unicode emoji from bundle
emoji: {},
emojiFetched: false,
unicodeEmojiAnnotations: {},
// Stickers
stickers: null,
}
const SORTED_EMOJI_GROUP_IDS = [
'smileys-and-emotion',
'people-and-body',
'animals-and-nature',
'food-and-drink',
'travel-and-places',
'activities',
'objects',
'symbols',
'flags',
]
const REGIONAL_INDICATORS = (() => {
const start = 0x1f1e6
const end = 0x1f1ff
const A = 'A'.codePointAt(0)
const res = new Array(end - start + 1)
for (let i = start; i <= end; ++i) {
const letter = String.fromCodePoint(A + i - start)
res[i - start] = {
replacement: String.fromCodePoint(i),
imageUrl: false,
displayText: 'regional_indicator_' + letter,
displayTextI18n: {
key: 'emoji.regional_indicator',
args: { letter },
},
}
}
return res
})()
const loadAnnotations = (lang) => {
return annotationsLoader[lang]().then((k) => k.default)
}
const injectAnnotations = (emoji, annotations) => {
const availableLangs = Object.keys(annotations)
return {
...emoji,
annotations: availableLangs.reduce((acc, cur) => {
acc[cur] = annotations[cur][emoji.replacement]
return acc
}, {}),
}
}
const injectRegionalIndicators = (groups) => {
groups.symbols.push(...REGIONAL_INDICATORS)
return groups
}
export const useEmojiStore = defineStore('emoji', {
state: () => ({ ...defaultState }),
getters: {
groupedCustomEmojis(state) {
const packsOf = (emoji) => {
const packs = emoji.tags
.filter((k) => k.startsWith('pack:'))
.map((k) => {
const packName = k.slice(5) // remove 'pack:' prefix
return {
id: `custom-${packName}`,
text: packName,
}
})
if (!packs.length) {
return [
{
id: 'unpacked',
},
]
} else {
return packs
}
}
return this.customEmoji.reduce((res, emoji) => {
packsOf(emoji).forEach(({ id: packId, text: packName }) => {
if (!res[packId]) {
res[packId] = {
id: packId,
text: packName,
image: emoji.imageUrl,
emojis: [],
}
}
res[packId].emojis.push(emoji)
})
return res
}, {})
},
standardEmojiList(state) {
return SORTED_EMOJI_GROUP_IDS.map((groupId) =>
(this.emoji[groupId] || []).map((k) =>
injectAnnotations(k, this.unicodeEmojiAnnotations),
),
).flat()
},
standardEmojiGroupList(state) {
return SORTED_EMOJI_GROUP_IDS.map((groupId) => ({
id: groupId,
emojis: (this.emoji[groupId] || []).map((k) =>
injectAnnotations(k, this.unicodeEmojiAnnotations),
),
}))
},
},
actions: {
setStickers(stickers) {
this.stickers = stickers
},
async getStaticEmoji() {
try {
// See build/emojis_plugin for more details
const values = (await import('src/assets/emoji.json')).default
const emoji = Object.keys(values).reduce((res, groupId) => {
res[groupId] = values[groupId].map((e) => ({
displayText: e.slug,
imageUrl: false,
replacement: e.emoji,
}))
return res
}, {})
this.emoji = injectRegionalIndicators(emoji)
} catch (e) {
console.warn("Can't load static emoji\n", e)
}
},
loadUnicodeEmojiData(language) {
const langList = ensureFinalFallback(language)
return Promise.all(
langList.map(async (lang) => {
if (!this.unicodeEmojiAnnotations[lang]) {
try {
const annotations = await loadAnnotations(lang)
this.unicodeEmojiAnnotations[lang] = annotations
} catch (e) {
console.warn(
`Error loading unicode emoji annotations for ${lang}: `,
e,
)
// ignore
}
}
}),
)
},
async getAdminPacksLocal(refresh) {
if (!refresh && this.adminPacksLocal) return this.adminPacksLocal
this.adminPacksLocalLoading = true
this.adminPacksLocal = await this.getAdminPacks(
useInstanceStore().server,
(params) =>
listEmojiPacks({
...params,
credentials: useOAuthStore().token,
}).then(({ data }) => data),
)
this.adminPacksLocalLoading = false
},
async getAdminPacks(instance, listFunction) {
const currentUser = useUsersStore().currentUser
if (!currentUser.rights.admin) return
const pageSize = 25
return await listFunction({
instance,
page: 1,
pageSize: 0,
})
.then((data) => {
const promises = []
for (let i = 0; i < Math.ceil(data.count / pageSize); i++) {
promises.push(
listFunction({
instance,
page: i,
pageSize,
}).then((pageData) => {
return pageData.packs
}),
)
}
return Promise.all(promises).then((results) => {
return merge({}, ...results)
})
})
.then((allPacks) => {
// Sort by key
return Object.keys(allPacks)
.sort((a, b) => a.localeCompare(b))
.reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = allPacks[key]
return acc
}, {})
})
.catch((data) => {
console.error(data)
})
},
async getCustomEmoji() {
try {
- let res = await window.fetch('/api/v1/pleroma/emoji')
+ let res = await window.fetch(apiUrl('/api/v1/pleroma/emoji'))
if (!res.ok) {
- res = await window.fetch('/api/pleroma/emoji.json')
+ res = await window.fetch(apiUrl('/api/pleroma/emoji.json'))
}
if (res.ok) {
const result = await res.json()
const values = Array.isArray(result)
? Object.assign({}, ...result)
: result
const caseInsensitiveStrCmp = (a, b) => {
const la = a.toLowerCase()
const lb = b.toLowerCase()
return la > lb ? 1 : la < lb ? -1 : 0
}
const noPackLast = (a, b) => {
const aNull = a === ''
const bNull = b === ''
if (aNull === bNull) {
return 0
} else if (aNull && !bNull) {
return 1
} else {
return -1
}
}
const byPackThenByName = (a, b) => {
const packOf = (emoji) =>
(emoji.tags.filter((k) => k.startsWith('pack:'))[0] || '').slice(
5,
)
const packOfA = packOf(a)
const packOfB = packOf(b)
return (
noPackLast(packOfA, packOfB) ||
caseInsensitiveStrCmp(packOfA, packOfB) ||
caseInsensitiveStrCmp(a.displayText, b.displayText)
)
}
const emoji = Object.entries(values)
.map(([key, value]) => {
const imageUrl = value.image_url
return {
displayText: key,
imageUrl: imageUrl
? useInstanceStore().server + imageUrl
: value,
tags: imageUrl
? value.tags.sort((a, b) => (a > b ? 1 : 0))
: ['utf'],
replacement: `:${key}: `,
}
// Technically could use tags but those are kinda useless right now,
// should have been "pack" field, that would be more useful
})
.sort(byPackThenByName)
this.customEmoji = emoji
} else {
throw res
}
} catch (e) {
console.warn("Can't load custom emojis\n", e)
}
},
fetchEmoji() {
if (!this.customEmojiFetched) {
this.getCustomEmoji().then(() => (this.customEmojiFetched = true))
}
if (!this.emojiFetched) {
this.getStaticEmoji().then(() => (this.emojiFetched = true))
}
},
},
})
diff --git a/src/stores/instance.js b/src/stores/instance.js
index 231140ca5f..7693e345f0 100644
--- a/src/stores/instance.js
+++ b/src/stores/instance.js
@@ -1,236 +1,241 @@
import { set } from 'lodash-es'
import { defineStore } from 'pinia'
import {
convertDefinitions,
INSTANCE_DEFAULT_CONFIG,
INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
INSTANCE_IDENTITY_DEFAULT,
INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
LOCAL_DEFAULT_CONFIG,
LOCAL_DEFAULT_CONFIG_DEFINITIONS,
validateSetting,
} from '../modules/default_config_state.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { fetchKnownDomains } from 'src/api/public.js'
const REMOTE_INTERACTION_URL = '/main/ostatus'
const ROOT_STATE_DEFINITIONS = {
name: {
type: 'string',
default: 'PleromaFE',
},
registrationOpen: {
required: true,
type: 'boolean',
},
server: {
description: 'Server URL',
required: true,
type: 'string',
},
+ hosted: {
+ description: 'Served from its own site, not by the instance',
+ type: 'boolean',
+ default: false,
+ },
privateMode: {
description: 'Private instance?',
required: true,
type: 'boolean',
},
federating: {
description: 'Is federation enabled?',
required: true,
type: 'boolean',
},
federationPolicy: {
description: '????',
required: true,
type: 'object',
},
// Indexes of themes/styles/palettes
themesIndex: {
required: true,
type: 'object',
},
stylesIndex: {
required: true,
type: 'object',
},
palettesIndex: {
required: true,
type: 'object',
},
themeData: {
description: 'Used for theme v2 editor',
required: true,
type: 'object',
},
vapidPublicKey: {
description: 'Used for WebPush',
required: true,
type: 'string',
},
// Stuff from static/config.json
loginMethod: {
description: 'Login method (token/password)',
default: 'password',
},
disableUpdateNotification: {
description: 'Disable update notification (pleroma-tan one)',
default: false,
},
knownDomains: {
description: 'List of known domains; used for domain list for domain mutes',
default: [],
},
// Moderation stuff
staffAccounts: {
description: 'List of staff accounts',
default: [],
},
accountActivationRequired: {
description:
'Account activation (by email or admin) required after registration',
required: true,
type: 'boolean',
},
accountApprovalRequired: {
description: 'Admin approval required after registration',
required: true,
type: 'boolean',
},
birthdayRequired: {
description: 'Require birthday entry when registering',
required: true,
type: 'boolean',
},
birthdayMinAge: {
description: 'Minimum age required for registration',
default: 0,
},
restrictedNicknames: {
description: 'List of nicknames that are not allowed',
default: [],
},
localBubbleInstances: {
description: "Akkoma's bubble feature, list of instances",
default: [],
}, // Akkoma
// Version Information
backendVersion: {
required: true,
type: 'string',
},
backendRepository: {
required: true,
type: 'string',
},
frontendVersion: {
required: true,
type: 'string',
},
}
const ROOT_STATE = convertDefinitions(ROOT_STATE_DEFINITIONS)
const DEFAULT_STATE = {
...ROOT_STATE,
// Instance-wide configurations that should not be changed by individual users
instanceIdentity: {
...INSTANCE_IDENTITY_DEFAULT,
},
limits: {
bannerlimit: null,
avatarlimit: null,
backgroundlimit: null,
uploadlimit: null,
fieldsLimits: null,
textLimit: null,
pollLimits: {
max_options: 4,
max_option_chars: 255,
min_expiration: 60,
max_expiration: 60 * 60 * 24,
},
},
// Instance admins can override default settings for the whole instance
prefsStorage: {
...INSTANCE_DEFAULT_CONFIG,
...LOCAL_DEFAULT_CONFIG,
},
}
export const useInstanceStore = defineStore('instance', {
state: () => ({ ...DEFAULT_STATE }),
getters: {
instanceDomain(state) {
return new URL(this.server).hostname
},
},
actions: {
set({ path, value }) {
let definition
const pathArray = path.split('.')
const subpath = pathArray[1] ?? pathArray[0]
if (path.startsWith('instanceIdentity.')) {
definition = INSTANCE_IDENTITY_DEFAULT_DEFINITIONS[subpath]
} else if (path.startsWith('prefsStorage.')) {
definition = {
...LOCAL_DEFAULT_CONFIG_DEFINITIONS,
...INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
}[subpath]
} else if (path.startsWith('limits.')) {
definition =
path === 'limits.pollLimits' || path === 'limits.fieldsLimits'
? { required: true, type: 'object' }
: { required: true, type: 'number' }
} else {
const definitions = ROOT_STATE_DEFINITIONS
definition = definitions[subpath]
}
const finalValue = validateSetting({
path,
value,
definition,
throwError: true,
defaultState: DEFAULT_STATE,
validateObjects: false,
})
set(this, path, finalValue)
if (path === 'name') useInterfaceStore().setPageTitle()
},
async getKnownDomains() {
try {
const { data } = await fetchKnownDomains({
credentials: useOAuthStore().token,
})
this.knownDomains = data
} catch (e) {
console.warn("Can't load known domains\n", e)
}
},
getRemoteInteractionLink({ statusId, nickname }) {
const server = this.server.endsWith('/')
? this.server.slice(0, -1)
: this.server
const link = server + REMOTE_INTERACTION_URL
if (statusId) {
return `${link}?status_id=${statusId}`
} else {
return `${link}?nickname=${nickname}`
}
},
},
})
diff --git a/src/stores/interface.js b/src/stores/interface.js
index f2b2d86c7f..a1f16ffd2a 100644
--- a/src/stores/interface.js
+++ b/src/stores/interface.js
@@ -1,895 +1,901 @@
import { defineStore } from 'pinia'
import {
applyTheme,
getResourcesIndex,
tryLoadCache,
} from '../services/style_setter/style_setter.js'
import { deserialize } from '../services/theme_data/iss_deserializer.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import {
registerPushNotifications,
unregisterPushNotifications,
} from 'src/services/sw/sw.js'
import {
CURRENT_VERSION,
generatePreset,
} from 'src/services/theme_data/theme_data.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
const getNotificationPermission = async () => {
const Notification = window.Notification
if (!Notification) return null
if (Notification.permission === 'default')
return Notification.requestPermission()
return Notification.permission
}
const GENERIC_FONT_NAMES = new Set([
'serif',
'sans-serif',
'system-ui',
'cursive',
'fantasy',
'math',
'monospace',
])
export const useInterfaceStore = defineStore('interface', {
state: () => ({
localFonts: null,
themeApplied: false,
themeChangeInProgress: false,
themeVersion: 'v3',
styleNameUsed: null,
styleDataUsed: null,
useStylePalette: false, // hack for applying styles from appearance tab
paletteNameUsed: null,
paletteDataUsed: null,
themeNameUsed: null,
themeDataUsed: null,
temporaryChangesTimeoutId: null,
temporaryChangesCountdown: -1, // used for temporary options that revert after a timeout
temporaryChangesConfirm: () => {
/* no-op */
}, // used for applying temporary options
temporaryChangesRevert: () => {
/* no-op */
}, // used for reverting temporary options
settingsModalState: 'hidden',
settingsModalLoadedUser: false,
settingsModalLoadedAdmin: false,
settingsModalTargetTab: null,
settingsModalMode: 'user',
settings: {
currentSaveStateNotice: null,
noticeClearTimeout: null,
notificationPermission: null,
},
browserSupport: {
cssFilter:
window.CSS?.supports &&
(window.CSS.supports('filter', 'drop-shadow(0 0)') ||
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')),
localFonts: typeof window.queryLocalFonts === 'function',
},
layoutType: 'normal',
globalNotices: [],
globalError: null,
layoutHeight: 0,
lastTimeline: null,
foreignProfileBackground: null,
}),
actions: {
attachSocket() {
const et = new EventTarget()
const socket = {
name: 'interface',
et,
}
et.addEventListener('open', this.onStreamConnect)
et.addEventListener('close', this.onStreamDisconnect)
useStreamingStore().addSubscriber(socket)
},
onLogin() {
getNotificationPermission().then((permission) =>
useInterfaceStore().setNotificationPermission(permission),
)
this.setLayoutWidth(windowWidth())
this.setLayoutHeight(windowHeight())
},
onLogout() {
this.setLastTimeline('public-timeline')
this.setLayoutWidth(windowWidth())
this.setLayoutHeight(windowHeight())
},
onStreamConnect() {
if (useStreamingStore().state !== WSConnectionStatus.STARTING_INITIAL) {
this.pushGlobalNotice({
level: 'success',
messageKey: 'timeline.socket_reconnected',
timeout: 5000,
})
}
},
onStreamDisconnect(closeEvent) {
+ // hosted and never connected: nothing broke (an instance that refuses
+ // sockets from this site is polled instead)
+ if (useInstanceStore().hosted && !useStreamingStore().everOpened) return
const intendedCodes = new Set([
1000, // Normal (intended) closure
1001, // Going away
])
const { code } = closeEvent.original
if (!intendedCodes.has(code)) {
this.pushGlobalNotice({
level: 'error',
messageKey: 'timeline.socket_broke',
messageArgs: [code],
timeout: 5000,
})
}
},
setTemporaryChanges({ confirm, revert }) {
this.temporaryChangesCountdown = 10
this.temporaryChangesConfirm = confirm
this.temporaryChangesRevert = revert
const countdownFunc = () => {
if (this.temporaryChangesCountdown <= 1) {
this.temporaryChangesRevert()
this.clearTemporaryChanges()
} else {
this.temporaryChangesCountdown--
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
}
}
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
},
clearTemporaryChanges() {
this.temporaryChangesTimeoutId ??
clearTimeout(this.temporaryChangesTimeoutId)
this.temporaryChangesTimeoutId = null
this.temporaryChangesCountdown = -1
this.temporaryChangesConfirm = () => {
/* no-op */
}
this.temporaryChangesRevert = () => {
/* no-op */
}
},
setPageTitle(option = '') {
try {
document.title = `${option} ${useInstanceStore().instanceIdentity.name}`
} catch (error) {
console.error(`${error}`)
}
},
setForeignProfileBackground(url) {
this.foreignProfileBackground = url
},
settingsSaved({ success, error }) {
if (success) {
if (this.noticeClearTimeout) {
clearTimeout(this.noticeClearTimeout)
}
this.settings.currentSaveStateNotice = { error: false, data: success }
this.settings.noticeClearTimeout = setTimeout(
() => delete this.settings.currentSaveStateNotice,
2000,
)
} else {
this.settings.currentSaveStateNotice = { error: true, errorData: error }
}
},
setNotificationPermission(permission) {
this.notificationPermission = permission
},
closeSettingsModal() {
this.settingsModalState = 'hidden'
},
openSettingsModal(value) {
this.settingsModalMode = value
this.settingsModalState = 'visible'
if (value === 'user') {
if (!this.settingsModalLoadedUser) {
this.settingsModalLoadedUser = true
}
} else if (value === 'admin') {
if (!this.settingsModalLoadedAdmin) {
this.settingsModalLoadedAdmin = true
}
}
},
setSettingsModalState(newState) {
const oldState = this.settingsModalState
const legal = (() => {
switch (oldState) {
case 'minimized':
return true
case 'visible':
return true
case 'hidden':
return newState === 'visible'
}
})()
if (legal) {
this.settingsModalState = newState
}
},
toggleMinimizeSettingsModal() {
switch (this.settingsModalState) {
case 'minimized':
this.settingsModalState = 'visible'
return
case 'visible':
this.settingsModalState = 'minimized'
return
case 'hidden':
return
default:
throw new Error(
`Illegal minimization state of settings modal: ${this.settingsModalState}`,
)
}
},
clearSettingsModalTargetTab() {
this.settingsModalTargetTab = null
},
openSettingsModalTab(value, mode = 'user') {
this.settingsModalTargetTab = value
this.openSettingsModal(mode)
},
removeGlobalNotice(notice) {
this.globalNotices = this.globalNotices.filter((n) => n !== notice)
},
setGlobalError({ error, instance, info }) {
switch (info) {
case 'https://vuejs.org/error-reference/#runtime-13': {
this.globalError = {
title: 'general.refresh_required',
content: 'general.refresh_required_content',
// `true` disables cache on Firefox (non-standard)
recover: () => window.location.reload(true),
recoverText: 'general.refresh_required_refresh',
error,
}
break
}
default: {
this.globalError = { error }
break
}
}
},
clearGlobalError() {
this.globalError = null
},
pushGlobalNotice({
messageKey,
messageArgs = {},
level = 'error',
timeout = 5000,
}) {
const notice = {
messageKey,
messageArgs,
level,
}
this.globalNotices.push(notice)
// Adding a new element to array wraps it in a Proxy, which breaks the comparison
// TODO: Generate UUID or something instead or relying on !== operator?
const newNotice = this.globalNotices[this.globalNotices.length - 1]
if (timeout > 0) {
setTimeout(() => this.removeGlobalNotice(newNotice), timeout)
}
return newNotice
},
setLayoutHeight(value) {
this.layoutHeight = value
},
setLayoutWidth(value) {
let width = value
if (value !== undefined) {
this.layoutWidth = value
} else {
width = this.layoutWidth
}
const mobileLayout = width <= 800
const normalOrMobile = mobileLayout ? 'mobile' : 'normal'
const { thirdColumnMode } = useMergedConfigStore().mergedConfig
if (thirdColumnMode === 'none' || !useUsersStore().currentUser) {
this.layoutType = normalOrMobile
} else {
const wideLayout = width >= 1300
this.layoutType = wideLayout ? 'wide' : normalOrMobile
}
},
setFontsList(value) {
this.localFonts = [...new Set(value.map(({ family }) => family)).values()]
},
queryLocalFonts() {
if (this.localFonts !== null) return
this.setFontsList([])
if (!this.browserSupport.localFonts) {
return
}
window
.queryLocalFonts()
.then((fonts) => {
this.setFontsList(fonts)
})
.catch((e) => {
this.pushGlobalNotice({
messageKey: 'settings.style.themes3.font.font_list_unavailable',
messageArgs: {
error: e,
},
level: 'error',
})
})
},
setLastTimeline(value) {
this.lastTimeline = value
},
async fetchPalettesIndex() {
try {
const value = await getResourcesIndex('/static/palettes/index.json')
useInstanceStore().set({
path: 'palettesIndex',
value,
})
return value
} catch (e) {
console.error('Could not fetch palettes index', e)
useInstanceStore().set({
path: 'palettesIndex',
value: { _error: e },
})
return {}
}
},
setPalette(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.palette', value })
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setPaletteCustom(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({
path: 'simple.paletteCustomData',
value,
})
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
async fetchStylesIndex() {
try {
const value = await getResourcesIndex(
'/static/styles/index.json',
deserialize,
)
useInstanceStore().set({ path: 'stylesIndex', value })
return value
} catch (e) {
console.error('Could not fetch styles index', e)
useInstanceStore().set({
path: 'simple.stylesIndex',
value: { _error: e },
})
return {}
}
},
setStyle(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
useSyncConfigStore().setPreference({ path: 'simple.style', value })
useSyncConfigStore().pushSyncConfig()
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
setStyleCustom(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
useSyncConfigStore().setPreference({
path: 'simple.styleCustomData',
value,
})
useSyncConfigStore().pushSyncConfig()
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
async fetchThemesIndex() {
try {
const value = await getResourcesIndex('/static/styles.json')
useInstanceStore().set({ path: 'themesIndex', value })
return value
} catch (e) {
console.error('Could not fetch themes index', e)
useInstanceStore().set({
path: 'themesIndex',
value: { _error: e },
})
return {}
}
},
setTheme(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.theme', value })
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setThemeCustom(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.customTheme', value })
useSyncConfigStore().setPreference({
path: 'simple.customThemeSource',
value,
})
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
resetThemeV3() {
useSyncConfigStore().setPreference({ path: 'simple.style', value: null })
useSyncConfigStore().setPreference({
path: 'simple.styleCustomData',
value: null,
})
},
resetThemeV3Palette() {
useSyncConfigStore().setPreference({
path: 'simple.palette',
value: null,
})
useSyncConfigStore().setPreference({
path: 'simple.paletteCustomData',
value: null,
})
},
resetThemeV2() {
useSyncConfigStore().setPreference({ path: 'simple.theme', value: null })
useSyncConfigStore().setPreference({
path: 'simple.customTheme',
value: null,
})
useSyncConfigStore().setPreference({
path: 'simple.customThemeSource',
value: null,
})
},
async getThemeData() {
const getData = async (resource, index, customData, name) => {
const capitalizedResource =
resource[0].toUpperCase() + resource.slice(1)
const result = {}
if (customData) {
result.nameUsed = 'custom' // custom data overrides name
result.dataUsed = customData
} else {
result.nameUsed = name
if (result.nameUsed == null) {
result.dataUsed = null
return result
}
let fetchFunc = index[result.nameUsed]
// Fallbacks
if (!fetchFunc) {
if (resource === 'style' || resource === 'palette') {
return result
}
const newName = Object.keys(index)[0]
fetchFunc = index[newName]
console.warn(
`${capitalizedResource} with id '${this.styleNameUsed}' not found, trying back to '${newName}'`,
)
if (!fetchFunc) {
console.warn(
`${capitalizedResource} doesn't have a fallback, defaulting to stock.`,
)
fetchFunc = () => Promise.resolve(null)
}
}
result.dataUsed = await fetchFunc()
}
return result
}
let {
theme: instanceThemeName,
style: instanceStyleName,
palette: instancePaletteName,
} = useInstanceStore().instanceIdentity
let { themesIndex, stylesIndex, palettesIndex } = useInstanceStore()
const {
style: userStyleName,
styleCustomData: userStyleCustomData,
palette: userPaletteName,
paletteCustomData: userPaletteCustomData,
} = useMergedConfigStore().mergedConfig
let {
theme: userThemeV2Name,
customTheme: userThemeV2Snapshot,
customThemeSource: userThemeV2Source,
} = useMergedConfigStore().mergedConfig
let majorVersionUsed
console.debug(
`User V3 palette: ${userPaletteName}, style: ${userStyleName} , custom: ${!!userStyleCustomData}`,
)
console.debug(
`User V2 name: ${userThemeV2Name}, source: ${!!userThemeV2Source}, snapshot: ${!!userThemeV2Snapshot}`,
)
console.debug(
`Instance V3 palette: ${instancePaletteName}, style: ${instanceStyleName}`,
)
console.debug('Instance V2 theme: ' + instanceThemeName)
if (
userPaletteName ||
userPaletteCustomData ||
userStyleName ||
userStyleCustomData ||
// User V2 overrides instance V3
((instancePaletteName || instanceStyleName) &&
instanceThemeName == null &&
userThemeV2Name == null)
) {
// Palette and/or style overrides V2 themes
instanceThemeName = null
userThemeV2Name = null
userThemeV2Source = null
userThemeV2Snapshot = null
majorVersionUsed = 'v3'
} else if (
userThemeV2Name ||
userThemeV2Snapshot ||
userThemeV2Source ||
instanceThemeName
) {
majorVersionUsed = 'v2'
} else {
// if all fails fallback to v3
majorVersionUsed = 'v3'
}
if (majorVersionUsed === 'v3') {
const result = await Promise.all([
this.fetchPalettesIndex(),
this.fetchStylesIndex(),
])
palettesIndex = result[0]
stylesIndex = result[1]
} else {
// Promise.all just to be uniform with v3
const result = await Promise.all([this.fetchThemesIndex()])
themesIndex = result[0]
}
this.themeVersion = majorVersionUsed
console.debug('Version used', majorVersionUsed)
if (majorVersionUsed === 'v3') {
this.themeDataUsed = null
this.themeNameUsed = null
const style = await getData(
'style',
stylesIndex,
userStyleCustomData,
userStyleName || instanceStyleName,
)
this.styleNameUsed = style.nameUsed
this.styleDataUsed = style.dataUsed
let firstStylePaletteName = null
style.dataUsed
?.filter((x) => x.component === '@palette')
.map((x) => {
const cleanDirectives = Object.fromEntries(
Object.entries(x.directives).filter(([k]) => k),
)
return { name: x.variant, ...cleanDirectives }
})
.forEach((palette) => {
const key =
'style.' + palette.name.toLowerCase().replaceAll(' ', '_')
if (!firstStylePaletteName) firstStylePaletteName = key
palettesIndex[key] = () => Promise.resolve(palette)
})
const palette = await getData(
'palette',
palettesIndex,
userPaletteCustomData,
this.useStylePalette
? firstStylePaletteName
: userPaletteName || instancePaletteName,
)
if (this.useStylePalette) {
useSyncConfigStore().setPreference({
path: 'simple.palette',
value: firstStylePaletteName,
})
useSyncConfigStore().pushSyncConfig()
}
this.paletteNameUsed = palette.nameUsed
this.paletteDataUsed = palette.dataUsed
if (this.paletteDataUsed) {
this.paletteDataUsed.link =
this.paletteDataUsed.link || this.paletteDataUsed.accent
this.paletteDataUsed.accent =
this.paletteDataUsed.accent || this.paletteDataUsed.link
}
if (Array.isArray(this.paletteDataUsed)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = palette.dataUsed
this.paletteDataUsed = {
name,
bg,
fg,
text,
link,
accent: link,
cRed,
cBlue,
cGreen,
cOrange,
}
}
console.debug('Palette data used', palette.dataUsed)
} else {
this.styleNameUsed = null
this.styleDataUsed = null
this.paletteNameUsed = null
this.paletteDataUsed = null
const theme = await getData(
'theme',
themesIndex,
userThemeV2Source || userThemeV2Snapshot,
userThemeV2Name || instanceThemeName,
)
this.themeNameUsed = theme.nameUsed
this.themeDataUsed = theme.dataUsed
}
},
async setThemeApplied() {
this.themeApplied = true
},
async applyTheme({ recompile = false } = {}) {
const { mergedConfig } = useMergedConfigStore()
const { forceThemeRecompilation, themeDebug } = mergedConfig
this.themeChangeInProgress = true
// If we're not forced to recompile try using
// cache (tryLoadCache return true if load successful)
const forceRecompile = forceThemeRecompilation || recompile
await this.getThemeData()
if (!forceRecompile && !themeDebug && (await tryLoadCache())) {
this.themeChangeInProgress = false
return this.setThemeApplied()
}
window.splashUpdate('splash.theme')
try {
const paletteIss = (() => {
if (!this.paletteDataUsed) return null
const result = {
component: 'Root',
directives: {},
}
Object.entries(this.paletteDataUsed)
.filter(([k]) => k !== 'name')
.forEach(([k, v]) => {
let issRootDirectiveName
switch (k) {
case 'background':
issRootDirectiveName = 'bg'
break
case 'foreground':
issRootDirectiveName = 'fg'
break
default:
issRootDirectiveName = k
}
result.directives['--' + issRootDirectiveName] = 'color | ' + v
})
return result
})()
const theme2ruleset =
this.themeDataUsed &&
convertTheme2To3(normalizeThemeData(this.themeDataUsed))
const hacks = []
const fontMap = {
Interface: 'Root',
Input: 'Input',
Posts: 'Post',
Monospace: 'Root',
}
Object.entries(fontMap).forEach(([font, component]) => {
const family = mergedConfig[`font${font}`]
const variable = font === 'Monospace' ? '--monoFont' : '--font'
if (typeof family === 'string') {
const familyString = GENERIC_FONT_NAMES.has(family)
? family
: `"${family}"`
hacks.push({
component,
directives: {
[variable]: `generic | ${familyString}`,
},
})
}
})
if (mergedConfig.underlay !== 'none') {
const newRule = {
component: 'Underlay',
directives: {},
}
if (mergedConfig.underlay === 'opaque') {
newRule.directives.opacity = 1
newRule.directives.background = '--wallpaper'
}
if (mergedConfig.underlay === 'transparent') {
newRule.directives.opacity = 0
}
hacks.push(newRule)
}
const rulesetArray = [
theme2ruleset,
this.styleDataUsed,
paletteIss,
hacks,
].filter(Boolean)
return applyTheme(
rulesetArray.flat(),
() => this.setThemeApplied(),
() => {
this.themeChangeInProgress = false
},
themeDebug,
)
} catch (e) {
console.error(e)
window.splashError(e)
}
},
// Push notifications
registerPushNotifications() {
+ // hosted: the service worker would ask this site, not the instance,
+ // for the pushed notification
+ if (useInstanceStore().hosted) return
const token = useOAuthStore().token
const vapidPublicKey = useInstanceStore().vapidPublicKey
const isEnabled = useMergedConfigStore().mergedConfig.webPushNotifications
const notificationVisibility =
useMergedConfigStore().mergedConfig.notificationVisibility
registerPushNotifications(
isEnabled,
vapidPublicKey,
token,
notificationVisibility,
)
},
unregisterPushNotifications() {
const token = useOAuthStore().token
unregisterPushNotifications(token)
},
},
})
export const normalizeThemeData = (input) => {
let themeData, themeSource
if (input.themeFileVerison === 1) {
// this might not be even used at all, some leftover of unimplemented code in V2 editor
return generatePreset(input).theme
} else if (
Object.hasOwn(input, '_pleroma_theme_version') ||
Object.hasOwn(input, 'source') ||
Object.hasOwn(input, 'theme')
) {
// We got passed a full theme file
themeData = input.theme
themeSource = input.source
} else if (
Object.hasOwn(input, 'themeEngineVersion') ||
Object.hasOwn(input, 'colors')
) {
// We got passed a source/snapshot
themeData = input
themeSource = input
}
// New theme presets don't have 'theme' property, they use 'source'
let out // shout, shout let it all out
if (themeSource?.themeEngineVersion === CURRENT_VERSION) {
// There are some themes in wild that have completely broken source
out = { ...(themeData || {}), ...themeSource }
} else {
out = themeData
}
// generatePreset here basically creates/updates "snapshot",
// while also fixing the 2.2 -> 2.3 colors/shadows/etc
return generatePreset(out).theme
}
diff --git a/src/stores/shout.js b/src/stores/shout.js
index 05cd340bee..9d84323626 100644
--- a/src/stores/shout.js
+++ b/src/stores/shout.js
@@ -1,54 +1,63 @@
import { Socket } from 'phoenix'
import { defineStore } from 'pinia'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useUsersStore } from 'src/stores/users.js'
+import { streamingUrl } from 'src/api/api_base.js'
+
// Maybe rename it to PhoenixSocket if we ever utilize this socket more
export const useShoutStore = defineStore('shout', {
state: () => ({
messages: [],
channel: { state: '' },
joined: false,
socket: null,
}),
getters: {
token: () => useUsersStore().currentUser?.token,
},
actions: {
initializeSocket() {
if (this.token === null) return
if (!useInstanceCapabilitiesStore().shoutAvailable) return
+ // the instance's socket refuses other sites (Pleroma's origin check)
+ if (useInstanceStore().hosted) return
if (this.socket !== null) return
- this.socket = new Socket('/socket', { params: { token: this.token } })
+ this.socket = new Socket(streamingUrl('/socket'), {
+ params: { token: this.token },
+ })
this.socket.connect()
},
initializeShout() {
+ if (this.socket === null) return
+
const channel = this.socket.channel('chat:public')
channel.joinPush.receive('ok', () => {
this.joined = true
})
channel.onClose(() => {
this.joined = false
})
channel.onError(() => {
this.joined = false
})
channel.on('new_msg', (msg) => {
this.messages.push(msg)
this.messages = this.messages.slice(-19, 20)
})
channel.on('messages', ({ messages }) => {
this.messages = messages.slice(-19, 20)
})
channel.join()
this.channel = channel
},
disconnectSocket() {
this.socket?.disconnect()
this.socket = null
},
},
})
diff --git a/src/stores/streaming.js b/src/stores/streaming.js
index 554d6a6b5c..7dfe98cd88 100644
--- a/src/stores/streaming.js
+++ b/src/stores/streaming.js
@@ -1,270 +1,292 @@
import { defineStore } from 'pinia'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
getMastodonSocketURI,
ProcessedWS,
WSConnectionStatus,
} from 'src/api/websocket.js'
const ARGUMENT_MAP = {
tag: 'tag',
list: 'list',
}
export const TIMELINE_STREAM_MAP = {
friends: 'user',
public: 'public',
tag: 'hashtag',
list: 'list',
dms: 'direct',
}
const retryTimeout = (multiplier) => 1000 * multiplier
+const REFUSED_AFTER_FAILED_STARTS = 3
export class StreamStateEvent extends Event {
original
constructor(name, original) {
super(name)
this.original = original
}
}
export class StreamErrorEvent extends Event {
error
constructor(error) {
super('error', error)
this.error = error
}
}
export class StreamMessageEvent extends Event {
data
stream
timestamp
constructor(name, stream, data) {
super(name)
this.data = data
this.stream = stream
this.timestamp = Date.now()
}
}
export const useStreamingStore = defineStore('streaming', {
state: () => ({
socket: null,
error: null,
state: null,
retryMultiplier: 1,
retrying: false,
+ // a socket opened at least once this session
+ everOpened: false,
+ // sockets in a row that closed before they opened
+ failedStarts: 0,
subscribers: new Set(),
subscriptions: new Map(),
globalSubscriptions: new Set(),
}),
actions: {
addSubscriber(subscriber) {
const { stream, et } = subscriber
if (stream) {
if (!this.subscriptions.has(stream.name)) {
this.subscriptions.set(stream.name, new Map())
}
const streamSubs = this.subscriptions.get(stream.name)
if (streamSubs.has(stream.argument)) {
throw new Error('Subscription already exists!')
}
streamSubs.set(stream.argument, subscriber)
} else {
this.globalSubscriptions.add(subscriber)
}
this.subscribers.add(subscriber)
if (this.state === WSConnectionStatus.JOINED) {
if (stream) {
this.socket.subscribe(...this.getSubArgs(stream))
}
et.dispatchEvent(new StreamStateEvent('open'))
}
},
removeSubscriber(subscriber) {
const { stream } = subscriber
this.subscribers.delete(subscriber)
if (stream) {
this.subscriptions.get(stream.name).delete(stream.argument)
} else {
this.globalSubscriptions.delete(subscriber)
}
if (stream && this.state === WSConnectionStatus.JOINED) {
this.socket.unsubscribe(...this.getSubArgs(stream))
}
},
initSocket(initial) {
if (this.socket) {
console.error(
"Socket already exists! This shouldn't happen! Force-closing the socket just in case",
)
try {
this.socket.close()
} catch (e) {
console.error(
"Error closing existing socket that shouldn't be there. Welp. Moving on.",
e,
)
}
}
this.state = initial
? WSConnectionStatus.STARTING_INITIAL
: WSConnectionStatus.STARTING
const credentials = useOAuthStore().token
const url = getMastodonSocketURI({ credentials })
this.socket = ProcessedWS({
url,
id: 'Unified',
credentials,
})
this.socket.addEventListener('pleroma:authenticated', this.onAuth)
this.socket.addEventListener('open', this.onOpen)
this.socket.addEventListener('close', this.onClose)
this.socket.addEventListener('message', this.onMessage)
this.socket.addEventListener('error', this.onError)
},
stopSocket() {
if (this.socket == null)
console.error("Socket is already stopped! This shouldn't happen!")
this.socket?.close()
this.socket = null
this.state = WSConnectionStatus.CLOSED
this.retrying = false
this.retryMultiplier = 1
+ this.failedStarts = 0
this.error = null
},
getSubArgs(stream) {
const argumentKey = ARGUMENT_MAP[stream.name]
const args = argumentKey
? {
[argumentKey]: stream.argument,
}
: null
return [stream.name, args]
},
onAuth() {
this.subscribers.forEach(({ stream, et }) => {
et.dispatchEvent(new StreamStateEvent('authenticated'))
if (stream) {
this.socket.subscribe(...this.getSubArgs(stream))
}
})
this.state = WSConnectionStatus.JOINED
},
onOpen() {
+ this.everOpened = true
+ this.failedStarts = 0
this.retryMultiplier = 1
this.retrying = false
this.error = null
this.subscribers.forEach(({ stream, et }) => {
et.dispatchEvent(new StreamStateEvent('open'))
})
},
onMessage({ data: message }) {
if (!message) return // pings
const { event: eventName, stream: eventStream, ...data } = message
const [streamName, streamArgument] = eventStream ?? []
const subscriber = this.subscriptions.get(streamName)?.get(streamArgument)
const totalSubs = [
...this.globalSubscriptions.values(),
subscriber,
].filter(Boolean)
const eventData = (() => {
switch (eventName) {
case 'status.update':
case 'update':
return [data.status]
case 'notification':
return [data.notification]
case 'delete':
return [data.id]
default:
return data
}
})()
const event = new StreamMessageEvent(
eventName,
{ name: streamName, argument: streamArgument },
eventData,
)
totalSubs.forEach(({ stream, et }) => {
et.dispatchEvent(event)
})
},
onError({ data: error }) {
this.subscribers.forEach(({ stream, et }) => {
et.dispatchEvent(new StreamErrorEvent(error))
})
this.error = error
console.error('Error in MastoAPI websocket:', error)
},
onClose({ data: closeEvent }) {
const ignoreCodes = new Set([
1000, // Normal (intended) closure
1001, // Going away
])
const { code } = closeEvent
if (ignoreCodes.has(code)) {
console.debug(
`Not restarting socket becasue of closure code ${code} is in ignore list`,
)
this.state = WSConnectionStatus.CLOSED
this.retrying = false
this.error = null
this.retryMultiplier = 1
this.subscribers.forEach(({ et }) => {
et.dispatchEvent(new StreamStateEvent('close', closeEvent))
})
+ } else if (
+ useInstanceStore().hosted &&
+ !this.everOpened &&
+ ++this.failedStarts >= REFUSED_AFTER_FAILED_STARTS
+ ) {
+ // hosted: the instance refuses sockets from this site (Pleroma's
+ // origin check), polling it is. An instance serving the page keeps
+ // retrying: it may just be restarting.
+ console.info('Streaming not available here, polling instead')
+ this.socket = null
+ this.state = WSConnectionStatus.DISABLED
+ this.retrying = false
+ this.retryMultiplier = 1
} else {
console.warn(
`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`,
)
setTimeout(() => {
if (!this.retrying) return // retry aborted (i.e. due to logout)
this.initSocket()
}, retryTimeout(this.retryMultiplier))
this.retryMultiplier += 1
if (!this.retrying) {
this.subscribers.forEach(({ et }) => {
et.dispatchEvent(new StreamStateEvent('close', closeEvent))
})
}
this.retrying = true
this.state = WSConnectionStatus.ERROR
}
},
},
})
diff --git a/test/unit/specs/api/api_base.spec.js b/test/unit/specs/api/api_base.spec.js
new file mode 100644
index 0000000000..3e5a6ffcd1
--- /dev/null
+++ b/test/unit/specs/api/api_base.spec.js
@@ -0,0 +1,72 @@
+import {
+ apiUrl,
+ serverUrl,
+ setApiBase,
+ streamingUrl,
+} from 'src/api/api_base.js'
+import { promisedRequest } from 'src/api/helpers.js'
+
+describe('API base', () => {
+ afterEach(() => {
+ setApiBase(null)
+ vi.unstubAllGlobals()
+ })
+
+ it('leaves paths alone when the page is the instance', () => {
+ expect(apiUrl('/api/v1/instance')).to.equal('/api/v1/instance')
+ })
+
+ it('puts the chosen instance in front of paths', () => {
+ setApiBase('https://pleroma.example/')
+ expect(apiUrl('/api/v1/instance')).to.equal(
+ 'https://pleroma.example/api/v1/instance',
+ )
+ })
+
+ it('leaves full URLs alone', () => {
+ setApiBase('https://pleroma.example')
+ expect(apiUrl('https://media.example/a.png')).to.equal(
+ 'https://media.example/a.png',
+ )
+ })
+
+ it('builds the streaming URL on the instance', () => {
+ // absolute also on the instance itself: WebSocket takes relative URLs
+ // only in recent browsers (Chrome 125, Firefox 124, Safari 17.3)
+ const ws = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+ expect(streamingUrl('/api/v1/streaming?x=1')).to.equal(
+ `${ws}//${window.location.host}/api/v1/streaming?x=1`,
+ )
+ setApiBase('https://pleroma.example')
+ expect(streamingUrl('/api/v1/streaming?x=1')).to.equal(
+ 'wss://pleroma.example/api/v1/streaming?x=1',
+ )
+ setApiBase('http://localhost:4000')
+ expect(streamingUrl('/socket')).to.equal('ws://localhost:4000/socket')
+ })
+
+ it('sends every API request to the instance', async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValue(
+ new Response('{}', { headers: { 'Content-Type': 'application/json' } }),
+ )
+ vi.stubGlobal('fetch', fetch)
+ setApiBase('https://pleroma.example')
+ await promisedRequest({ url: '/api/v1/timelines/home' })
+ expect(fetch.mock.calls[0][0]).to.equal(
+ 'https://pleroma.example/api/v1/timelines/home',
+ )
+ })
+})
+
+describe('instance asset URLs', () => {
+ it('puts the server in front of a path, leaves full URLs alone', () => {
+ expect(serverUrl('https://pleroma.example', '/images/avi.png')).to.equal(
+ 'https://pleroma.example/images/avi.png',
+ )
+ expect(
+ serverUrl('https://pleroma.example', 'https://cdn.example/a.png'),
+ ).to.equal('https://cdn.example/a.png')
+ })
+})
diff --git a/test/unit/specs/components/instance_picker.spec.js b/test/unit/specs/components/instance_picker.spec.js
new file mode 100644
index 0000000000..7575d4efa6
--- /dev/null
+++ b/test/unit/specs/components/instance_picker.spec.js
@@ -0,0 +1,54 @@
+import { createTestingPinia } from '@pinia/testing'
+import { flushPromises, mount } from '@vue/test-utils'
+import { setActivePinia } from 'pinia'
+import { $t, mountOpts } from 'test/fixtures/setup_test.js'
+
+import InstancePicker from 'src/components/instance_picker/instance_picker.vue'
+
+import { chosenInstance, forgetInstance } from 'src/services/hosted/hosted.js'
+
+const factory = (check) => {
+ const done = vi.fn()
+ const wrapper = mount(
+ InstancePicker,
+ mountOpts({ props: { check, onChosen: done } }),
+ )
+ return { wrapper, done }
+}
+
+describe('InstancePicker', () => {
+ beforeEach(() => setActivePinia(createTestingPinia()))
+ afterEach(() => forgetInstance())
+
+ it('remembers an instance that answers and carries on', async () => {
+ const check = vi.fn().mockResolvedValue(true)
+ const { wrapper, done } = factory(check)
+ await wrapper.find('input').setValue('@me@Pleroma.Example')
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
+ expect(check).toHaveBeenCalledWith('https://pleroma.example')
+ expect(chosenInstance()).to.equal('https://pleroma.example')
+ expect(done).toHaveBeenCalled()
+ })
+
+ it('says so when the instance cannot be reached from here', async () => {
+ const check = vi.fn().mockResolvedValue(false)
+ const { wrapper, done } = factory(check)
+ await wrapper.find('input').setValue('gone.example')
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
+ expect(wrapper.text()).to.include($t('hosted.unreachable'))
+ expect(chosenInstance()).to.equal(null)
+ expect(done).not.toHaveBeenCalled()
+ })
+
+ it('asks for a domain when the input is not one', async () => {
+ const check = vi.fn()
+ const { wrapper } = factory(check)
+ await wrapper.find('input').setValue('not a domain')
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
+ expect(check).not.toHaveBeenCalled()
+ expect(wrapper.text()).to.include($t('hosted.not_a_domain'))
+ })
+})
diff --git a/test/unit/specs/components/login_form_hosted.spec.js b/test/unit/specs/components/login_form_hosted.spec.js
new file mode 100644
index 0000000000..40e6f6d3b5
--- /dev/null
+++ b/test/unit/specs/components/login_form_hosted.spec.js
@@ -0,0 +1,54 @@
+import { createTestingPinia } from '@pinia/testing'
+import { mount } from '@vue/test-utils'
+import { setActivePinia } from 'pinia'
+import { $t, mountOpts } from 'test/fixtures/setup_test.js'
+
+import LoginForm from 'src/components/login_form/login_form.vue'
+
+import { useInstanceStore } from 'src/stores/instance.js'
+import { useOAuthStore } from 'src/stores/oauth.js'
+
+import {
+ chooseInstance,
+ chosenInstance,
+ forgetInstance,
+} from 'src/services/hosted/hosted.js'
+
+describe('LoginForm on the hosted site', () => {
+ beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
+ afterEach(() => forgetInstance())
+
+ const render = (hosted) => {
+ const instance = useInstanceStore()
+ instance.hosted = hosted
+ instance.server = 'https://pleroma.example'
+ const reloadPage = vi.fn()
+ const wrapper = mount(
+ LoginForm,
+ mountOpts({ global: { provide: { reloadPage } } }),
+ )
+ return { wrapper, reloadPage }
+ }
+
+ it('names the instance and offers to change it', async () => {
+ chooseInstance('https://pleroma.example')
+ const oauth = useOAuthStore()
+ oauth.clientId = 'id'
+ oauth.clientSecret = 'secret'
+ oauth.userToken = 'token-of-the-old-instance'
+ const { wrapper, reloadPage } = render(true)
+ expect(wrapper.text()).to.include('pleroma.example')
+ await wrapper.findByText('button', $t('hosted.change')).trigger('click')
+ expect(chosenInstance()).to.equal(null)
+ // that app was registered with the old instance
+ expect(oauth.clientId).to.not.equal('id')
+ // never sent to the next instance
+ expect(oauth.userToken).to.not.equal('token-of-the-old-instance')
+ expect(reloadPage).toHaveBeenCalled()
+ })
+
+ it('offers no instance change on an instance', () => {
+ const { wrapper } = render(false)
+ expect(wrapper.text()).to.not.include($t('hosted.change'))
+ })
+})
diff --git a/test/unit/specs/composables/useVirtualScrolling.spec.js b/test/unit/specs/composables/useVirtualScrolling.spec.js
index 5d71f4198c..e37f91a881 100644
--- a/test/unit/specs/composables/useVirtualScrolling.spec.js
+++ b/test/unit/specs/composables/useVirtualScrolling.spec.js
@@ -1,325 +1,385 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { ref } from 'vue'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
const scrollPositionInstance = {
x: ref(0),
y: ref(0),
vHeight: ref(768),
vWidth: ref(1024),
cHeight: ref(768),
cWidth: ref(1024),
scrollBy: vi.fn(),
hasReachedTop: ref(false),
hasReachedBottom: ref(false),
}
describe('useVirtualScrolling', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.resetAllMocks()
scrollPositionInstance.x.value = 0
scrollPositionInstance.y.value = 0
})
- it('should init boundaries and compute a height chart', () => {
+ it('should init boundaries and compute a height chart', async () => {
const body = ref({
getBoundingClientRect: () => ({
top: 0,
}),
})
const list = [...new Array(20)].map((i, index) => ({
id: `${index}i`,
}))
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list: ref(list),
body: ref(body),
+ buffer: 3,
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
result.updateBoundaries()
const items = [...new Array(11)].map((i, index) => ({
id: `${index}i`,
top: index * 100,
height: 100,
type: 'item',
}))
const spacer = {
type: 'spacer',
top: 1100,
height: 900,
ids: expect.any(Set),
id: 'i11',
}
+ await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([...items, spacer])
})
- it('should update boundaries and compute a height chart on scroll', () => {
+ it('should update a height chart on list update', async () => {
+ const body = ref({
+ getBoundingClientRect: () => ({
+ top: 0,
+ }),
+ })
+ const list = ref(
+ [...new Array(40)].map((i, index) => ({
+ id: `${index}i`,
+ })),
+ )
+ const result = useVirtualScrolling({
+ name: 'Test',
+ enabled: ref(true),
+ list,
+ body: ref(body),
+ offset: 0,
+ getPlaceholderHeight: () => ref(100),
+ scrollPositionInstance,
+ })
+
+ result.updateBoundaries()
+ const items = [...new Array(18)].map((i, index) => ({
+ id: `${index}i`,
+ top: index * 100,
+ height: 100,
+ type: 'item',
+ }))
+ const spacerOld = {
+ type: 'spacer',
+ top: 1800,
+ height: 2200,
+ ids: expect.any(Set),
+ id: 'i18',
+ }
+ const spacerNew = {
+ type: 'spacer',
+ top: 1800,
+ height: 2300,
+ ids: expect.any(Set),
+ id: 'i18',
+ }
+ await vi.advanceTimersByTime(32)
+ expect(result.heightChart.value).toEqual([...items, spacerOld])
+ list.value.push({ id: 'new' })
+ await vi.advanceTimersByTime(32)
+ await vi.advanceTimersByTime(32)
+ expect(result.heightChart.value).toEqual([...items, spacerNew])
+ })
+
+ it('should update boundaries and compute a height chart on scroll', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const list = [...new Array(20)].map((i, index) => ({
id: `${index}i`,
}))
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list: ref(list),
body: ref(body),
+ buffer: 3,
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
result.updateBoundaries()
const amount = 500
topPosition -= amount
result.updateBoundaries()
const spacer1 = {
type: 'spacer',
top: 0,
height: 200,
ids: expect.any(Set),
id: 'i0',
}
const items = [...new Array(14)].map((i, index) => ({
id: `${index + 2}i`,
top: (index + 2) * 100,
height: 100,
type: 'item',
}))
const spacer2 = {
type: 'spacer',
top: 1600,
height: 400,
ids: expect.any(Set),
id: 'i16',
}
+ await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([spacer1, ...items, spacer2])
})
it('should compensate for scroll when new items added on top', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const listOld = [...new Array(10)].map((i, index) => ({
id: `${index}ia`,
}))
const listNew = [...new Array(10)].map((i, index) => ({
id: `${index}ib`,
}))
const list = ref(listOld)
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list,
body: ref(body),
offset: 0,
+ buffer: 3,
getPlaceholderHeight: () => ref(100),
scrollCompensation: true,
scrollPositionInstance,
})
listOld.forEach((i) => {
result.updateVirtualHeight({ id: i.id, height: 200 })
})
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
list.value = [...listNew, ...listOld]
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const amount = 1000
expect(scrollPositionInstance.scrollBy).to.have.been.calledWith(0, amount)
topPosition -= amount
result.updateBoundaries()
const spacer1 = {
type: 'spacer',
top: 0,
height: 700,
ids: expect.any(Set),
id: 'i0',
}
const itemsNew = [...new Array(3)].map((i, index) => ({
id: `${index + 7}ib`,
top: (index + 7) * 100,
height: 100,
type: 'item',
}))
const itemsOld = [...new Array(6)].map((i, index) => ({
id: `${index}ia`,
top: index * 200 + 1000,
height: 200,
type: 'item',
}))
const spacer2 = {
type: 'spacer',
top: 2200,
height: 800,
ids: expect.any(Set),
id: 'i16',
}
+ await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([
spacer1,
...itemsNew,
...itemsOld,
spacer2,
])
})
it('should compensate for scroll when items are removed from top', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const listA = [...new Array(10)].map((i, index) => ({
id: `${index}ia`,
}))
const listB = [...new Array(10)].map((i, index) => ({
id: `${index}ib`,
}))
const listC = [...new Array(10)].map((i, index) => ({
id: `${index}ic`,
}))
const list = ref([...listA, ...listB, ...listC])
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list,
body: ref(body),
offset: 0,
+ buffer: 3,
getPlaceholderHeight: () => ref(100),
scrollCompensation: true,
scrollPositionInstance,
})
list.value.forEach((i) => {
result.updateVirtualHeight({ id: i.id, height: 200 })
})
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
list.value = listB
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const amount = -2000
expect(scrollPositionInstance.scrollBy).to.have.been.calledWith(0, amount)
// topPosition -= amount // not doing this because it would overscroll
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const items = [...new Array(6)].map((i, index) => ({
id: `${index}ib`,
top: index * 200,
height: 200,
type: 'item',
}))
const spacer = {
type: 'spacer',
top: 1200,
height: 800,
ids: expect.any(Set),
id: 'i6',
}
+ await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([...items, spacer])
})
it('should exclude unsuspendable items from virtualization', async () => {
let topPosition = 0
const body = ref({
getBoundingClientRect: () => ({
top: topPosition,
}),
})
const list = ref(
[...new Array(20)].map((i, index) => ({
id: `${index}i`,
})),
)
const result = useVirtualScrolling({
name: 'Test',
enabled: ref(true),
list,
body: ref(body),
offset: 0,
+ buffer: 3,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
list.value.forEach((i) => {
result.updateVirtualHeight({ id: i.id, height: 200 })
})
result.changeSuspendState({ id: '2i', suspendable: false })
result.changeSuspendState({ id: '3i', suspendable: true })
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const amount = 2000
topPosition -= amount
result.updateBoundaries()
await vi.advanceTimersToNextTimerAsync()
const spacer1 = {
type: 'spacer',
top: 0,
height: 400,
ids: expect.any(Set),
id: 'i0',
}
const items1 = [
{
id: `2i`,
top: 400,
height: 200,
type: 'item',
},
]
const spacer2 = {
type: 'spacer',
top: 600,
height: 1000,
ids: expect.any(Set),
id: 'i3',
}
const items2 = [...new Array(8)].map((i, index) => ({
id: `${index + 8}i`,
top: (index + 8) * 200,
height: 200,
type: 'item',
}))
const spacer3 = {
type: 'spacer',
top: 3200,
height: 800,
ids: expect.any(Set),
id: 'i16',
}
+ await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([
spacer1,
...items1,
spacer2,
...items2,
spacer3,
])
})
})
diff --git a/test/unit/specs/services/hosted/hosted.spec.js b/test/unit/specs/services/hosted/hosted.spec.js
new file mode 100644
index 0000000000..527c5a2446
--- /dev/null
+++ b/test/unit/specs/services/hosted/hosted.spec.js
@@ -0,0 +1,99 @@
+import {
+ checkInstance,
+ chooseInstance,
+ chosenInstance,
+ forgetInstance,
+ instanceConfig,
+ instanceOrigin,
+ isHosted,
+} from 'src/services/hosted/hosted.js'
+
+describe('hosted mode', () => {
+ afterEach(() => forgetInstance())
+
+ it('is on only when the site says so', () => {
+ expect(isHosted({ hosted: true })).to.equal(true)
+ expect(isHosted({})).to.equal(false)
+ expect(isHosted(null)).to.equal(false)
+ })
+
+ it('reads an instance from what people type', () => {
+ expect(instanceOrigin('pleroma.example')).to.equal(
+ 'https://pleroma.example',
+ )
+ expect(instanceOrigin(' Pleroma.Example/ ')).to.equal(
+ 'https://pleroma.example',
+ )
+ expect(instanceOrigin('@me@pleroma.example')).to.equal(
+ 'https://pleroma.example',
+ )
+ expect(instanceOrigin('https://pleroma.example/main/all')).to.equal(
+ 'https://pleroma.example',
+ )
+ expect(instanceOrigin('http://localhost:4000')).to.equal(
+ 'http://localhost:4000',
+ )
+ // a token never goes over plain http to a real server
+ expect(instanceOrigin('http://pleroma.example')).to.equal(
+ 'https://pleroma.example',
+ )
+ expect(instanceOrigin('')).to.equal(null)
+ expect(instanceOrigin('not a domain')).to.equal(null)
+ })
+
+ it('remembers the chosen instance until it is forgotten', () => {
+ expect(chosenInstance()).to.equal(null)
+ chooseInstance('https://pleroma.example')
+ expect(chosenInstance()).to.equal('https://pleroma.example')
+ forgetInstance()
+ expect(chosenInstance()).to.equal(null)
+ })
+
+ it('checks that an instance answers with its API', async () => {
+ const json = (body) =>
+ Promise.resolve(
+ new Response(JSON.stringify(body), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ const ok = vi.fn(() => json({ uri: 'pleroma.example', title: 'P' }))
+ expect(await checkInstance('https://pleroma.example', ok)).to.equal(true)
+ expect(ok.mock.calls[0][0]).to.equal(
+ 'https://pleroma.example/api/v1/instance',
+ )
+ const html = () => Promise.resolve(new Response('<html></html>'))
+ expect(await checkInstance('https://example.com', html)).to.equal(false)
+ const down = () => Promise.reject(new TypeError('Failed to fetch'))
+ expect(await checkInstance('https://gone.example', down)).to.equal(false)
+ })
+})
+
+describe('the instance config in hosted mode', () => {
+ it('points the instance paths at the instance', () => {
+ const resolve = (p) => `https://pleroma.example${p}`
+ expect(
+ instanceConfig(
+ {
+ logo: '/static/logo.svg',
+ background: 'https://cdn.example/bg.png',
+ name: 'Pleroma',
+ loginMethod: 'password',
+ redirectRootNoLogin: '/main/all',
+ },
+ resolve,
+ ),
+ ).to.eql({
+ logo: 'https://pleroma.example/static/logo.svg',
+ background: 'https://cdn.example/bg.png',
+ name: 'Pleroma',
+ // the redirect login is the only one that works from another site
+ loginMethod: 'token',
+ // app routes stay app routes
+ redirectRootNoLogin: '/main/all',
+ })
+ })
+
+ it('copes with no config from the instance', () => {
+ expect(instanceConfig(undefined, (p) => p)).to.eql({ loginMethod: 'token' })
+ })
+})
diff --git a/test/unit/specs/stores/push_hosted.spec.js b/test/unit/specs/stores/push_hosted.spec.js
new file mode 100644
index 0000000000..87f1ddf471
--- /dev/null
+++ b/test/unit/specs/stores/push_hosted.spec.js
@@ -0,0 +1,33 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
+
+import { useInstanceStore } from 'src/stores/instance.js'
+import { useInterfaceStore } from 'src/stores/interface.js'
+
+import { registerPushNotifications } from 'src/services/sw/sw.js'
+
+vi.mock('src/services/sw/sw.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ registerPushNotifications: vi.fn(),
+}))
+
+// The service worker would fetch pushed notifications from this site, not
+// from the instance: no web push on the hosted site (yet).
+describe('web push on the hosted site', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ registerPushNotifications.mockClear()
+ })
+
+ it('does not subscribe', () => {
+ useInstanceStore().hosted = true
+ useInterfaceStore().registerPushNotifications()
+ expect(registerPushNotifications).not.toHaveBeenCalled()
+ })
+
+ it('subscribes on an instance as before', () => {
+ useInstanceStore().hosted = false
+ useInterfaceStore().registerPushNotifications()
+ expect(registerPushNotifications).toHaveBeenCalled()
+ })
+})
diff --git a/test/unit/specs/stores/shout_hosted.spec.js b/test/unit/specs/stores/shout_hosted.spec.js
new file mode 100644
index 0000000000..672c6e115e
--- /dev/null
+++ b/test/unit/specs/stores/shout_hosted.spec.js
@@ -0,0 +1,25 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
+
+import { useInstanceStore } from 'src/stores/instance.js'
+import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useShoutStore } from 'src/stores/shout.js'
+import { useUsersStore } from 'src/stores/users.js'
+
+// Pleroma refuses sockets from other sites: no endless reconnecting
+describe('shoutbox on the hosted site', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ useUsersStore().currentUser = { token: 'token' }
+ useInstanceCapabilitiesStore().shoutAvailable = true
+ useInstanceStore().hosted = true
+ })
+
+ it('opens no socket and joins nothing', () => {
+ const store = useShoutStore()
+ store.initializeSocket()
+ store.initializeShout()
+ expect(store.socket).to.equal(null)
+ expect(store.joined).to.equal(false)
+ })
+})
diff --git a/test/unit/specs/stores/streaming_refused.spec.js b/test/unit/specs/stores/streaming_refused.spec.js
new file mode 100644
index 0000000000..047415d696
--- /dev/null
+++ b/test/unit/specs/stores/streaming_refused.spec.js
@@ -0,0 +1,83 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
+
+import { useInstanceStore } from 'src/stores/instance.js'
+import { useInterfaceStore } from 'src/stores/interface.js'
+import { useStreamingStore } from 'src/stores/streaming.js'
+
+import { WSConnectionStatus } from 'src/api/websocket.js'
+
+// A server that refuses sockets from this site (Pleroma's origin check, for
+// a hosted frontend): the socket closes before it ever opens. The app
+// stops asking after a few tries and keeps polling, without notices.
+describe('streaming a server refuses', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ vi.useFakeTimers({ toFake: ['setTimeout'] })
+ })
+ afterEach(() => vi.useRealTimers())
+
+ const refused = { data: { code: 1006 } }
+
+ it('stops retrying after three sockets that never opened', async () => {
+ useInstanceStore().hosted = true
+ const streaming = useStreamingStore()
+ const initSocket = vi
+ .spyOn(streaming, 'initSocket')
+ .mockReturnValue(undefined)
+ for (let i = 0; i < 3; i++) {
+ streaming.onClose(refused)
+ await vi.runAllTimersAsync()
+ }
+ expect(initSocket).toHaveBeenCalledTimes(2)
+ expect(streaming.state).to.equal(WSConnectionStatus.DISABLED)
+ expect(streaming.retrying).to.equal(false)
+ })
+
+ it('keeps retrying a socket that did open before (a server restart)', async () => {
+ const streaming = useStreamingStore()
+ const initSocket = vi
+ .spyOn(streaming, 'initSocket')
+ .mockReturnValue(undefined)
+ streaming.onOpen()
+ for (let i = 0; i < 4; i++) {
+ streaming.onClose(refused)
+ await vi.runAllTimersAsync()
+ }
+ expect(initSocket).toHaveBeenCalledTimes(4)
+ expect(streaming.state).to.not.equal(WSConnectionStatus.DISABLED)
+ })
+
+ // an instance serving the page may just be restarting at page load
+ it('keeps retrying on an instance', async () => {
+ const streaming = useStreamingStore()
+ const initSocket = vi
+ .spyOn(streaming, 'initSocket')
+ .mockReturnValue(undefined)
+ for (let i = 0; i < 4; i++) {
+ streaming.onClose(refused)
+ await vi.runAllTimersAsync()
+ }
+ expect(initSocket).toHaveBeenCalledTimes(4)
+ expect(streaming.state).to.not.equal(WSConnectionStatus.DISABLED)
+ })
+
+ it('shows no "socket broke" notice for a socket that never opened', () => {
+ useInstanceStore().hosted = true
+ const ui = useInterfaceStore()
+ const push = vi.spyOn(ui, 'pushGlobalNotice')
+ ui.onStreamDisconnect({ original: { code: 1006 } })
+ expect(push).not.toHaveBeenCalled()
+ useStreamingStore().onOpen()
+ ui.onStreamDisconnect({ original: { code: 1006 } })
+ expect(push).toHaveBeenCalled()
+ })
+
+ // an instance serving the page may just be restarting: say so, as before
+ it('shows the notice on an instance even before a socket opened', () => {
+ const ui = useInterfaceStore()
+ const push = vi.spyOn(ui, 'pushGlobalNotice')
+ ui.onStreamDisconnect({ original: { code: 1006 } })
+ expect(push).toHaveBeenCalled()
+ })
+})

File Metadata

Mime Type
text/x-diff
Expires
Mon, Oct 12, 12:39 AM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1786003
Default Alt Text
(288 KB)

Event Timeline