Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85803723
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
53 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
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)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment