Merge remote-tracking branch 'origin/develop' into users-statuses-pinia

This commit is contained in:
Henry Jameson 2026-08-12 15:57:42 +03:00
commit 657f405ee0
27 changed files with 167 additions and 20 deletions

View file

@ -0,0 +1 @@
fixed tapping "Mute..." and "Change visiblity" (admin action) in extra status actions closing the dropdown

View file

@ -0,0 +1 @@
domain mute fixedw

View file

@ -0,0 +1 @@
Fix followers list showing followed users as non-followed

View file

@ -0,0 +1 @@
fix moderation tools button possibly not appearing for admins

View file

@ -0,0 +1 @@
Fixed moderation actions requiring confirmation closing user popover

View file

@ -0,0 +1 @@
Fixed server-side domain mutes rendering/api calls

View file

@ -0,0 +1 @@
Fix theme lists failing to load when custom resource indexes are unavailable

View file

@ -0,0 +1 @@
Fix Theme 2 fonts falling back to serif after upgrade

View file

@ -0,0 +1 @@
Fix Themes 2.0 applying incorrect fonts

1
changelog.d/themes3.fix Normal file
View file

@ -0,0 +1 @@
Fixed themes 3 not loading in appearance tab

View file

@ -0,0 +1 @@
Fixed status index approxmiation in timeline rendering for dynamically changing viewport geometries

View file

@ -67,6 +67,7 @@ export const promisedRequest = async ({
url, url,
payload, payload,
formData, formData,
forceContentType,
cache, cache,
credentials, credentials,
headers = {}, headers = {},
@ -75,7 +76,7 @@ export const promisedRequest = async ({
method, method,
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
Accept: 'application/json', Accept: forceContentType ?? 'application/json',
...headers, ...headers,
}, },
} }
@ -110,7 +111,7 @@ export const promisedRequest = async ({
) )
if (contentLength === 0) return null if (contentLength === 0) return null
switch (contentType) { switch (forceContentType ?? contentType) {
case 'text/plain': case 'text/plain':
return await response.text() return await response.text()
case 'application/json': case 'application/json':

View file

@ -117,9 +117,17 @@ export const fetchUserByName = ({ name, credentials }) =>
export const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => export const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) =>
promisedRequest({ promisedRequest({
url: MASTODON_FOLLOWING_URL(id, { maxId, sinceId, limit }), url: MASTODON_FOLLOWING_URL(id, {
maxId,
sinceId,
limit,
withRelationships: true,
}),
credentials, credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) })) }).then(({ data, ...rest }) => ({
...rest,
data: data.map(parseUser),
}))
export const fetchFollowers = ({ export const fetchFollowers = ({
id, id,
@ -136,7 +144,10 @@ export const fetchFollowers = ({
withRelationships: true, withRelationships: true,
}), }),
credentials, credentials,
}).then(({ data, ...rest }) => ({ ...rest, data: data.map(parseUser) })) }).then(({ data, ...rest }) => ({
...rest,
data: data.map(parseUser),
}))
export const fetchConversation = ({ id, credentials }) => export const fetchConversation = ({ id, credentials }) =>
promisedRequest({ promisedRequest({

View file

@ -410,7 +410,6 @@ export const exportFriends = ({ id, credentials }) => {
id, id,
maxId, maxId,
credentials, credentials,
withRelationships: true,
}) })
friends = [...friends, ...users] friends = [...friends, ...users]
if (users.length === 0) { if (users.length === 0) {

View file

@ -517,8 +517,7 @@ const ModerationTools = {
setOpen(value) { setOpen(value) {
this.open = value this.open = value
}, },
maybeShowConfirm(close, { group, name, action, value }) { maybeShowConfirm({ group, name, action, value }) {
close()
this.confirmDialogName = name this.confirmDialogName = name
this.confirmDialogGroup = group this.confirmDialogGroup = group
this.confirmDialogAction = () => action() this.confirmDialogAction = () => action()

View file

@ -9,7 +9,7 @@
@show="setOpen(true)" @show="setOpen(true)"
@close="setOpen(false)" @close="setOpen(false)"
> >
<template #content="{close}"> <template #content>
<div class="dropdown-menu"> <div class="dropdown-menu">
<template v-for="(entry, index) in entries"> <template v-for="(entry, index) in entries">
<div <div
@ -26,7 +26,7 @@
> >
<button <button
class="main-button" class="main-button"
@click="() => maybeShowConfirm(close, entry)" @click="() => maybeShowConfirm(entry)"
> >
<span <span
v-if="entry.checkbox" v-if="entry.checkbox"

View file

@ -78,8 +78,8 @@
v-model="expiryUnit" v-model="expiryUnit"
unstyled="true" unstyled="true"
class="expiry-unit" class="expiry-unit"
@change="expiryAmountChange"
:aria-label="$t('polls.expiry_unit')" :aria-label="$t('polls.expiry_unit')"
@change="expiryAmountChange"
> >
<option <option
v-for="unit in expiryUnits" v-for="unit in expiryUnits"

View file

@ -580,9 +580,12 @@ const PostStatusForm = {
}), }),
}, },
watch: { watch: {
isDirty(newVal, oldVal) { newStatus: {
deep: true,
handler() {
this.statusChanged() this.statusChanged()
}, },
},
saveable(val) { saveable(val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes // https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
// MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed // MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed

View file

@ -153,7 +153,6 @@
</div> </div>
</template> </template>
<template #item="{item}"> <template #item="{item}">
{{ item }}
<DomainMuteCard :domain="item" /> <DomainMuteCard :domain="item" />
</template> </template>
<template #empty> <template #empty>

View file

@ -169,7 +169,7 @@ export default {
setTimeout(() => { setTimeout(() => {
this.animationState = false this.animationState = false
}, 500) }, 500)
close() if (!this.button.dropdown) close()
} }
}, },
}, },

View file

@ -213,7 +213,10 @@ const Timeline = {
// Start from approximating the index of some visible status by using the // Start from approximating the index of some visible status by using the
// the center of the screen on the timeline. // the center of the screen on the timeline.
let approxIndex = Math.floor(statuses.length * (centerOfScreen / height)) let approxIndex = Math.min(
Math.floor(statuses.length * (centerOfScreen / height)),
statuses.length - 1,
)
let err = statuses[approxIndex].getBoundingClientRect().y let err = statuses[approxIndex].getBoundingClientRect().y
// if we have a previous scroll index that can be used, test if it's // if we have a previous scroll index that can be used, test if it's

View file

@ -214,6 +214,7 @@ const api = {
if (state.mastoUserSocketStatus !== WSConnectionStatus.ERROR) { if (state.mastoUserSocketStatus !== WSConnectionStatus.ERROR) {
dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications') dispatch('startFetchingNotifications')
useChatsStore().startFetchingChats()
useInterfaceStore().pushGlobalNotice({ useInterfaceStore().pushGlobalNotice({
level: 'error', level: 'error',
messageKey: 'timeline.socket_broke', messageKey: 'timeline.socket_broke',
@ -235,6 +236,7 @@ const api = {
stopMastoUserSocket({ state, dispatch }) { stopMastoUserSocket({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications') dispatch('startFetchingNotifications')
useChatsStore().startFetchingChats()
state.mastoUserSocket.close() state.mastoUserSocket.close()
}, },

View file

@ -92,6 +92,11 @@ export const adoptStyleSheets = throttle(() => {
const EAGER_STYLE_ID = 'pleroma-eager-styles' const EAGER_STYLE_ID = 'pleroma-eager-styles'
const LAZY_STYLE_ID = 'pleroma-lazy-styles' const LAZY_STYLE_ID = 'pleroma-lazy-styles'
export const hasInvalidCachedThemeRules = (data) =>
data
.flat()
.some((rule) => /--(?:mono)?font:\s*\[object Object\](?:;|$)/i.test(rule))
const generateTheme = (inputRuleset, callbacks, debug) => { const generateTheme = (inputRuleset, callbacks, debug) => {
const { const {
onNewRule = () => { onNewRule = () => {
@ -151,7 +156,8 @@ export const tryLoadCache = async () => {
if ( if (
cache.engineChecksum === getEngineChecksum() && cache.engineChecksum === getEngineChecksum() &&
cache.checksum !== undefined && cache.checksum !== undefined &&
cache.checksum === useMergedConfigStore().mergedConfig.themeChecksum cache.checksum === useMergedConfigStore().mergedConfig.themeChecksum &&
!hasInvalidCachedThemeRules(cache.data)
) { ) {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10) const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20) const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
@ -307,7 +313,9 @@ export const applyStyleConfig = (input) => {
adoptStyleSheets() adoptStyleSheets()
} }
export const getResourcesIndex = async (url, parser = (x) => x) => { const noop = (x) => x
export const getResourcesIndex = async (url, parser = noop) => {
const cache = 'no-store' const cache = 'no-store'
const customUrl = url.replace(/\.(\w+)$/, '.custom.$1') const customUrl = url.replace(/\.(\w+)$/, '.custom.$1')
let builtin let builtin
@ -324,6 +332,7 @@ export const getResourcesIndex = async (url, parser = (x) => x) => {
promisedRequest({ promisedRequest({
url: v, url: v,
cache, cache,
forceContentType: parser === noop ? null : 'text/plain',
}) })
.then(({ data: text }) => parser(text)) .then(({ data: text }) => parser(text))
.catch((e) => { .catch((e) => {
@ -339,7 +348,11 @@ export const getResourcesIndex = async (url, parser = (x) => x) => {
} }
try { try {
const { data: builtinData } = await promisedRequest({ url, cache }) const { data: builtinData } = await promisedRequest({
url,
cache,
forceContentType: 'application/json',
})
builtin = resourceTransform(builtinData) builtin = resourceTransform(builtinData)
} catch { } catch {
builtin = [] builtin = []
@ -350,6 +363,7 @@ export const getResourcesIndex = async (url, parser = (x) => x) => {
const { data: customData } = await promisedRequest({ const { data: customData } = await promisedRequest({
url: customUrl, url: customUrl,
cache, cache,
forceContentType: 'application/json',
}) })
custom = resourceTransform(customData) custom = resourceTransform(customData)
} catch { } catch {

View file

@ -266,7 +266,7 @@ export const convertTheme2To3 = (data) => {
Object.keys(data.fonts || {}).forEach((key) => { Object.keys(data.fonts || {}).forEach((key) => {
if (!fontsKeys.has(key)) return if (!fontsKeys.has(key)) return
if (!data.fonts[key]) return if (!data.fonts[key]) return
const originalFont = data.fonts[key] const originalFont = data.fonts[key].family
const rule = { source: '2to3' } const rule = { source: '2to3' }
switch (key) { switch (key) {

View file

@ -75,6 +75,11 @@ export const useNotificationsStore = defineStore('notifications', {
return true return true
}) })
commit(
'addNewUsers',
validNotifications.map((notification) => notification.from_profile),
)
const statusNotifications = validNotifications.filter( const statusNotifications = validNotifications.filter(
(notification) => (notification) =>
isStatusNotification(notification.type) && notification.status, isStatusNotification(notification.type) && notification.status,

View file

@ -0,0 +1,58 @@
import {
getResourcesIndex,
hasInvalidCachedThemeRules,
} from 'src/services/style_setter/style_setter.js'
describe('resource index', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('ignores an HTML fallback returned for a missing custom index', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ builtin: { version: 1 } }), {
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response('<!doctype html><title>Pleroma</title>', {
headers: { 'Content-Type': 'text/html' },
}),
),
)
const resources = await getResourcesIndex('/static/styles.json')
expect(Object.keys(resources)).to.deep.equal(['builtin'])
expect(resources.builtin()).to.deep.equal({ version: 1 })
})
})
describe('style setter cache', () => {
it('rejects cached rules containing serialized objects', () => {
expect(
hasInvalidCachedThemeRules([
['html { --font: [object Object]; }'],
['.post { --font: sans-serif; }'],
]),
).to.equal(true)
})
it('accepts cached rules containing valid font families', () => {
expect(
hasInvalidCachedThemeRules([
['html { --font: sans-serif; }'],
[
'.post { --font: "Atkinson Hyperlegible"; }',
'.post::after { content: "[object Object]"; }',
],
]),
).to.equal(false)
})
})

View file

@ -0,0 +1,42 @@
import {
basePaletteKeys,
convertTheme2To3,
} from 'src/services/theme_data/theme2_to_theme3.js'
describe('Theme 2 to Theme 3 conversion', () => {
it('converts font descriptors to CSS font families', () => {
const colors = Object.fromEntries(
[...basePaletteKeys].map((key) => [key, '#000000']),
)
const rules = convertTheme2To3({
colors,
fonts: {
interface: { family: 'sans-serif' },
input: { family: 'Open Sans' },
post: { family: 'Atkinson Hyperlegible' },
postCode: { family: 'monospace' },
},
})
expect(rules).to.deep.include({
source: '2to3',
component: 'Root',
directives: { '--font': 'generic | sans-serif' },
})
expect(rules).to.deep.include({
source: '2to3',
component: 'Root',
directives: { '--monoFont': 'generic | monospace' },
})
expect(rules).to.deep.include({
source: '2to3',
component: 'Input',
directives: { '--font': 'generic | Open Sans' },
})
expect(rules).to.deep.include({
source: '2to3',
component: 'RichContent',
directives: { '--font': 'generic | Atkinson Hyperlegible' },
})
})
})