Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
61 KB
Referenced Files
None
Subscribers
None
diff --git a/changelog.d/akkoma.skip b/changelog.d/akkoma.skip
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/components/settings_modal/tabs/appearance_tab.js b/src/components/settings_modal/tabs/appearance_tab.js
index fd18b91e54..996f3a20c5 100644
--- a/src/components/settings_modal/tabs/appearance_tab.js
+++ b/src/components/settings_modal/tabs/appearance_tab.js
@@ -1,420 +1,427 @@
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import UnitSetting from '../helpers/unit_setting.vue'
import { defaultHorizontalUnits } from '../helpers/unit_setting.js'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import Preview from './theme_tab/theme_preview.vue'
import FontControl from 'src/components/font_control/font_control.vue'
import { newImporter } from 'src/services/export_import/export_import.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import {
- getCssRules,
- getScopedVersion
+ getCssRules
} from 'src/services/theme_data/css_utils.js'
import { deserialize } from 'src/services/theme_data/iss_deserializer.js'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import { mapActions } from 'pinia'
import { useInterfaceStore, normalizeThemeData } from 'src/stores/interface'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faGlobe
} from '@fortawesome/free-solid-svg-icons'
library.add(
faGlobe
)
const AppearanceTab = {
data () {
return {
availableThemesV3: [],
availableThemesV2: [],
bundledPalettes: [],
compilationCache: {},
fileImporter: newImporter({
accept: '.json, .iss',
validator: this.importValidator,
onImport: this.onImport,
parser: this.importParser,
onImportFailure: this.onImportFailure
}),
palettesKeys: [
'bg',
'fg',
'link',
'text',
'cRed',
'cGreen',
'cBlue',
'cOrange'
],
userPalette: {},
intersectionObserver: null,
thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.third_column_mode_${mode}`)
})),
forcedRoundnessOptions: ['disabled', 'sharp', 'nonsharp', 'round'].map((mode, i) => ({
key: mode,
value: i - 1,
label: this.$t(`settings.style.themes3.hacks.forced_roundness_mode_${mode}`)
})),
underlayOverrideModes: ['none', 'opaque', 'transparent'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.style.themes3.hacks.underlay_override_mode_${mode}`)
}))
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
ProfileSettingIndicator,
FontControl,
Preview,
PaletteEditor
},
mounted () {
useInterfaceStore().getThemeData()
const updateIndex = (resource) => {
const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
const currentIndex = this.$store.state.instance[`${resource}sIndex`]
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = useInterfaceStore()[`fetch${capitalizedResource}sIndex`]()
}
return promise.then(index => {
return Object
.entries(index)
.map(([k, func]) => [k, func()])
})
}
updateIndex('style').then(styles => {
styles.forEach(([key, stylePromise]) => stylePromise.then(data => {
const meta = data.find(x => x.component === '@meta')
this.availableThemesV3.push({ key, data, name: meta.directives.name, version: 'v3' })
}))
})
updateIndex('theme').then(themes => {
themes.forEach(([key, themePromise]) => themePromise.then(data => {
if (!data) {
console.warn(`Theme with key ${key} is empty or malformed`)
} else if (Array.isArray(data)) {
console.warn(`Theme with key ${key} is a v1 theme and should be moved to static/palettes/index.json`)
} else if (!data.source && !data.theme) {
console.warn(`Theme with key ${key} is malformed`)
} else {
this.availableThemesV2.push({ key, data, name: data.name, version: 'v2' })
}
}))
})
this.userPalette = useInterfaceStore().paletteDataUsed || {}
updateIndex('palette').then(bundledPalettes => {
bundledPalettes.forEach(([key, palettePromise]) => palettePromise.then(v => {
let palette
if (Array.isArray(v)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00'
] = v
palette = { key, name, bg, fg, text, link, cRed, cBlue, cGreen, cOrange }
} else {
palette = { key, ...v }
}
if (!palette.key.startsWith('style.')) {
this.bundledPalettes.push(palette)
}
}))
})
+ this.previewTheme('stock', 'v3')
+
if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(({ target, isIntersecting }) => {
if (!isIntersecting) return
const theme = this.availableStyles.find(x => x.key === target.dataset.themeKey)
this.$nextTick(() => {
- if (theme) theme.ready = true
+ if (theme) this.previewTheme(theme.key, theme.version, theme.data)
})
observer.unobserve(target)
})
}, {
root: this.$refs.themeList
})
+ } else {
+ this.availableStyles.forEach(theme => this.previewTheme(theme.key, theme.version, theme.data))
}
},
updated () {
this.$nextTick(() => {
this.$refs.themeList.querySelectorAll('.theme-preview').forEach(node => {
this.intersectionObserver.observe(node)
})
})
},
watch: {
paletteDataUsed () {
this.userPalette = this.paletteDataUsed || {}
}
},
computed: {
switchInProgress () {
return useInterfaceStore().themeChangeInProgress
},
paletteDataUsed () {
return useInterfaceStore().paletteDataUsed
},
availableStyles () {
return [
...this.availableThemesV3,
...this.availableThemesV2
]
},
availablePalettes () {
return [
...this.bundledPalettes,
...this.stylePalettes
]
},
stylePalettes () {
const ruleset = useInterfaceStore().styleDataUsed || []
if (!ruleset && ruleset.length === 0) return
const meta = ruleset.find(x => x.component === '@meta')
const result = ruleset.filter(x => x.component.startsWith('@palette'))
.map(x => {
const { variant, directives } = x
const {
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper
} = directives
const result = {
name: `${meta.directives.name || this.$t('settings.style.themes3.palette.imported')}: ${variant}`,
key: `style.${variant.toLowerCase().replace(/ /g, '_')}`,
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper
}
return Object.fromEntries(Object.entries(result).filter(([, v]) => v))
})
return result
},
noIntersectionObserver () {
return !window.IntersectionObserver
},
horizontalUnits () {
return defaultHorizontalUnits
},
fontsOverride () {
return this.$store.getters.mergedConfig.fontsOverride
},
columns () {
const mode = this.$store.getters.mergedConfig.thirdColumnMode
const notif = mode === 'none' ? [] : ['notifs']
if (this.$store.getters.mergedConfig.sidebarRight || mode === 'postform') {
return [...notif, 'content', 'sidebar']
} else {
return ['sidebar', 'content', ...notif]
}
},
instanceWallpaperUsed () {
return this.$store.state.instance.background &&
!this.$store.state.users.currentUser.background_image
},
language: {
get: function () { return this.$store.getters.mergedConfig.interfaceLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
}
},
customThemeVersion () {
const { themeVersion } = useInterfaceStore()
return themeVersion
},
isCustomThemeUsed () {
const { customTheme, customThemeSource } = this.mergedConfig
return customTheme != null || customThemeSource != null
},
isCustomStyleUsed () {
const { styleCustomData } = this.mergedConfig
return styleCustomData != null
},
...SharedComputedObject()
},
methods: {
updateFont (key, value) {
this.$store.dispatch('setOption', {
name: 'theme3hacks',
value: {
...this.mergedConfig.theme3hacks,
fonts: {
...this.mergedConfig.theme3hacks.fonts,
[key]: value
}
}
})
},
importFile () {
this.fileImporter.importData()
},
importParser (file, filename) {
if (filename.endsWith('.json')) {
return JSON.parse(file)
} else if (filename.endsWith('.iss')) {
return deserialize(file)
}
},
onImport (parsed, filename) {
if (filename.endsWith('.json')) {
useInterfaceStore().setThemeCustom(parsed.source || parsed.theme)
} else if (filename.endsWith('.iss')) {
useInterfaceStore().setStyleCustom(parsed)
}
},
onImportFailure (result) {
console.error('Failure importing theme:', result)
useInterfaceStore().pushGlobalNotice({ messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
importValidator (parsed, filename) {
if (filename.endsWith('.json')) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
} else if (filename.endsWith('.iss')) {
if (!Array.isArray(parsed)) return false
if (parsed.length < 1) return false
if (parsed.find(x => x.component === '@meta') == null) return false
return true
}
},
isThemeActive (key) {
return key === (this.mergedConfig.theme || this.$store.state.instance.theme)
},
isStyleActive (key) {
return key === (this.mergedConfig.style || this.$store.state.instance.style)
},
isPaletteActive (key) {
return key === (this.mergedConfig.palette || this.$store.state.instance.palette)
},
...mapActions(useInterfaceStore, [
'setStyle',
'setTheme'
]),
setPalette (name, data) {
useInterfaceStore().setPalette(name)
this.userPalette = data
},
setPaletteCustom (data) {
useInterfaceStore().setPaletteCustom(data)
this.userPalette = data
},
resetTheming () {
useInterfaceStore().setStyle('stock')
},
previewTheme (key, version, input) {
let theme3
if (this.compilationCache[key]) {
theme3 = this.compilationCache[key]
} else if (input) {
if (version === 'v2') {
const style = normalizeThemeData(input)
const theme2 = convertTheme2To3(style)
theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
})
} else if (version === 'v3') {
const palette = input.find(x => x.component === '@palette')
let paletteRule
if (palette) {
const { directives } = palette
directives.link = directives.link || directives.accent
directives.accent = directives.accent || directives.link
paletteRule = {
component: 'Root',
directives: Object.fromEntries(
Object
.entries(directives)
.filter(([k]) => k && k !== 'name')
.map(([k, v]) => ['--' + k, 'color | ' + v])
)
}
} else {
paletteRule = null
}
theme3 = init({
inputRuleset: [...input, paletteRule].filter(x => x),
ultimateBackgroundColor: '#000000',
liteMode: true,
- debug: true,
onlyNormalState: true
})
}
} else {
theme3 = init({
inputRuleset: [],
ultimateBackgroundColor: '#000000',
liteMode: true,
- debug: true,
onlyNormalState: true
})
}
if (!this.compilationCache[key]) {
this.compilationCache[key] = theme3
}
- return getScopedVersion(
- getCssRules(theme3.eager),
- '#theme-preview-' + key
- ).join('\n')
+ const styleEl = document.getElementById('theme-holder')
+ const styleSheet = styleEl.sheet
+ styleSheet.insertRule([
+ '#theme-preview-',
+ key,
+ ' {\n',
+ getCssRules(theme3.eager).join('\n'),
+ '\n}'
+ ].join(''), 'index-max')
+
}
}
}
export default AppearanceTab
diff --git a/src/components/settings_modal/tabs/appearance_tab.vue b/src/components/settings_modal/tabs/appearance_tab.vue
index d49a286517..cbbb8ff9cb 100644
--- a/src/components/settings_modal/tabs/appearance_tab.vue
+++ b/src/components/settings_modal/tabs/appearance_tab.vue
@@ -1,404 +1,386 @@
<template>
<div
class="appearance-tab"
:label="$t('settings.general')"
>
<div class="setting-item">
<h2>{{ $t('settings.theme') }}</h2>
<ul
ref="themeList"
class="theme-list"
>
<button
class="button-default theme-preview"
data-theme-key="stock"
:class="{ toggled: isStyleActive('stock'), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="resetTheming"
>
- <!-- eslint-disable vue/no-v-text-v-html-on-component -->
- <!-- eslint-disable vue/no-v-html -->
- <component
- :is="'style'"
- v-html="previewTheme('stock', 'v3')"
- />
- <!-- eslint-enable vue/no-v-html -->
- <!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview id="theme-preview-stock" />
<h4 class="theme-name">
{{ $t('settings.style.stock_theme_used') }}
<span class="alert neutral version">v3</span>
</h4>
</button>
<button
v-if="isCustomThemeUsed"
disabled
class="button-default theme-preview toggled"
>
<preview />
<h4 class="theme-name">
{{ $t('settings.style.custom_theme_used') }}
<span class="alert neutral version">v2</span>
</h4>
</button>
<button
v-if="isCustomStyleUsed"
disabled
class="button-default theme-preview toggled"
>
<preview />
<h4 class="theme-name">
{{ $t('settings.style.custom_style_used') }}
<span class="alert neutral version">v3</span>
</h4>
</button>
<button
v-for="style in availableStyles"
:key="style.key"
:data-theme-key="style.key"
class="button-default theme-preview"
:class="{ toggled: isThemeActive(style.key), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="style.version === 'v2' ? setTheme(style.key) : setStyle(style.key)"
>
- <!-- eslint-disable vue/no-v-text-v-html-on-component -->
- <!-- eslint-disable vue/no-v-html -->
- <div v-if="style.ready || noIntersectionObserver">
- <component
- :is="'style'"
- v-html="previewTheme(style.key, style.version, style.data)"
- />
- </div>
- <!-- eslint-enable vue/no-v-html -->
- <!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview :id="'theme-preview-' + style.key" />
<h4 class="theme-name">
{{ style.name }}
<span class="alert neutral version">{{ style.version }}</span>
</h4>
</button>
</ul>
<div class="import-file-container">
<button
class="btn button-default"
:class="{ disabled: switchInProgress }"
:disabled="switchInProgress"
@click="importFile"
>
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
</div>
<div class="setting-item">
<h2>{{ $t('settings.style.themes3.palette.label') }}</h2>
<div
v-if="customThemeVersion === 'v3'"
class="palettes-container"
>
<h4 v-if="stylePalettes?.length > 0">
{{ $t('settings.style.themes3.palette.style') }}
</h4>
<div class="palettes">
<button
v-for="p in stylePalettes || []"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="() => setPalette(p.key, p)"
>
<div class="palette-label">
<label>
{{ p.name ?? $t('settings.style.themes3.palette.user') }}
</label>
</div>
<div class="palette-preview">
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</div>
</button>
<h4>{{ $t('settings.style.themes3.palette.bundled') }}</h4>
<button
v-for="p in bundledPalettes"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key), disabled: switchInProgress }"
:disabled="switchInProgress"
@click="() => setPalette(p.key, p)"
>
<div class="palette-label">
<label>
{{ p.name }}
</label>
</div>
<div class="palette-preview">
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</div>
</button>
</div>
</div>
<div>
<template v-if="customThemeVersion === 'v3'">
<h4 v-if="expertLevel > 0">
{{ $t('settings.style.themes3.palette.user') }}
</h4>
<PaletteEditor
v-if="expertLevel > 0"
v-model="userPalette"
class="userPalette"
:compact="true"
:apply="true"
:disabled="switchInProgress"
@apply-palette="data => setPaletteCustom(data)"
/>
</template>
<template v-else-if="customThemeVersion === 'v2'">
<div class="alert neutral theme-notice unsupported-theme-v2">
{{ $t('settings.style.themes3.palette.v2_unsupported') }}
</div>
</template>
</div>
</div>
</div>
<div class="setting-item">
<h2>{{ $t('settings.scale_and_layout') }}</h2>
<div class="alert neutral theme-notice">
{{ $t("settings.style.appearance_tab_note") }}
</div>
<ul class="setting-list">
<li>
<UnitSetting
path="textSize"
:step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 14, 'rem': 1 }"
timed-apply-mode
>
{{ $t('settings.text_size') }}
</UnitSetting>
<div>
<small>
<i18n-t
scope="global"
keypath="settings.text_size_tip"
tag="span"
>
<code>px</code>
<code>rem</code>
</i18n-t>
<br>
<i18n-t
scope="global"
keypath="settings.text_size_tip2"
tag="span"
>
<code>14px</code>
</i18n-t>
</small>
</div>
</li>
<li>
<UnitSetting
path="emojiSize"
:step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 32, 'rem': 2.2 }"
>
{{ $t('settings.emoji_size') }}
</UnitSetting>
<ul
class="setting-list suboptions"
>
<li>
<FloatSetting
v-if="user"
path="emojiReactionsScale"
expert="1"
>
{{ $t('settings.emoji_reactions_scale') }}
</FloatSetting>
</li>
</ul>
</li>
<li>
<UnitSetting
path="navbarSize"
:step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 55, 'rem': 3.5 }"
>
{{ $t('settings.navbar_size') }}
</UnitSetting>
</li>
<h3>{{ $t('settings.style.interface_font_user_override') }}</h3>
<li>
<FontControl
:model-value="mergedConfig.theme3hacks.fonts.interface"
name="ui"
:label="$t('settings.style.fonts.components.interface')"
:fallback="{ family: 'sans-serif' }"
no-inherit="1"
@update:model-value="v => updateFont('interface', v)"
/>
</li>
<li>
<FontControl
v-if="expertLevel > 0"
:model-value="mergedConfig.theme3hacks.fonts.input"
name="input"
:fallback="{ family: 'inherit' }"
:label="$t('settings.style.fonts.components.input')"
@update:model-value="v => updateFont('input', v)"
/>
</li>
<li>
<FontControl
v-if="expertLevel > 0"
:model-value="mergedConfig.theme3hacks.fonts.post"
name="post"
:fallback="{ family: 'inherit' }"
:label="$t('settings.style.fonts.components.post')"
@update:model-value="v => updateFont('post', v)"
/>
</li>
<li>
<FontControl
v-if="expertLevel > 0"
:model-value="mergedConfig.theme3hacks.fonts.monospace"
name="postCode"
:fallback="{ family: 'monospace' }"
:label="$t('settings.style.fonts.components.monospace')"
@update:model-value="v => updateFont('monospace', v)"
/>
</li>
<h3>{{ $t('settings.columns') }}</h3>
<li>
<UnitSetting
path="panelHeaderSize"
:step="0.1"
:units="['px', 'rem']"
:reset-default="{ 'px': 52, 'rem': 3.2 }"
timed-apply-mode
>
{{ $t('settings.panel_header_size') }}
</UnitSetting>
</li>
<li>
<BooleanSetting path="sidebarRight">
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="navbarColumnStretch">
{{ $t('settings.navbar_column_stretch') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
v-if="user"
id="thirdColumnMode"
path="thirdColumnMode"
:options="thirdColumnModeOptions"
>
{{ $t('settings.third_column_mode') }}
</ChoiceSetting>
</li>
<li v-if="expertLevel > 0">
{{ $t('settings.column_sizes') }}
<div class="column-settings">
<UnitSetting
v-for="column in columns"
:key="column"
:path="column + 'ColumnWidth'"
:units="horizontalUnits"
expert="1"
>
{{ $t('settings.column_sizes_' + column) }}
</UnitSetting>
</div>
</li>
<li>
<BooleanSetting path="disableStickyHeaders">
{{ $t('settings.disable_sticky_headers') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="showScrollbars">
{{ $t('settings.show_scrollbars') }}
</BooleanSetting>
</li>
<li>
<UnitSetting
path="themeEditorMinWidth"
:units="['px', 'rem']"
expert="1"
>
{{ $t('settings.theme_editor_min_width') }}
</UnitSetting>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.visual_tweaks') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting path="modalMobileCenter">
{{ $t('settings.mobile_center_dialog') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
id="forcedRoundness"
path="forcedRoundness"
:options="forcedRoundnessOptions"
>
{{ $t('settings.style.themes3.hacks.force_interface_roundness') }}
</ChoiceSetting>
</li>
<li>
<ChoiceSetting
id="underlayOverride"
path="theme3hacks.underlay"
:options="underlayOverrideModes"
>
{{ $t('settings.style.themes3.hacks.underlay_overrides') }}
</ChoiceSetting>
</li>
<li v-if="instanceWallpaperUsed">
<BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="forceThemeRecompilation"
:expert="1"
>
{{ $t('settings.force_theme_recompilation_debug') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="themeDebug"
:expert="1"
>
{{ $t('settings.theme_debug') }}
</BooleanSetting>
</li>
</ul>
</div>
</div>
</template>
<script src="./appearance_tab.js"></script>
<style lang="scss" src="./appearance_tab.scss"></style>
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index efdfc5894b..d260a5a5a7 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -1,668 +1,668 @@
import {
remove,
slice,
each,
findIndex,
find,
maxBy,
minBy,
merge,
first,
last,
isArray,
omitBy
} from 'lodash'
import apiService from '../services/api/api.service.js'
import { useInterfaceStore } from 'src/stores/interface'
const emptyTl = (userId = 0) => ({
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minId: 0,
minVisibleId: 0,
loading: false,
followers: [],
friends: [],
userId,
flushMarker: 0
})
export const defaultState = () => ({
allStatuses: [],
scrobblesNextFetch: {},
allStatusesObject: {},
conversationsObject: {},
maxId: 0,
favorites: new Set(),
pleromaScrobblesAvailable: true, // not reported in nodeinfo
timelines: {
mentions: emptyTl(),
public: emptyTl(),
user: emptyTl(),
favorites: emptyTl(),
media: emptyTl(),
publicAndExternal: emptyTl(),
friends: emptyTl(),
tag: emptyTl(),
dms: emptyTl(),
bookmarks: emptyTl(),
list: emptyTl(),
bubble: emptyTl()
}
})
export const prepareStatus = (status) => {
// Set deleted flag
status.deleted = false
// To make the array reactive
status.attachments = status.attachments || []
return status
}
const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
// We ignore null values to avoid overwriting existing properties with missing data
// we also skip 'user' because that is handled by users module
merge(oldItem, omitBy(item, (v, k) => v === null || k === 'user'))
// Reactivity fix.
oldItem.attachments.splice(oldItem.attachments.length)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
prepareStatus(item)
arr.push(item)
obj[item.id] = item
return { item, new: true }
}
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
const sortTimeline = (timeline) => {
timeline.visibleStatuses = timeline.visibleStatuses.sort(sortById)
timeline.statuses = timeline.statuses.sort(sortById)
timeline.minVisibleId = (last(timeline.visibleStatuses) || {}).id
return timeline
}
const getLatestScrobble = (state, user) => {
- const scrobbles = state.pleromaScrobblesAvailable
- if (!scrobbles) return
+ const scrobblesSupport = state.pleromaScrobblesAvailable
+ if (!scrobblesSupport) return
if (state.scrobblesNextFetch[user.id] && state.scrobblesNextFetch[user.id] > Date.now()) {
return
}
state.scrobblesNextFetch[user.id] = Date.now() + 24 * 60 * 60 * 1000
- if (!scrobbles) return
+ if (!scrobblesSupport) return
apiService.fetchScrobbles({ accountId: user.id }).then((scrobbles) => {
- if (scrobbles?.error?.status === 501) {
+ if (scrobbles?.error) {
state.pleromaScrobblesAvailable = false
return
}
if (scrobbles.length > 0) {
user.latestScrobble = scrobbles[0]
state.scrobblesNextFetch[user.id] = Date.now() + 60 * 1000
}
}).catch(e => {
console.warn('cannot fetch scrobbles', e)
})
}
// Add status to the global storages (arrays and objects maintaining statuses) except timelines
const addStatusToGlobalStorage = (state, data) => {
getLatestScrobble(state, data.user)
const result = mergeOrAdd(state.allStatuses, state.allStatusesObject, data)
if (result.new) {
// Add to conversation
const status = result.item
const conversationsObject = state.conversationsObject
const conversationId = status.statusnet_conversation_id
if (conversationsObject[conversationId]) {
conversationsObject[conversationId].push(status)
} else {
conversationsObject[conversationId] = [status]
}
}
return result
}
const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId, pagination = {} }) => {
// Sanity check
if (!isArray(statuses)) {
return false
}
const allStatuses = state.allStatuses
const timelineObject = state.timelines[timeline]
// Mismatch between API pagination and our internal minId/maxId tracking systems:
// pagination.maxId is the oldest of the returned statuses when fetching older,
// and pagination.minId is the newest when fetching newer. The names come directly
// from the arguments they're supposed to be passed as for the next fetch.
const minNew = pagination.maxId || (statuses.length > 0 ? minBy(statuses, 'id').id : 0)
const maxNew = pagination.minId || (statuses.length > 0 ? maxBy(statuses, 'id').id : 0)
const newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0
const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0
if (!noIdUpdate && newer) {
timelineObject.maxId = maxNew
}
if (!noIdUpdate && older) {
timelineObject.minId = minNew
}
// This makes sure that user timeline won't get data meant for other
// user. I.e. opening different user profiles makes request which could
// return data late after user already viewing different user profile
if ((timeline === 'user' || timeline === 'media') && timelineObject.userId !== userId) {
return
}
const addStatus = (data, showImmediately, addToTimeline = true) => {
const result = addStatusToGlobalStorage(state, data)
const status = result.item
if (result.new) {
// We are mentioned in a post
if (status.type === 'status' && find(status.attentions, { id: user.id })) {
const mentions = state.timelines.mentions
// Add the mention to the mentions timeline
if (timelineObject !== mentions) {
mergeOrAdd(mentions.statuses, mentions.statusesObject, status)
mentions.newStatusCount += 1
sortTimeline(mentions)
}
}
if (status.visibility === 'direct') {
const dms = state.timelines.dms
mergeOrAdd(dms.statuses, dms.statusesObject, status)
dms.newStatusCount += 1
sortTimeline(dms)
}
}
// Decide if we should treat the status as new for this timeline.
let resultForCurrentTimeline
// Some statuses should only be added to the global status repository.
if (timeline && addToTimeline) {
resultForCurrentTimeline = mergeOrAdd(timelineObject.statuses, timelineObject.statusesObject, status)
}
if (timeline && showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
mergeOrAdd(timelineObject.visibleStatuses, timelineObject.visibleStatusesObject, status)
} else if (timeline && addToTimeline && resultForCurrentTimeline.new) {
// Just change newStatuscount
timelineObject.newStatusCount += 1
}
if (status.quote) {
addStatus(status.quote, /* showImmediately = */ false, /* addToTimeline = */ false)
}
return status
}
const favoriteStatus = (favorite) => {
const status = find(allStatuses, { id: favorite.in_reply_to_status_id })
if (status) {
// This is our favorite, so the relevant bit.
if (favorite.user.id === user.id) {
status.favorited = true
} else {
status.fave_num += 1
}
}
return status
}
const processors = {
status: (status) => {
addStatus(status, showImmediately)
},
edit: (status) => {
addStatus(status, showImmediately)
},
retweet: (status) => {
// RetweetedStatuses are never shown immediately
const retweetedStatus = addStatus(status.retweeted_status, false, false)
let retweet
// If the retweeted status is already there, don't add the retweet
// to the timeline.
if (timeline && find(timelineObject.statuses, (s) => {
if (s.retweeted_status) {
return s.id === retweetedStatus.id || s.retweeted_status.id === retweetedStatus.id
} else {
return s.id === retweetedStatus.id
}
})) {
// Already have it visible (either as the original or another RT), don't add to timeline, don't show.
retweet = addStatus(status, false, false)
} else {
retweet = addStatus(status, showImmediately)
}
retweet.retweeted_status = retweetedStatus
},
favorite: (favorite) => {
// Only update if this is a new favorite.
// Ignore our own favorites because we get info about likes as response to like request
if (!state.favorites.has(favorite.id)) {
state.favorites.add(favorite.id)
favoriteStatus(favorite)
}
},
follow: () => {
// NOOP, it is known status but we don't do anything about it for now
},
default: (unknown) => {
console.warn('unknown status type')
console.warn(unknown)
}
}
each(statuses, (status) => {
const type = status.type
const processor = processors[type] || processors.default
processor(status)
})
// Keep the visible statuses sorted
if (timeline && !(timeline === 'bookmarks')) {
sortTimeline(timelineObject)
}
}
const removeStatus = (state, { timeline, userId }) => {
const timelineObject = state.timelines[timeline]
if (userId) {
remove(timelineObject.statuses, { user: { id: userId } })
remove(timelineObject.visibleStatuses, { user: { id: userId } })
timelineObject.minVisibleId = timelineObject.visibleStatuses.length > 0 ? last(timelineObject.visibleStatuses).id : 0
timelineObject.maxId = timelineObject.statuses.length > 0 ? first(timelineObject.statuses).id : 0
}
}
export const mutations = {
addNewStatuses,
removeStatus,
showNewStatuses (state, { timeline }) {
const oldTimeline = (state.timelines[timeline])
oldTimeline.newStatusCount = 0
oldTimeline.visibleStatuses = slice(oldTimeline.statuses, 0, 50)
oldTimeline.minVisibleId = last(oldTimeline.visibleStatuses).id
oldTimeline.minId = oldTimeline.minVisibleId
oldTimeline.visibleStatusesObject = {}
each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status })
},
resetStatuses (state) {
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
state[key] = value
})
},
clearTimeline (state, { timeline, excludeUserId = false }) {
const userId = excludeUserId ? state.timelines[timeline].userId : undefined
state.timelines[timeline] = emptyTl(userId)
},
setFavorited (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus.favorited !== value) {
if (value) {
newStatus.fave_num++
} else {
newStatus.fave_num--
}
}
newStatus.favorited = value
},
setFavoritedConfirm (state, { status, user }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.favorited = status.favorited
newStatus.fave_num = status.fave_num
const index = findIndex(newStatus.favoritedBy, { id: user.id })
if (index !== -1 && !newStatus.favorited) {
newStatus.favoritedBy.splice(index, 1)
} else if (index === -1 && newStatus.favorited) {
newStatus.favoritedBy.push(user)
}
},
setMutedStatus (state, status) {
const newStatus = state.allStatusesObject[status.id]
newStatus.thread_muted = status.thread_muted
if (newStatus.thread_muted !== undefined) {
state.conversationsObject[newStatus.statusnet_conversation_id].forEach(status => { status.thread_muted = newStatus.thread_muted })
}
},
setRetweeted (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value
},
setRetweetedConfirm (state, { status, user }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.repeated = status.repeated
newStatus.repeat_num = status.repeat_num
const index = findIndex(newStatus.rebloggedBy, { id: user.id })
if (index !== -1 && !newStatus.repeated) {
newStatus.rebloggedBy.splice(index, 1)
} else if (index === -1 && newStatus.repeated) {
newStatus.rebloggedBy.push(user)
}
},
setBookmarked (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = value
newStatus.bookmark_folder_id = status.bookmark_folder_id
},
setBookmarkedConfirm (state, { status }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = status.bookmarked
if (status.pleroma) newStatus.bookmark_folder_id = status.pleroma.bookmark_folder
},
setDeleted (state, { status }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus) newStatus.deleted = true
},
setManyDeleted (state, condition) {
Object.values(state.allStatusesObject).forEach(status => {
if (condition(status)) {
status.deleted = true
}
})
},
setLoading (state, { timeline, value }) {
state.timelines[timeline].loading = value
},
setNsfw (state, { id, nsfw }) {
const newStatus = state.allStatusesObject[id]
newStatus.nsfw = nsfw
},
queueFlush (state, { timeline, id }) {
state.timelines[timeline].flushMarker = id
},
queueFlushAll (state) {
Object.keys(state.timelines).forEach((timeline) => {
state.timelines[timeline].flushMarker = state.timelines[timeline].maxId
})
},
addRepeats (state, { id, rebloggedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _)
// repeats stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.repeat_num = newStatus.rebloggedBy.length
newStatus.repeated = !!newStatus.rebloggedBy.find(({ id }) => currentUser.id === id)
},
addFavs (state, { id, favoritedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
newStatus.favoritedBy = favoritedByUsers.filter(_ => _)
// favorites stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.fave_num = newStatus.favoritedBy.length
newStatus.favorited = !!newStatus.favoritedBy.find(({ id }) => currentUser.id === id)
},
addEmojiReactionsBy (state, { id, emojiReactions }) {
const status = state.allStatusesObject[id]
status.emoji_reactions = emojiReactions
},
addOwnReaction (state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
const reaction = status.emoji_reactions[reactionIndex] || { name: emoji, count: 0, accounts: [] }
const newReaction = {
...reaction,
count: reaction.count + 1,
me: true,
accounts: [
...reaction.accounts,
currentUser
]
}
// Update count of existing reaction if it exists, otherwise append at the end
if (reactionIndex >= 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = [...status.emoji_reactions, newReaction]
}
},
removeOwnReaction (state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
if (reactionIndex < 0) return
const reaction = status.emoji_reactions[reactionIndex]
const accounts = reaction.accounts || []
const newReaction = {
...reaction,
count: reaction.count - 1,
me: false,
accounts: accounts.filter(acc => acc.id !== currentUser.id)
}
if (newReaction.count > 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = status.emoji_reactions.filter(r => r.name !== emoji)
}
},
updateStatusWithPoll (state, { id, poll }) {
const status = state.allStatusesObject[id]
status.poll = poll
},
setVirtualHeight (state, { statusId, height }) {
state.allStatusesObject[statusId].virtualHeight = height
}
}
const statuses = {
state: defaultState(),
actions: {
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId, pagination }) {
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId, pagination })
},
fetchStatus ({ rootState, dispatch }, id) {
return rootState.api.backendInteractor.fetchStatus({ id })
.then((status) => dispatch('addNewStatuses', { statuses: [status] }))
},
fetchStatusSource ({ rootState }, status) {
return apiService.fetchStatusSource({ id: status.id, credentials: rootState.users.currentUser.credentials })
},
fetchStatusHistory (_, status) {
return apiService.fetchStatusHistory({ status })
},
deleteStatus ({ rootState, commit }, status) {
apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials })
.then(() => {
commit('setDeleted', { status })
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'status.delete_error',
messageArgs: [e.message],
timeout: 5000
})
})
},
deleteStatusById ({ rootState, commit }, id) {
const status = rootState.statuses.allStatusesObject[id]
commit('setDeleted', { status })
},
markStatusesAsDeleted ({ commit }, condition) {
commit('setManyDeleted', condition)
},
favorite ({ rootState, commit }, status) {
// Optimistic favoriting...
commit('setFavorited', { status, value: true })
rootState.api.backendInteractor.favorite({ id: status.id })
.then(status => commit('setFavoritedConfirm', { status, user: rootState.users.currentUser }))
},
unfavorite ({ rootState, commit }, status) {
// Optimistic unfavoriting...
commit('setFavorited', { status, value: false })
rootState.api.backendInteractor.unfavorite({ id: status.id })
.then(status => commit('setFavoritedConfirm', { status, user: rootState.users.currentUser }))
},
fetchPinnedStatuses ({ rootState, dispatch }, userId) {
rootState.api.backendInteractor.fetchPinnedStatuses({ id: userId })
.then(statuses => dispatch('addNewStatuses', { statuses, timeline: 'user', userId, showImmediately: true, noIdUpdate: true }))
},
pinStatus ({ rootState, dispatch }, statusId) {
return rootState.api.backendInteractor.pinOwnStatus({ id: statusId })
.then((status) => dispatch('addNewStatuses', { statuses: [status] }))
},
unpinStatus ({ rootState, dispatch }, statusId) {
rootState.api.backendInteractor.unpinOwnStatus({ id: statusId })
.then((status) => dispatch('addNewStatuses', { statuses: [status] }))
},
muteConversation ({ rootState, commit }, { id: statusId }) {
return rootState.api.backendInteractor.muteConversation({ id: statusId })
.then((status) => commit('setMutedStatus', status))
},
unmuteConversation ({ rootState, commit }, { id: statusId }) {
return rootState.api.backendInteractor.unmuteConversation({ id: statusId })
.then((status) => commit('setMutedStatus', status))
},
retweet ({ rootState, commit }, status) {
// Optimistic retweeting...
commit('setRetweeted', { status, value: true })
rootState.api.backendInteractor.retweet({ id: status.id })
.then(status => commit('setRetweetedConfirm', { status: status.retweeted_status, user: rootState.users.currentUser }))
},
unretweet ({ rootState, commit }, status) {
// Optimistic unretweeting...
commit('setRetweeted', { status, value: false })
rootState.api.backendInteractor.unretweet({ id: status.id })
.then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser }))
},
bookmark ({ rootState, commit }, status) {
commit('setBookmarked', { status, value: true })
rootState.api.backendInteractor.bookmarkStatus({ id: status.id, folder_id: status.bookmark_folder_id })
.then(status => {
commit('setBookmarkedConfirm', { status })
})
},
unbookmark ({ rootState, commit }, status) {
commit('setBookmarked', { status, value: false })
rootState.api.backendInteractor.unbookmarkStatus({ id: status.id })
.then(status => {
commit('setBookmarkedConfirm', { status })
})
},
queueFlush ({ commit }, { timeline, id }) {
commit('queueFlush', { timeline, id })
},
queueFlushAll ({ commit }) {
commit('queueFlushAll')
},
fetchFavsAndRepeats ({ rootState, commit }, id) {
Promise.all([
rootState.api.backendInteractor.fetchFavoritedByUsers({ id }),
rootState.api.backendInteractor.fetchRebloggedByUsers({ id })
]).then(([favoritedByUsers, rebloggedByUsers]) => {
commit('addFavs', { id, favoritedByUsers, currentUser: rootState.users.currentUser })
commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser })
})
},
reactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('addOwnReaction', { id, emoji, currentUser })
rootState.api.backendInteractor.reactWithEmoji({ id, emoji }).then(
() => {
dispatch('fetchEmojiReactionsBy', id)
}
)
},
unreactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('removeOwnReaction', { id, emoji, currentUser })
rootState.api.backendInteractor.unreactWithEmoji({ id, emoji }).then(
() => {
dispatch('fetchEmojiReactionsBy', id)
}
)
},
fetchEmojiReactionsBy ({ rootState, commit }, id) {
return rootState.api.backendInteractor.fetchEmojiReactions({ id }).then(
emojiReactions => {
commit('addEmojiReactionsBy', { id, emojiReactions, currentUser: rootState.users.currentUser })
}
)
},
fetchFavs ({ rootState, commit }, id) {
rootState.api.backendInteractor.fetchFavoritedByUsers({ id })
.then(favoritedByUsers => commit('addFavs', { id, favoritedByUsers, currentUser: rootState.users.currentUser }))
},
fetchRepeats ({ rootState, commit }, id) {
rootState.api.backendInteractor.fetchRebloggedByUsers({ id })
.then(rebloggedByUsers => commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser }))
},
search (store, { q, resolve, limit, offset, following, type }) {
return store.rootState.api.backendInteractor.search2({ q, resolve, limit, offset, following, type })
.then((data) => {
store.commit('addNewUsers', data.accounts)
store.commit('addNewUsers', data.statuses.map(s => s.user).filter(u => u))
store.commit('addNewStatuses', { statuses: data.statuses })
return data
})
},
setVirtualHeight ({ commit }, { statusId, height }) {
commit('setVirtualHeight', { statusId, height })
}
},
mutations
}
export default statuses
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index 9af9732260..04c0d637c7 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -1,107 +1,110 @@
import { useInterfaceStore } from 'src/stores/interface.js'
import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js'
// For using include_types when fetching notifications.
// Note: chat_mention excluded as pleroma-fe polls them separately
const mastoApiNotificationTypes = [
'mention',
'status',
'favourite',
'reblog',
'follow',
'follow_request',
'move',
'poll',
'pleroma:emoji_reaction',
- 'pleroma:chat_mention',
'pleroma:report'
]
const update = ({ store, notifications, older }) => {
store.dispatch('addNewNotifications', { notifications, older })
}
const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
const args = { credentials }
const { getters } = store
const rootState = store.rootState || store.state
const timelineData = rootState.notifications
const hideMutedPosts = getters.mergedConfig.hideMutedPosts
+ if (store.rootState.instance.pleromaChatMessagesAvailable) {
+ mastoApiNotificationTypes.push('pleroma:chat_mention')
+ }
+
args.includeTypes = mastoApiNotificationTypes
args.withMuted = !hideMutedPosts
args.timeline = 'notifications'
if (older) {
if (timelineData.minId !== Number.POSITIVE_INFINITY) {
args.until = timelineData.minId
}
return fetchNotifications({ store, args, older })
} else {
// fetch new notifications
if (since === undefined && timelineData.maxId !== Number.POSITIVE_INFINITY) {
args.since = timelineData.maxId
} else if (since !== null) {
args.since = since
}
const result = fetchNotifications({ store, args, older })
// If there's any unread notifications, try fetch notifications since
// the newest read notification to check if any of the unread notifs
// have changed their 'seen' state (marked as read in another session), so
// we can update the state in this session to mark them as read as well.
// The normal maxId-check does not tell if older notifications have changed
const notifications = timelineData.data
const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id)
const unreadNotifsIds = notifications.filter(n => !n.seen).map(n => n.id)
if (readNotifsIds.length > 0 && readNotifsIds.length > 0) {
const minId = Math.min(...unreadNotifsIds) // Oldest known unread notification
if (minId !== Infinity) {
args.since = false // Don't use since_id since it sorta conflicts with min_id
args.minId = minId - 1 // go beyond
fetchNotifications({ store, args, older })
}
}
return result
}
}
const fetchNotifications = ({ store, args, older }) => {
return apiService.fetchTimeline(args)
.then((response) => {
if (response.errors) {
throw new Error(`${response.status} ${response.statusText}`)
}
const notifications = response.data
update({ store, notifications, older })
return notifications
})
.catch((error) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'notifications.error',
messageArgs: [error.message],
timeout: 5000
})
console.error(error)
})
}
const startFetching = ({ credentials, store }) => {
// Initially there's set flag to silence all desktop notifications so
// that there won't spam of them when user just opened up the FE we
// reset that flag after a while to show new notifications once again.
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
boundFetchAndUpdate()
return promiseInterval(boundFetchAndUpdate, 10000)
}
const notificationsFetcher = {
fetchAndUpdate,
startFetching
}
export default notificationsFetcher
diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js
index a986e98ddb..8c5edc9edf 100644
--- a/src/services/sw/sw.js
+++ b/src/services/sw/sw.js
@@ -1,156 +1,157 @@
/* global process */
function urlBase64ToUint8Array (base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
}
export function isSWSupported () {
return 'serviceWorker' in navigator
}
function isPushSupported () {
return 'PushManager' in window
}
function getOrCreateServiceWorker () {
+ if (!isSWSupported()) return
const swType = process.env.HAS_MODULE_SERVICE_WORKER ? 'module' : 'classic'
return navigator.serviceWorker.register('/sw-pleroma.js', { type: swType })
.catch((err) => console.error('Unable to get or create a service worker.', err))
}
function subscribePush (registration, isEnabled, vapidPublicKey) {
if (!isEnabled) return Promise.reject(new Error('Web Push is disabled in config'))
if (!vapidPublicKey) return Promise.reject(new Error('VAPID public key is not found'))
const subscribeOptions = {
userVisibleOnly: false,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
}
return registration.pushManager.subscribe(subscribeOptions)
}
function unsubscribePush (registration) {
return registration.pushManager.getSubscription()
.then((subscription) => {
if (subscription === null) { return }
return subscription.unsubscribe()
})
}
function deleteSubscriptionFromBackEnd (token) {
return fetch('/api/v1/push/subscription/', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
}).then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response
})
}
function sendSubscriptionToBackEnd (subscription, token, notificationVisibility) {
return window.fetch('/api/v1/push/subscription/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
subscription,
data: {
alerts: {
follow: notificationVisibility.follows,
favourite: notificationVisibility.likes,
mention: notificationVisibility.mentions,
reblog: notificationVisibility.repeats,
move: notificationVisibility.moves
}
}
})
}).then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response.json()
}).then((responseData) => {
if (!responseData.id) throw new Error('Bad response from server.')
return responseData
})
}
export async function initServiceWorker (store) {
if (!isSWSupported()) return
await getOrCreateServiceWorker()
navigator.serviceWorker.addEventListener('message', (event) => {
const { dispatch } = store
const { type, ...rest } = event.data
switch (type) {
case 'notificationClicked':
dispatch('notificationClicked', { id: rest.id })
}
})
}
export async function showDesktopNotification (content) {
if (!isSWSupported) return
const { active: sw } = (await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
sw.postMessage({ type: 'desktopNotification', content })
}
export async function closeDesktopNotification ({ id }) {
if (!isSWSupported) return
const { active: sw } = (await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
if (id >= 0) {
sw.postMessage({ type: 'desktopNotificationClose', content: { id } })
} else {
sw.postMessage({ type: 'desktopNotificationClose', content: { all: true } })
}
}
export async function updateFocus () {
if (!isSWSupported) return
const { active: sw } = (await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
sw.postMessage({ type: 'updateFocus' })
}
export function registerPushNotifications (isEnabled, vapidPublicKey, token, notificationVisibility) {
if (isPushSupported()) {
getOrCreateServiceWorker()
.then((registration) => subscribePush(registration, isEnabled, vapidPublicKey))
.then((subscription) => sendSubscriptionToBackEnd(subscription, token, notificationVisibility))
.catch((e) => console.warn(`Failed to setup Web Push Notifications: ${e.message}`))
}
}
export function unregisterPushNotifications (token) {
if (isPushSupported()) {
Promise.all([
deleteSubscriptionFromBackEnd(token),
getOrCreateServiceWorker()
.then((registration) => {
return unsubscribePush(registration).then((result) => [registration, result])
})
.then(([, unsubResult]) => {
if (!unsubResult) {
console.warn('Push subscription cancellation wasn\'t successful')
}
})
]).catch((e) => console.warn(`Failed to disable Web Push Notifications: ${e.message}`))
}
}
export const shouldCache = process.env.NODE_ENV === 'production'
export const cacheKey = 'pleroma-fe'
export const emojiCacheKey = 'pleroma-fe-emoji'
export const clearCache = (key) => caches.delete(key)
export { getOrCreateServiceWorker }

File Metadata

Mime Type
text/x-diff
Expires
Fri, Oct 30, 6:51 AM (23 h, 26 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1799720
Default Alt Text
(61 KB)

Event Timeline