Page MenuHomePhorge

No OneTemporary

Size
43 KB
Referenced Files
None
Subscribers
None
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

Mime Type
text/x-diff
Expires
Sun, Oct 11, 4:53 PM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785890
Default Alt Text
(43 KB)

Event Timeline