Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85805601
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
43 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/stores/interface.js b/src/stores/interface.js
index f2b2d86c7f..a1f16ffd2a 100644
--- a/src/stores/interface.js
+++ b/src/stores/interface.js
@@ -1,895 +1,901 @@
import { defineStore } from 'pinia'
import {
applyTheme,
getResourcesIndex,
tryLoadCache,
} from '../services/style_setter/style_setter.js'
import { deserialize } from '../services/theme_data/iss_deserializer.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import {
registerPushNotifications,
unregisterPushNotifications,
} from 'src/services/sw/sw.js'
import {
CURRENT_VERSION,
generatePreset,
} from 'src/services/theme_data/theme_data.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
const getNotificationPermission = async () => {
const Notification = window.Notification
if (!Notification) return null
if (Notification.permission === 'default')
return Notification.requestPermission()
return Notification.permission
}
const GENERIC_FONT_NAMES = new Set([
'serif',
'sans-serif',
'system-ui',
'cursive',
'fantasy',
'math',
'monospace',
])
export const useInterfaceStore = defineStore('interface', {
state: () => ({
localFonts: null,
themeApplied: false,
themeChangeInProgress: false,
themeVersion: 'v3',
styleNameUsed: null,
styleDataUsed: null,
useStylePalette: false, // hack for applying styles from appearance tab
paletteNameUsed: null,
paletteDataUsed: null,
themeNameUsed: null,
themeDataUsed: null,
temporaryChangesTimeoutId: null,
temporaryChangesCountdown: -1, // used for temporary options that revert after a timeout
temporaryChangesConfirm: () => {
/* no-op */
}, // used for applying temporary options
temporaryChangesRevert: () => {
/* no-op */
}, // used for reverting temporary options
settingsModalState: 'hidden',
settingsModalLoadedUser: false,
settingsModalLoadedAdmin: false,
settingsModalTargetTab: null,
settingsModalMode: 'user',
settings: {
currentSaveStateNotice: null,
noticeClearTimeout: null,
notificationPermission: null,
},
browserSupport: {
cssFilter:
window.CSS?.supports &&
(window.CSS.supports('filter', 'drop-shadow(0 0)') ||
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')),
localFonts: typeof window.queryLocalFonts === 'function',
},
layoutType: 'normal',
globalNotices: [],
globalError: null,
layoutHeight: 0,
lastTimeline: null,
foreignProfileBackground: null,
}),
actions: {
attachSocket() {
const et = new EventTarget()
const socket = {
name: 'interface',
et,
}
et.addEventListener('open', this.onStreamConnect)
et.addEventListener('close', this.onStreamDisconnect)
useStreamingStore().addSubscriber(socket)
},
onLogin() {
getNotificationPermission().then((permission) =>
useInterfaceStore().setNotificationPermission(permission),
)
this.setLayoutWidth(windowWidth())
this.setLayoutHeight(windowHeight())
},
onLogout() {
this.setLastTimeline('public-timeline')
this.setLayoutWidth(windowWidth())
this.setLayoutHeight(windowHeight())
},
onStreamConnect() {
if (useStreamingStore().state !== WSConnectionStatus.STARTING_INITIAL) {
this.pushGlobalNotice({
level: 'success',
messageKey: 'timeline.socket_reconnected',
timeout: 5000,
})
}
},
onStreamDisconnect(closeEvent) {
+ // hosted and never connected: nothing broke (an instance that refuses
+ // sockets from this site is polled instead)
+ if (useInstanceStore().hosted && !useStreamingStore().everOpened) return
const intendedCodes = new Set([
1000, // Normal (intended) closure
1001, // Going away
])
const { code } = closeEvent.original
if (!intendedCodes.has(code)) {
this.pushGlobalNotice({
level: 'error',
messageKey: 'timeline.socket_broke',
messageArgs: [code],
timeout: 5000,
})
}
},
setTemporaryChanges({ confirm, revert }) {
this.temporaryChangesCountdown = 10
this.temporaryChangesConfirm = confirm
this.temporaryChangesRevert = revert
const countdownFunc = () => {
if (this.temporaryChangesCountdown <= 1) {
this.temporaryChangesRevert()
this.clearTemporaryChanges()
} else {
this.temporaryChangesCountdown--
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
}
}
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
},
clearTemporaryChanges() {
this.temporaryChangesTimeoutId ??
clearTimeout(this.temporaryChangesTimeoutId)
this.temporaryChangesTimeoutId = null
this.temporaryChangesCountdown = -1
this.temporaryChangesConfirm = () => {
/* no-op */
}
this.temporaryChangesRevert = () => {
/* no-op */
}
},
setPageTitle(option = '') {
try {
document.title = `${option} ${useInstanceStore().instanceIdentity.name}`
} catch (error) {
console.error(`${error}`)
}
},
setForeignProfileBackground(url) {
this.foreignProfileBackground = url
},
settingsSaved({ success, error }) {
if (success) {
if (this.noticeClearTimeout) {
clearTimeout(this.noticeClearTimeout)
}
this.settings.currentSaveStateNotice = { error: false, data: success }
this.settings.noticeClearTimeout = setTimeout(
() => delete this.settings.currentSaveStateNotice,
2000,
)
} else {
this.settings.currentSaveStateNotice = { error: true, errorData: error }
}
},
setNotificationPermission(permission) {
this.notificationPermission = permission
},
closeSettingsModal() {
this.settingsModalState = 'hidden'
},
openSettingsModal(value) {
this.settingsModalMode = value
this.settingsModalState = 'visible'
if (value === 'user') {
if (!this.settingsModalLoadedUser) {
this.settingsModalLoadedUser = true
}
} else if (value === 'admin') {
if (!this.settingsModalLoadedAdmin) {
this.settingsModalLoadedAdmin = true
}
}
},
setSettingsModalState(newState) {
const oldState = this.settingsModalState
const legal = (() => {
switch (oldState) {
case 'minimized':
return true
case 'visible':
return true
case 'hidden':
return newState === 'visible'
}
})()
if (legal) {
this.settingsModalState = newState
}
},
toggleMinimizeSettingsModal() {
switch (this.settingsModalState) {
case 'minimized':
this.settingsModalState = 'visible'
return
case 'visible':
this.settingsModalState = 'minimized'
return
case 'hidden':
return
default:
throw new Error(
`Illegal minimization state of settings modal: ${this.settingsModalState}`,
)
}
},
clearSettingsModalTargetTab() {
this.settingsModalTargetTab = null
},
openSettingsModalTab(value, mode = 'user') {
this.settingsModalTargetTab = value
this.openSettingsModal(mode)
},
removeGlobalNotice(notice) {
this.globalNotices = this.globalNotices.filter((n) => n !== notice)
},
setGlobalError({ error, instance, info }) {
switch (info) {
case 'https://vuejs.org/error-reference/#runtime-13': {
this.globalError = {
title: 'general.refresh_required',
content: 'general.refresh_required_content',
// `true` disables cache on Firefox (non-standard)
recover: () => window.location.reload(true),
recoverText: 'general.refresh_required_refresh',
error,
}
break
}
default: {
this.globalError = { error }
break
}
}
},
clearGlobalError() {
this.globalError = null
},
pushGlobalNotice({
messageKey,
messageArgs = {},
level = 'error',
timeout = 5000,
}) {
const notice = {
messageKey,
messageArgs,
level,
}
this.globalNotices.push(notice)
// Adding a new element to array wraps it in a Proxy, which breaks the comparison
// TODO: Generate UUID or something instead or relying on !== operator?
const newNotice = this.globalNotices[this.globalNotices.length - 1]
if (timeout > 0) {
setTimeout(() => this.removeGlobalNotice(newNotice), timeout)
}
return newNotice
},
setLayoutHeight(value) {
this.layoutHeight = value
},
setLayoutWidth(value) {
let width = value
if (value !== undefined) {
this.layoutWidth = value
} else {
width = this.layoutWidth
}
const mobileLayout = width <= 800
const normalOrMobile = mobileLayout ? 'mobile' : 'normal'
const { thirdColumnMode } = useMergedConfigStore().mergedConfig
if (thirdColumnMode === 'none' || !useUsersStore().currentUser) {
this.layoutType = normalOrMobile
} else {
const wideLayout = width >= 1300
this.layoutType = wideLayout ? 'wide' : normalOrMobile
}
},
setFontsList(value) {
this.localFonts = [...new Set(value.map(({ family }) => family)).values()]
},
queryLocalFonts() {
if (this.localFonts !== null) return
this.setFontsList([])
if (!this.browserSupport.localFonts) {
return
}
window
.queryLocalFonts()
.then((fonts) => {
this.setFontsList(fonts)
})
.catch((e) => {
this.pushGlobalNotice({
messageKey: 'settings.style.themes3.font.font_list_unavailable',
messageArgs: {
error: e,
},
level: 'error',
})
})
},
setLastTimeline(value) {
this.lastTimeline = value
},
async fetchPalettesIndex() {
try {
const value = await getResourcesIndex('/static/palettes/index.json')
useInstanceStore().set({
path: 'palettesIndex',
value,
})
return value
} catch (e) {
console.error('Could not fetch palettes index', e)
useInstanceStore().set({
path: 'palettesIndex',
value: { _error: e },
})
return {}
}
},
setPalette(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.palette', value })
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setPaletteCustom(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({
path: 'simple.paletteCustomData',
value,
})
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
async fetchStylesIndex() {
try {
const value = await getResourcesIndex(
'/static/styles/index.json',
deserialize,
)
useInstanceStore().set({ path: 'stylesIndex', value })
return value
} catch (e) {
console.error('Could not fetch styles index', e)
useInstanceStore().set({
path: 'simple.stylesIndex',
value: { _error: e },
})
return {}
}
},
setStyle(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
useSyncConfigStore().setPreference({ path: 'simple.style', value })
useSyncConfigStore().pushSyncConfig()
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
setStyleCustom(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
useSyncConfigStore().setPreference({
path: 'simple.styleCustomData',
value,
})
useSyncConfigStore().pushSyncConfig()
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
async fetchThemesIndex() {
try {
const value = await getResourcesIndex('/static/styles.json')
useInstanceStore().set({ path: 'themesIndex', value })
return value
} catch (e) {
console.error('Could not fetch themes index', e)
useInstanceStore().set({
path: 'themesIndex',
value: { _error: e },
})
return {}
}
},
setTheme(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.theme', value })
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setThemeCustom(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.customTheme', value })
useSyncConfigStore().setPreference({
path: 'simple.customThemeSource',
value,
})
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
resetThemeV3() {
useSyncConfigStore().setPreference({ path: 'simple.style', value: null })
useSyncConfigStore().setPreference({
path: 'simple.styleCustomData',
value: null,
})
},
resetThemeV3Palette() {
useSyncConfigStore().setPreference({
path: 'simple.palette',
value: null,
})
useSyncConfigStore().setPreference({
path: 'simple.paletteCustomData',
value: null,
})
},
resetThemeV2() {
useSyncConfigStore().setPreference({ path: 'simple.theme', value: null })
useSyncConfigStore().setPreference({
path: 'simple.customTheme',
value: null,
})
useSyncConfigStore().setPreference({
path: 'simple.customThemeSource',
value: null,
})
},
async getThemeData() {
const getData = async (resource, index, customData, name) => {
const capitalizedResource =
resource[0].toUpperCase() + resource.slice(1)
const result = {}
if (customData) {
result.nameUsed = 'custom' // custom data overrides name
result.dataUsed = customData
} else {
result.nameUsed = name
if (result.nameUsed == null) {
result.dataUsed = null
return result
}
let fetchFunc = index[result.nameUsed]
// Fallbacks
if (!fetchFunc) {
if (resource === 'style' || resource === 'palette') {
return result
}
const newName = Object.keys(index)[0]
fetchFunc = index[newName]
console.warn(
`${capitalizedResource} with id '${this.styleNameUsed}' not found, trying back to '${newName}'`,
)
if (!fetchFunc) {
console.warn(
`${capitalizedResource} doesn't have a fallback, defaulting to stock.`,
)
fetchFunc = () => Promise.resolve(null)
}
}
result.dataUsed = await fetchFunc()
}
return result
}
let {
theme: instanceThemeName,
style: instanceStyleName,
palette: instancePaletteName,
} = useInstanceStore().instanceIdentity
let { themesIndex, stylesIndex, palettesIndex } = useInstanceStore()
const {
style: userStyleName,
styleCustomData: userStyleCustomData,
palette: userPaletteName,
paletteCustomData: userPaletteCustomData,
} = useMergedConfigStore().mergedConfig
let {
theme: userThemeV2Name,
customTheme: userThemeV2Snapshot,
customThemeSource: userThemeV2Source,
} = useMergedConfigStore().mergedConfig
let majorVersionUsed
console.debug(
`User V3 palette: ${userPaletteName}, style: ${userStyleName} , custom: ${!!userStyleCustomData}`,
)
console.debug(
`User V2 name: ${userThemeV2Name}, source: ${!!userThemeV2Source}, snapshot: ${!!userThemeV2Snapshot}`,
)
console.debug(
`Instance V3 palette: ${instancePaletteName}, style: ${instanceStyleName}`,
)
console.debug('Instance V2 theme: ' + instanceThemeName)
if (
userPaletteName ||
userPaletteCustomData ||
userStyleName ||
userStyleCustomData ||
// User V2 overrides instance V3
((instancePaletteName || instanceStyleName) &&
instanceThemeName == null &&
userThemeV2Name == null)
) {
// Palette and/or style overrides V2 themes
instanceThemeName = null
userThemeV2Name = null
userThemeV2Source = null
userThemeV2Snapshot = null
majorVersionUsed = 'v3'
} else if (
userThemeV2Name ||
userThemeV2Snapshot ||
userThemeV2Source ||
instanceThemeName
) {
majorVersionUsed = 'v2'
} else {
// if all fails fallback to v3
majorVersionUsed = 'v3'
}
if (majorVersionUsed === 'v3') {
const result = await Promise.all([
this.fetchPalettesIndex(),
this.fetchStylesIndex(),
])
palettesIndex = result[0]
stylesIndex = result[1]
} else {
// Promise.all just to be uniform with v3
const result = await Promise.all([this.fetchThemesIndex()])
themesIndex = result[0]
}
this.themeVersion = majorVersionUsed
console.debug('Version used', majorVersionUsed)
if (majorVersionUsed === 'v3') {
this.themeDataUsed = null
this.themeNameUsed = null
const style = await getData(
'style',
stylesIndex,
userStyleCustomData,
userStyleName || instanceStyleName,
)
this.styleNameUsed = style.nameUsed
this.styleDataUsed = style.dataUsed
let firstStylePaletteName = null
style.dataUsed
?.filter((x) => x.component === '@palette')
.map((x) => {
const cleanDirectives = Object.fromEntries(
Object.entries(x.directives).filter(([k]) => k),
)
return { name: x.variant, ...cleanDirectives }
})
.forEach((palette) => {
const key =
'style.' + palette.name.toLowerCase().replaceAll(' ', '_')
if (!firstStylePaletteName) firstStylePaletteName = key
palettesIndex[key] = () => Promise.resolve(palette)
})
const palette = await getData(
'palette',
palettesIndex,
userPaletteCustomData,
this.useStylePalette
? firstStylePaletteName
: userPaletteName || instancePaletteName,
)
if (this.useStylePalette) {
useSyncConfigStore().setPreference({
path: 'simple.palette',
value: firstStylePaletteName,
})
useSyncConfigStore().pushSyncConfig()
}
this.paletteNameUsed = palette.nameUsed
this.paletteDataUsed = palette.dataUsed
if (this.paletteDataUsed) {
this.paletteDataUsed.link =
this.paletteDataUsed.link || this.paletteDataUsed.accent
this.paletteDataUsed.accent =
this.paletteDataUsed.accent || this.paletteDataUsed.link
}
if (Array.isArray(this.paletteDataUsed)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = palette.dataUsed
this.paletteDataUsed = {
name,
bg,
fg,
text,
link,
accent: link,
cRed,
cBlue,
cGreen,
cOrange,
}
}
console.debug('Palette data used', palette.dataUsed)
} else {
this.styleNameUsed = null
this.styleDataUsed = null
this.paletteNameUsed = null
this.paletteDataUsed = null
const theme = await getData(
'theme',
themesIndex,
userThemeV2Source || userThemeV2Snapshot,
userThemeV2Name || instanceThemeName,
)
this.themeNameUsed = theme.nameUsed
this.themeDataUsed = theme.dataUsed
}
},
async setThemeApplied() {
this.themeApplied = true
},
async applyTheme({ recompile = false } = {}) {
const { mergedConfig } = useMergedConfigStore()
const { forceThemeRecompilation, themeDebug } = mergedConfig
this.themeChangeInProgress = true
// If we're not forced to recompile try using
// cache (tryLoadCache return true if load successful)
const forceRecompile = forceThemeRecompilation || recompile
await this.getThemeData()
if (!forceRecompile && !themeDebug && (await tryLoadCache())) {
this.themeChangeInProgress = false
return this.setThemeApplied()
}
window.splashUpdate('splash.theme')
try {
const paletteIss = (() => {
if (!this.paletteDataUsed) return null
const result = {
component: 'Root',
directives: {},
}
Object.entries(this.paletteDataUsed)
.filter(([k]) => k !== 'name')
.forEach(([k, v]) => {
let issRootDirectiveName
switch (k) {
case 'background':
issRootDirectiveName = 'bg'
break
case 'foreground':
issRootDirectiveName = 'fg'
break
default:
issRootDirectiveName = k
}
result.directives['--' + issRootDirectiveName] = 'color | ' + v
})
return result
})()
const theme2ruleset =
this.themeDataUsed &&
convertTheme2To3(normalizeThemeData(this.themeDataUsed))
const hacks = []
const fontMap = {
Interface: 'Root',
Input: 'Input',
Posts: 'Post',
Monospace: 'Root',
}
Object.entries(fontMap).forEach(([font, component]) => {
const family = mergedConfig[`font${font}`]
const variable = font === 'Monospace' ? '--monoFont' : '--font'
if (typeof family === 'string') {
const familyString = GENERIC_FONT_NAMES.has(family)
? family
: `"${family}"`
hacks.push({
component,
directives: {
[variable]: `generic | ${familyString}`,
},
})
}
})
if (mergedConfig.underlay !== 'none') {
const newRule = {
component: 'Underlay',
directives: {},
}
if (mergedConfig.underlay === 'opaque') {
newRule.directives.opacity = 1
newRule.directives.background = '--wallpaper'
}
if (mergedConfig.underlay === 'transparent') {
newRule.directives.opacity = 0
}
hacks.push(newRule)
}
const rulesetArray = [
theme2ruleset,
this.styleDataUsed,
paletteIss,
hacks,
].filter(Boolean)
return applyTheme(
rulesetArray.flat(),
() => this.setThemeApplied(),
() => {
this.themeChangeInProgress = false
},
themeDebug,
)
} catch (e) {
console.error(e)
window.splashError(e)
}
},
// Push notifications
registerPushNotifications() {
+ // hosted: the service worker would ask this site, not the instance,
+ // for the pushed notification
+ if (useInstanceStore().hosted) return
const token = useOAuthStore().token
const vapidPublicKey = useInstanceStore().vapidPublicKey
const isEnabled = useMergedConfigStore().mergedConfig.webPushNotifications
const notificationVisibility =
useMergedConfigStore().mergedConfig.notificationVisibility
registerPushNotifications(
isEnabled,
vapidPublicKey,
token,
notificationVisibility,
)
},
unregisterPushNotifications() {
const token = useOAuthStore().token
unregisterPushNotifications(token)
},
},
})
export const normalizeThemeData = (input) => {
let themeData, themeSource
if (input.themeFileVerison === 1) {
// this might not be even used at all, some leftover of unimplemented code in V2 editor
return generatePreset(input).theme
} else if (
Object.hasOwn(input, '_pleroma_theme_version') ||
Object.hasOwn(input, 'source') ||
Object.hasOwn(input, 'theme')
) {
// We got passed a full theme file
themeData = input.theme
themeSource = input.source
} else if (
Object.hasOwn(input, 'themeEngineVersion') ||
Object.hasOwn(input, 'colors')
) {
// We got passed a source/snapshot
themeData = input
themeSource = input
}
// New theme presets don't have 'theme' property, they use 'source'
let out // shout, shout let it all out
if (themeSource?.themeEngineVersion === CURRENT_VERSION) {
// There are some themes in wild that have completely broken source
out = { ...(themeData || {}), ...themeSource }
} else {
out = themeData
}
// generatePreset here basically creates/updates "snapshot",
// while also fixing the 2.2 -> 2.3 colors/shadows/etc
return generatePreset(out).theme
}
diff --git a/src/stores/shout.js b/src/stores/shout.js
index 05cd340bee..9d84323626 100644
--- a/src/stores/shout.js
+++ b/src/stores/shout.js
@@ -1,54 +1,63 @@
import { Socket } from 'phoenix'
import { defineStore } from 'pinia'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useUsersStore } from 'src/stores/users.js'
+import { streamingUrl } from 'src/api/api_base.js'
+
// Maybe rename it to PhoenixSocket if we ever utilize this socket more
export const useShoutStore = defineStore('shout', {
state: () => ({
messages: [],
channel: { state: '' },
joined: false,
socket: null,
}),
getters: {
token: () => useUsersStore().currentUser?.token,
},
actions: {
initializeSocket() {
if (this.token === null) return
if (!useInstanceCapabilitiesStore().shoutAvailable) return
+ // the instance's socket refuses other sites (Pleroma's origin check)
+ if (useInstanceStore().hosted) return
if (this.socket !== null) return
- this.socket = new Socket('/socket', { params: { token: this.token } })
+ this.socket = new Socket(streamingUrl('/socket'), {
+ params: { token: this.token },
+ })
this.socket.connect()
},
initializeShout() {
+ if (this.socket === null) return
+
const channel = this.socket.channel('chat:public')
channel.joinPush.receive('ok', () => {
this.joined = true
})
channel.onClose(() => {
this.joined = false
})
channel.onError(() => {
this.joined = false
})
channel.on('new_msg', (msg) => {
this.messages.push(msg)
this.messages = this.messages.slice(-19, 20)
})
channel.on('messages', ({ messages }) => {
this.messages = messages.slice(-19, 20)
})
channel.join()
this.channel = channel
},
disconnectSocket() {
this.socket?.disconnect()
this.socket = null
},
},
})
diff --git a/src/stores/streaming.js b/src/stores/streaming.js
index 554d6a6b5c..7dfe98cd88 100644
--- a/src/stores/streaming.js
+++ b/src/stores/streaming.js
@@ -1,270 +1,292 @@
import { defineStore } from 'pinia'
+import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
getMastodonSocketURI,
ProcessedWS,
WSConnectionStatus,
} from 'src/api/websocket.js'
const ARGUMENT_MAP = {
tag: 'tag',
list: 'list',
}
export const TIMELINE_STREAM_MAP = {
friends: 'user',
public: 'public',
tag: 'hashtag',
list: 'list',
dms: 'direct',
}
const retryTimeout = (multiplier) => 1000 * multiplier
+const REFUSED_AFTER_FAILED_STARTS = 3
export class StreamStateEvent extends Event {
original
constructor(name, original) {
super(name)
this.original = original
}
}
export class StreamErrorEvent extends Event {
error
constructor(error) {
super('error', error)
this.error = error
}
}
export class StreamMessageEvent extends Event {
data
stream
timestamp
constructor(name, stream, data) {
super(name)
this.data = data
this.stream = stream
this.timestamp = Date.now()
}
}
export const useStreamingStore = defineStore('streaming', {
state: () => ({
socket: null,
error: null,
state: null,
retryMultiplier: 1,
retrying: false,
+ // a socket opened at least once this session
+ everOpened: false,
+ // sockets in a row that closed before they opened
+ failedStarts: 0,
subscribers: new Set(),
subscriptions: new Map(),
globalSubscriptions: new Set(),
}),
actions: {
addSubscriber(subscriber) {
const { stream, et } = subscriber
if (stream) {
if (!this.subscriptions.has(stream.name)) {
this.subscriptions.set(stream.name, new Map())
}
const streamSubs = this.subscriptions.get(stream.name)
if (streamSubs.has(stream.argument)) {
throw new Error('Subscription already exists!')
}
streamSubs.set(stream.argument, subscriber)
} else {
this.globalSubscriptions.add(subscriber)
}
this.subscribers.add(subscriber)
if (this.state === WSConnectionStatus.JOINED) {
if (stream) {
this.socket.subscribe(...this.getSubArgs(stream))
}
et.dispatchEvent(new StreamStateEvent('open'))
}
},
removeSubscriber(subscriber) {
const { stream } = subscriber
this.subscribers.delete(subscriber)
if (stream) {
this.subscriptions.get(stream.name).delete(stream.argument)
} else {
this.globalSubscriptions.delete(subscriber)
}
if (stream && this.state === WSConnectionStatus.JOINED) {
this.socket.unsubscribe(...this.getSubArgs(stream))
}
},
initSocket(initial) {
if (this.socket) {
console.error(
"Socket already exists! This shouldn't happen! Force-closing the socket just in case",
)
try {
this.socket.close()
} catch (e) {
console.error(
"Error closing existing socket that shouldn't be there. Welp. Moving on.",
e,
)
}
}
this.state = initial
? WSConnectionStatus.STARTING_INITIAL
: WSConnectionStatus.STARTING
const credentials = useOAuthStore().token
const url = getMastodonSocketURI({ credentials })
this.socket = ProcessedWS({
url,
id: 'Unified',
credentials,
})
this.socket.addEventListener('pleroma:authenticated', this.onAuth)
this.socket.addEventListener('open', this.onOpen)
this.socket.addEventListener('close', this.onClose)
this.socket.addEventListener('message', this.onMessage)
this.socket.addEventListener('error', this.onError)
},
stopSocket() {
if (this.socket == null)
console.error("Socket is already stopped! This shouldn't happen!")
this.socket?.close()
this.socket = null
this.state = WSConnectionStatus.CLOSED
this.retrying = false
this.retryMultiplier = 1
+ this.failedStarts = 0
this.error = null
},
getSubArgs(stream) {
const argumentKey = ARGUMENT_MAP[stream.name]
const args = argumentKey
? {
[argumentKey]: stream.argument,
}
: null
return [stream.name, args]
},
onAuth() {
this.subscribers.forEach(({ stream, et }) => {
et.dispatchEvent(new StreamStateEvent('authenticated'))
if (stream) {
this.socket.subscribe(...this.getSubArgs(stream))
}
})
this.state = WSConnectionStatus.JOINED
},
onOpen() {
+ this.everOpened = true
+ this.failedStarts = 0
this.retryMultiplier = 1
this.retrying = false
this.error = null
this.subscribers.forEach(({ stream, et }) => {
et.dispatchEvent(new StreamStateEvent('open'))
})
},
onMessage({ data: message }) {
if (!message) return // pings
const { event: eventName, stream: eventStream, ...data } = message
const [streamName, streamArgument] = eventStream ?? []
const subscriber = this.subscriptions.get(streamName)?.get(streamArgument)
const totalSubs = [
...this.globalSubscriptions.values(),
subscriber,
].filter(Boolean)
const eventData = (() => {
switch (eventName) {
case 'status.update':
case 'update':
return [data.status]
case 'notification':
return [data.notification]
case 'delete':
return [data.id]
default:
return data
}
})()
const event = new StreamMessageEvent(
eventName,
{ name: streamName, argument: streamArgument },
eventData,
)
totalSubs.forEach(({ stream, et }) => {
et.dispatchEvent(event)
})
},
onError({ data: error }) {
this.subscribers.forEach(({ stream, et }) => {
et.dispatchEvent(new StreamErrorEvent(error))
})
this.error = error
console.error('Error in MastoAPI websocket:', error)
},
onClose({ data: closeEvent }) {
const ignoreCodes = new Set([
1000, // Normal (intended) closure
1001, // Going away
])
const { code } = closeEvent
if (ignoreCodes.has(code)) {
console.debug(
`Not restarting socket becasue of closure code ${code} is in ignore list`,
)
this.state = WSConnectionStatus.CLOSED
this.retrying = false
this.error = null
this.retryMultiplier = 1
this.subscribers.forEach(({ et }) => {
et.dispatchEvent(new StreamStateEvent('close', closeEvent))
})
+ } else if (
+ useInstanceStore().hosted &&
+ !this.everOpened &&
+ ++this.failedStarts >= REFUSED_AFTER_FAILED_STARTS
+ ) {
+ // hosted: the instance refuses sockets from this site (Pleroma's
+ // origin check), polling it is. An instance serving the page keeps
+ // retrying: it may just be restarting.
+ console.info('Streaming not available here, polling instead')
+ this.socket = null
+ this.state = WSConnectionStatus.DISABLED
+ this.retrying = false
+ this.retryMultiplier = 1
} else {
console.warn(
`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`,
)
setTimeout(() => {
if (!this.retrying) return // retry aborted (i.e. due to logout)
this.initSocket()
}, retryTimeout(this.retryMultiplier))
this.retryMultiplier += 1
if (!this.retrying) {
this.subscribers.forEach(({ et }) => {
et.dispatchEvent(new StreamStateEvent('close', closeEvent))
})
}
this.retrying = true
this.state = WSConnectionStatus.ERROR
}
},
},
})
diff --git a/test/unit/specs/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
Sun, Oct 11, 4:53 PM (1 d, 15 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785890
Default Alt Text
(43 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment