Page MenuHomePhorge

No OneTemporary

Size
53 KB
Referenced Files
None
Subscribers
None
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,5 +1,6 @@
import { snakeCase } from 'lodash-es'
+import { apiUrl } from 'src/api/api_base.js'
import { StatusCodeError } from 'src/services/errors/errors'
export const paramsString = (params = {}) => {
@@ -100,7 +101,7 @@ export const promisedRequest = async ({
}
}
- const response = await fetch(url, options)
+ const response = await fetch(apiUrl(url), options)
const data = await (async () => {
const [contentType] = response.headers
.get('content-type')
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,3 +1,4 @@
+import { streamingUrl } from './api_base.js'
import { paramsString } from './helpers.js'
import {
@@ -7,7 +8,7 @@ import {
} 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 })
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
@@ -6,6 +6,7 @@ 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'
@@ -43,6 +44,7 @@ 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 {
@@ -50,6 +52,11 @@ import {
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
@@ -75,7 +82,7 @@ const decodeUTF8Base64 = (data) => {
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)
@@ -134,7 +141,9 @@ const getInstanceConfig = async ({ store }) => {
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
@@ -202,7 +211,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
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 })
@@ -233,12 +242,14 @@ const getInstancePanel = async ({ store }) => {
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) {
@@ -443,18 +454,32 @@ const getNodeInfo = async ({ store }) => {
}
}
-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) {
@@ -548,12 +573,28 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
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()
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
@@ -6,6 +6,7 @@ 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'
@@ -13,6 +14,9 @@ import { faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes)
const LoginForm = {
+ inject: {
+ reloadPage: { default: () => () => window.location.reload() },
+ },
data: () => ({
user: {},
error: false,
@@ -20,7 +24,12 @@ const LoginForm = {
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,
@@ -29,6 +38,16 @@ const LoginForm = {
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()
},
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
@@ -42,3 +42,15 @@
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
@@ -49,6 +49,20 @@
<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>
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
@@ -210,6 +210,10 @@ $modal-view-button-icon-margin: 0.5em;
&.loading {
opacity: 0.5;
}
+
+ &.still-image img {
+ will-change: transform;
+ }
}
.loading-spinner {
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
@@ -101,10 +101,12 @@ const UsersTab = {
},
methods: {
fetchUsers(page) {
- return useAdminSettingsStore().fetchUsers({
- ...this.fetchOptions,
- page,
- })
+ return useAdminSettingsStore()
+ .fetchUsers({
+ ...this.fetchOptions,
+ page,
+ })
+ .then((k) => k.items)
},
},
watch: {
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
@@ -31,7 +31,7 @@
<button
class="button-unstyled loadmore-button"
:title="loadButtonString"
- @click.prevent="showNewStatuses"
+ @click.prevent="showNewStatuses(false)"
>
<FAIcon
fixed-width
@@ -59,7 +59,7 @@
<button
v-if="showLoadButton"
class="button-default loadmore-button"
- @click.prevent="showNewStatuses"
+ @click.prevent="showNewStatuses(false)"
>
{{ loadButtonString }}
</button>
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
@@ -3,6 +3,8 @@ 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'
@@ -31,7 +33,11 @@ const UserAvatar = {
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,
}
},
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
@@ -27,6 +27,7 @@ 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'
@@ -380,16 +381,17 @@ export default {
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() {
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,5 +1,5 @@
-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
@@ -47,7 +47,8 @@ export function useVirtualScrolling({
// # 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
@@ -69,8 +70,16 @@ export function useVirtualScrolling({
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)
}
@@ -142,9 +151,9 @@ export function useVirtualScrolling({
})
// # 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 }) => {
@@ -165,12 +174,26 @@ export function useVirtualScrolling({
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
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
@@ -82,6 +82,17 @@
"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",
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
@@ -354,9 +354,10 @@ export const useAdminSettingsStore = defineStore('adminSettings', {
})
},
- installFrontend() {
+ installFrontend({ payload }) {
return installFrontend({
credentials: useOAuthStore().token,
+ payload,
}).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
@@ -5,6 +5,7 @@ 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'
@@ -242,9 +243,9 @@ export const useEmojiStore = defineStore('emoji', {
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()
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
@@ -33,6 +33,11 @@ const ROOT_STATE_DEFINITIONS = {
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,
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
@@ -129,6 +129,9 @@ export const useInterfaceStore = defineStore('interface', {
}
},
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
@@ -835,6 +838,9 @@ export const useInterfaceStore = defineStore('interface', {
// 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
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,9 +1,12 @@
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: () => ({
@@ -19,12 +22,18 @@ export const useShoutStore = defineStore('shout', {
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', () => {
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,5 +1,6 @@
import { defineStore } from 'pinia'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
@@ -22,6 +23,7 @@ export const TIMELINE_STREAM_MAP = {
}
const retryTimeout = (multiplier) => 1000 * multiplier
+const REFUSED_AFTER_FAILED_STARTS = 3
export class StreamStateEvent extends Event {
original
@@ -61,6 +63,10 @@ export const useStreamingStore = defineStore('streaming', {
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(),
@@ -149,6 +155,7 @@ export const useStreamingStore = defineStore('streaming', {
this.state = WSConnectionStatus.CLOSED
this.retrying = false
this.retryMultiplier = 1
+ this.failedStarts = 0
this.error = null
},
@@ -173,6 +180,8 @@ export const useStreamingStore = defineStore('streaming', {
this.state = WSConnectionStatus.JOINED
},
onOpen() {
+ this.everOpened = true
+ this.failedStarts = 0
this.retryMultiplier = 1
this.retrying = false
this.error = null
@@ -243,6 +252,19 @@ export const useStreamingStore = defineStore('streaming', {
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}`,
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
@@ -29,7 +29,7 @@ describe('useVirtualScrolling', () => {
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,
@@ -43,6 +43,7 @@ describe('useVirtualScrolling', () => {
enabled: ref(true),
list: ref(list),
body: ref(body),
+ buffer: 3,
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
@@ -62,10 +63,61 @@ describe('useVirtualScrolling', () => {
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: () => ({
@@ -80,6 +132,7 @@ describe('useVirtualScrolling', () => {
enabled: ref(true),
list: ref(list),
body: ref(body),
+ buffer: 3,
offset: 0,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
@@ -110,6 +163,7 @@ describe('useVirtualScrolling', () => {
ids: expect.any(Set),
id: 'i16',
}
+ await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([spacer1, ...items, spacer2])
})
@@ -133,6 +187,7 @@ describe('useVirtualScrolling', () => {
list,
body: ref(body),
offset: 0,
+ buffer: 3,
getPlaceholderHeight: () => ref(100),
scrollCompensation: true,
scrollPositionInstance,
@@ -177,6 +232,7 @@ describe('useVirtualScrolling', () => {
ids: expect.any(Set),
id: 'i16',
}
+ await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([
spacer1,
...itemsNew,
@@ -208,6 +264,7 @@ describe('useVirtualScrolling', () => {
list,
body: ref(body),
offset: 0,
+ buffer: 3,
getPlaceholderHeight: () => ref(100),
scrollCompensation: true,
scrollPositionInstance,
@@ -240,6 +297,7 @@ describe('useVirtualScrolling', () => {
ids: expect.any(Set),
id: 'i6',
}
+ await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([...items, spacer])
})
@@ -261,6 +319,7 @@ describe('useVirtualScrolling', () => {
list,
body: ref(body),
offset: 0,
+ buffer: 3,
getPlaceholderHeight: () => ref(100),
scrollPositionInstance,
})
@@ -314,6 +373,7 @@ describe('useVirtualScrolling', () => {
ids: expect.any(Set),
id: 'i16',
}
+ await vi.advanceTimersByTime(32)
expect(result.heightChart.value).toEqual([
spacer1,
...items1,
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
Fri, Oct 9, 11:30 AM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1784721
Default Alt Text
(53 KB)

Event Timeline