Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85628898
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
71 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/api/helpers.js b/src/api/helpers.js
index 22ba41ecb1..90956a4dc8 100644
--- a/src/api/helpers.js
+++ b/src/api/helpers.js
@@ -1,142 +1,138 @@
import { snakeCase } from 'lodash'
import { StatusCodeError } from 'src/services/errors/errors'
export const paramsString = (params = {}) => {
if (params == null || params === undefined) return ''
if (typeof params !== 'object' || Array.isArray(params)) {
throw new TypeError('Params are not an object!')
}
const entries = (() => {
if (params instanceof Map) {
return [...params.entries()]
} else {
return Object.entries(params)
}
})()
const arrays = []
const nonArrays = []
entries.forEach(([k, v]) => {
if (v == null) return // Drop nulls
if (
(typeof v === 'object' && !Array.isArray(v)) ||
typeof v === 'function'
) {
throw new TypeError('Param cannot be non-primitive!')
}
if (Array.isArray(v)) {
arrays.push([k, v])
} else {
nonArrays.push([k, v])
}
})
arrays.forEach(([k, array]) => {
array.forEach((v) => {
- if (
- typeof v === 'object' ||
- typeof v === 'function' ||
- v === undefined
- )
+ if (typeof v === 'object' || typeof v === 'function' || v === undefined)
throw new TypeError('Array param cannot contain non-primitives!')
})
})
if (nonArrays.length + arrays.length === 0) return ''
return (
'?' +
[
...nonArrays.map(([k, v]) => [snakeCase(k), v]),
// turning [a,[1,2,3]] into [[a[],1],[a[],2],[a[],3]]
...arrays.reduce(
(acc, [k, arrayValue]) => [
...acc,
...arrayValue.map((v) => [snakeCase(k) + '[]', v]),
],
[],
),
]
.map(([k, v]) => `${k}=${window.encodeURIComponent(v)}`)
.join('&')
)
}
export const promisedRequest = async ({
method,
url,
payload,
formData,
cache,
credentials,
headers = {},
}) => {
const options = {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...headers,
},
}
if (!formData) {
options.headers['Content-Type'] = 'application/json'
}
if (cache) {
options.cache = cache
}
if (formData || payload) {
options.body = formData || JSON.stringify(payload)
}
if (credentials) {
options.headers = {
...options.headers,
...authHeaders(credentials),
}
}
const response = await fetch(url, options)
const data = await (async () => {
const [contentType] = response.headers
.get('content-type')
.split(';')
.map((x) => x.toLowerCase().trim())
const contentLength = Number.parseInt(
response.headers.get('content-length'),
)
if (contentLength === 0) return null
switch (contentType) {
case 'text/plain':
return await response.text()
case 'application/json':
return await response.json()
default:
return await response.bytes()
}
})()
const { ok, status } = response
if (ok) {
return { response, status, data }
} else {
throw new StatusCodeError(response.status, data, { url, options }, response)
}
}
const authHeaders = (accessToken) => {
if (accessToken) {
return { Authorization: `Bearer ${accessToken}` }
} else {
return {}
}
}
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index f05af6f36d..d27551c3c3 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,636 +1,635 @@
import { clone, filter, findIndex, get, reduce } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { fetchConversation, fetchStatus } from 'src/api/public.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
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 idA < idB ? -1 : 1
}
}
const sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) =>
status.type === 'retweet' ||
status.id !== statusoid.retweeted_status.id,
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter(Boolean).sort(sortById)
}
const conversation = {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
collapsable: {
// Whether conversation can be collapsed
// i.e. when it's not a page
type: Boolean,
default: false,
},
isPage: {
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
type: Boolean,
default: false,
},
pinnedStatusIdsObject: {
// Used for user profile, map of pinned statuses
type: Object,
default: null,
},
inProfile: {
// Whether conversation is rendered in a user profile
// used for overriding muted status
type: Boolean,
default: false,
},
profileUserId: {
// used with inProfile, user id of the profile
type: String,
default: null,
},
virtualHidden: {
// Whether conversation is suspended. Controls rendering of statuses
type: Boolean,
default: false,
},
},
data() {
return {
focused: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
inlineDivePosition: null,
loadStatusError: null,
unsuspendibleIds: new Set(),
}
},
created() {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
maxDepthToShowByDefault() {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
const maxDepth = this.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
displayStyle() {
return this.mergedConfig.conversationDisplay
},
treeViewIsSimple() {
return !this.mergedConfig.conversationTreeAdvanced
},
isTreeView() {
return this.displayStyle === 'tree'
},
isLinearView() {
return this.displayStyle !== 'tree'
},
shouldFadeAncestors() {
return this.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition() {
return this.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus() {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus() {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable() {
return this.unsuspendibleIds.size > 0
},
hideStatus() {
return this.virtualHidden && this.suspendable
},
status() {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
originalStatusId() {
if (this.status.retweeted_status) {
return this.status.retweeted_status.id
} else {
return this.statusId
}
},
conversationId() {
return this.getConversationId(this.statusId)
},
conversation() {
if (!this.status) {
return []
}
if (!this.isExpanded) {
return [this.status]
}
const conversation = clone(
this.$store.state.statuses.conversationsObject[this.conversationId],
)
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
},
statusMap() {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree() {
const reverseLookupTable = this.conversation.reduce(
(table, status, index) => {
table[status.id] = index
return table
},
{},
)
const threads = this.conversation.reduce(
(a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id).map((s) => s.id)
return a
},
{
forest: {},
},
)
const walk = (forest, topLevel, depth = 0, processed = {}) =>
topLevel
.map((id) => {
if (processed[id]) {
return []
}
processed[id] = true
return [
{
status: this.conversation[reverseLookupTable[id]],
id,
depth,
},
walk(forest, forest[id], depth + 1, processed),
].flat()
})
.flat()
const linearized = walk(
threads.forest,
this.topLevel.map((k) => k.id),
)
return linearized
},
replyIds() {
return this.conversation
.map((k) => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map((k) => k.id)
return res
}, {})
},
totalReplyCount() {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeSizeFor(cid))
.reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map((k) => k.id).map(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth() {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeDepthFor(cid))
.reduce((a, b) => (a > b ? a : b), 0)
return depths[id]
}
this.conversation.map((k) => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths() {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel() {
const topLevel = this.conversation.reduce(
(tl, cur) =>
- tl.filter(
- (k) =>
- this.getReplies(cur.id)
- .map((v) => v.id)
- .includes(k.id),
+ tl.filter((k) =>
+ this.getReplies(cur.id)
+ .map((v) => v.id)
+ .includes(k.id),
),
this.conversation,
)
return topLevel
},
otherTopLevelCount() {
return this.topLevel.length - 1
},
showingTopLevel() {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot() {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth() {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode() {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton() {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return (
this.isTreeView &&
this.isExpanded &&
this.diveMode &&
this.topLevel.length > 1
)
},
shouldShowAncestors() {
return (
this.isTreeView &&
this.isExpanded &&
this.ancestorsOf(this.diveRoot).length
)
},
replies() {
let i = 1
return reduce(
this.conversation,
(result, { id, in_reply_to_status_id: irid }) => {
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id,
})
}
i++
return result
},
{},
)
},
isExpanded() {
return !!(this.expanded || this.isPage)
},
hiddenStyle() {
const height = this.status?.virtualHeight || '120px'
return this.virtualHidden ? { height } : {}
},
threadDisplayStatus() {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
const status = (() => {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if (depth - this.diveDepth <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
}
})()
a[id] = status
return a
}, {})
},
canDive() {
return this.isTreeView && this.isExpanded
},
maybeFocused() {
return this.isExpanded ? this.focused : null
},
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
}),
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
PostStatusForm,
RichContent,
},
watch: {
statusId(newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
this.setFocused(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded(value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden() {
this.$store.dispatch('setVirtualHeight', {
statusId: this.statusId,
height: `${this.$el.clientHeight}px`,
})
},
},
methods: {
fetchConversation() {
if (this.status) {
fetchConversation({
id: this.statusId,
credentials: useOAuthStore().token,
}).then(({ data: { ancestors, descendants } }) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
this.setFocused(this.originalStatusId)
})
} else {
this.loadStatusError = null
fetchStatus({
id: this.statusId,
credentials: useOAuthStore().token,
})
.then(({ data: status }) => {
this.$store.dispatch('addNewStatuses', { statuses: [status] })
this.fetchConversation()
})
.catch((error) => {
console.error(error)
this.loadStatusError = error
})
}
},
getReplies(id) {
return this.replies[id] || []
},
setFocused(id) {
if (!id) return
this.focused = id
if (!this.streamingEnabled) {
this.$store.dispatch('fetchStatus', id)
}
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
toggleExpanded() {
this.expanded = !this.expanded
},
getConversationId(statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
setThreadDisplay(id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus,
}
},
toggleThreadDisplay(id) {
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively(id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id)
.map((k) => k.id)
.map((id) => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively(id) {
this.setThreadDisplayRecursively(id, 'showing')
},
leastVisibleAncestor(id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
// if the parent is showing it means cur is visible
if (this.threadDisplayStatus[parent] === 'showing') {
return cur
}
parent = this.parentOf(parent)
cur = this.parentOf(cur)
}
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined
},
diveIntoStatus(id) {
this.tryScrollTo(id)
},
diveToTopLevel() {
this.tryScrollTo(
this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id,
)
},
// only used when we are not on a page
undive() {
this.inlineDivePosition = null
this.setFocused(this.statusId)
},
tryScrollTo(id) {
if (!id) {
return
}
if (this.isPage) {
// set statusId
this.$router.push({ name: 'conversation', params: { id } })
} else {
this.inlineDivePosition = id
}
// Because the conversation can be unmounted when out of sight
// and mounted again when it comes into sight,
// the `mounted` or `created` function in `status` should not
// contain scrolling calls, as we do not want the page to jump
// when we scroll with an expanded conversation.
//
// Now the method is to rely solely on the `focused` watcher
// in `status` components.
// In linear views, all statuses are rendered at all times, but
// in tree views, it is possible that a change in active status
// removes and adds status components (e.g. an originally child
// status becomes an ancestor status, and thus they will be
// different).
// Here, let the components be rendered first, in order to trigger
// the `focused` watcher.
this.$nextTick(() => {
this.setFocused(id)
})
},
goToCurrent() {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById(id) {
return this.statusMap[id]
},
parentOf(id) {
const status = this.statusById(id)
if (!status) {
return undefined
}
const { in_reply_to_status_id: parentId } = status
if (!this.statusMap[parentId]) {
return undefined
}
return parentId
},
parentOrSelf(id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf(id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
ancestors.unshift(this.statusMap[cur])
cur = this.parentOf(cur)
}
return ancestors
},
topLevelAncestorOrSelfId(id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
cur = this.parentOf(cur)
parent = this.parentOf(parent)
}
return cur
},
resetDisplayState() {
this.undive()
this.threadDisplayStatusObject = {}
},
onStatusSuspendStateChange({ id, suspend }) {
if (!suspend) {
this.unsuspendibleIds.add(id)
} else {
this.unsuspendibleIds.delete(id)
}
},
onPosted(data) {
if (this.isPage) {
this.$router.push({ name: 'conversation', params: { id: data.id } })
}
},
},
}
export default conversation
diff --git a/src/components/settings_modal/helpers/unit_setting.js b/src/components/settings_modal/helpers/unit_setting.js
index f537eab85c..3be6b7a104 100644
--- a/src/components/settings_modal/helpers/unit_setting.js
+++ b/src/components/settings_modal/helpers/unit_setting.js
@@ -1,84 +1,87 @@
import Select from 'src/components/select/select.vue'
import Setting from './setting.js'
export const allCssUnits = [
'cm',
'mm',
'in',
'px',
'pt',
'pc',
'em',
'ex',
'ch',
'rem',
'vw',
'vh',
'vmin',
'vmax',
'%',
]
export const defaultHorizontalUnits = ['px', 'rem', 'vw']
export const defaultVerticalUnits = ['px', 'rem', 'vh']
export default {
...Setting,
components: {
...Setting.components,
Select,
},
props: {
...Setting.props,
min: Number,
units: {
type: Array,
default: () => allCssUnits,
},
unitSet: {
type: String,
default: 'none',
},
step: {
type: Number,
default: 1,
},
resetDefault: {
type: Object,
default: null,
},
},
computed: {
...Setting.computed,
stateUnit() {
return typeof this.state === 'string'
? this.state.replace(/[0-9,.]+/, '')
: ''
},
stateValue() {
return typeof this.state === 'string'
? this.state.replace(/[^0-9,.]+/, '')
: ''
},
},
methods: {
...Setting.methods,
getUnitString(value) {
if (this.unitSet === 'none') return value
return this.$t(['settings', 'units', this.unitSet, value].join('.'))
},
updateValue(e) {
- this.configSink(this.path, Number.parseFloat(e.target.value) + this.stateUnit)
+ this.configSink(
+ this.path,
+ Number.parseFloat(e.target.value) + this.stateUnit,
+ )
},
updateUnit(e) {
let value = this.stateValue
const newUnit = e.target.value
if (this.resetDefault) {
const replaceValue = this.resetDefault[newUnit]
if (replaceValue != null) {
value = replaceValue
}
}
this.configSink(this.path, value + newUnit)
},
},
}
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 107b183c2b..9e443bf8bd 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -1,205 +1,204 @@
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
import { muteFilterHits } from '../status_parser/status_parser.js'
import FaviconService from 'src/services/favicon_service/favicon_service.js'
export const ACTIONABLE_NOTIFICATION_TYPES = new Set([
'mention',
'pleroma:report',
'follow_request',
])
let cachedBadgeUrl = null
export const notificationsFromStore = (store) => store.state.notifications.data
const visibleTypes = (notificationVisibility) => {
return [
notificationVisibility.likes && 'like',
notificationVisibility.mentions && 'mention',
notificationVisibility.statuses && 'status',
notificationVisibility.repeats && 'repeat',
notificationVisibility.follows && 'follow',
notificationVisibility.followRequest && 'follow_request',
notificationVisibility.moves && 'move',
notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
notificationVisibility.reports && 'pleroma:report',
notificationVisibility.polls && 'poll',
].filter(Boolean)
}
const statusNotifications = new Set([
'like',
'mention',
'status',
'repeat',
'pleroma:emoji_reaction',
'poll',
])
export const isStatusNotification = (type) => statusNotifications.has(type)
export const isValidNotification = (notification) => {
if (isStatusNotification(notification.type) && !notification.status) {
return false
}
return 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 isMutedNotification = (muteFilters, notification) => {
if (!notification.status) return false
if (notification.status.muted) return true
return muteFilterHits(muteFilters, notification.status).length > 0
}
export const maybeShowNotification = (
store,
notificationVisibility,
muteFilters,
notification,
i18n,
) => {
const rootState = store.rootState || store.state
if (notification.seen) return
if (!visibleTypes(notificationVisibility).includes(notification.type)) return
if (
notification.type === 'mention' &&
isMutedNotification(muteFilters, notification)
)
return
const notificationObject = prepareNotificationObject(notification, i18n)
showDesktopNotification(rootState, notificationObject)
}
export const filteredNotificationsFromStore = (
store,
notificationVisibility,
types,
) => {
// map is just to clone the array since sort mutates it and it causes some issues
- const sortedNotifications = notificationsFromStore(store)
- .sort(sortById)
+ const sortedNotifications = notificationsFromStore(store).sort(sortById)
// TODO implement sorting elsewhere and make it optional
return sortedNotifications.filter((notification) =>
(types || visibleTypes(notificationVisibility)).includes(notification.type),
)
}
export const unseenNotificationsFromStore = (
store,
notificationVisibility,
ignoreInactionableSeen,
) => {
return filteredNotificationsFromStore(store, notificationVisibility).filter(
({ seen, type }) => {
if (!ignoreInactionableSeen) return !seen
if (seen) return false
return ACTIONABLE_NOTIFICATION_TYPES.has(type)
},
)
}
export const prepareNotificationObject = (notification, i18n) => {
if (cachedBadgeUrl === null) {
const favicons = FaviconService.getOriginalFavicons()
const favicon = favicons[favicons.length - 1]
if (!favicon) {
cachedBadgeUrl = 'about:blank'
} else {
cachedBadgeUrl = favicon.favimg.src
}
}
const notifObj = {
tag: notification.id,
type: notification.type,
badge: cachedBadgeUrl,
}
const status = notification.status
const title = notification.from_profile.name
notifObj.title = title
notifObj.icon = notification.from_profile.profile_image_url
let i18nString
switch (notification.type) {
case 'like':
i18nString = 'favorited_you'
break
case 'status':
i18nString = 'subscribed_status'
break
case 'repeat':
i18nString = 'repeated_you'
break
case 'follow':
i18nString = 'followed_you'
break
case 'move':
i18nString = 'migrated_to'
break
case 'follow_request':
i18nString = 'follow_request'
break
case 'pleroma:report':
i18nString = 'submitted_report'
break
case 'poll':
i18nString = 'poll_ended'
break
}
if (notification.type === 'pleroma:emoji_reaction') {
notifObj.body = i18n.t('notifications.reacted_with', [notification.emoji])
} else if (i18nString) {
notifObj.body = i18n.t('notifications.' + i18nString)
} else if (isStatusNotification(notification.type)) {
notifObj.body = notification.status.text
}
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
if (!status.nsfw && status?.attachments?.[0]?.mimetype.startsWith('image/')) {
notifObj.image = status.attachments[0].url
}
return notifObj
}
export const countExtraNotifications = (
store,
mergedConfig,
unreadChatsCount,
unreadAnnouncementCount,
) => {
const rootGetters = store.rootGetters || store.getters
if (!mergedConfig.showExtraNotifications) {
return 0
}
return [
mergedConfig.showChatsInExtraNotifications ? unreadChatsCount : 0,
mergedConfig.showAnnouncementsInExtraNotifications
? unreadAnnouncementCount
: 0,
mergedConfig.showFollowRequestsInExtraNotifications
? rootGetters.followRequestCount
: 0,
].reduce((a, c) => a + c, 0)
}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index a6f22e50b1..318978264e 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,365 +1,367 @@
import sum from 'hash-sum'
import localforage from 'localforage'
import { chunk, throttle } from 'lodash'
import { getCssRules } from '../theme_data/css_utils.js'
import { getEngineChecksum, init } from '../theme_data/theme_data_3.service.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { promisedRequest } from 'src/api/helpers.js'
import { ROOT_CONFIG } from 'src/modules/default_config_state.js'
// On platforms where this is not supported, it will return undefined
// Otherwise it will return an array
const supportsAdoptedStyleSheets = !!document.adoptedStyleSheets
const stylesheets = {}
export const createStyleSheet = (id, priority = 1000) => {
if (stylesheets[id]) return stylesheets[id]
const newStyleSheet = {
rules: [],
ready: false,
priority,
clear() {
this.rules = []
},
addRule(rule) {
let newRule = rule
if (!CSS.supports?.('backdrop-filter', 'blur()')) {
newRule = newRule.replaceAll(/backdrop-filter:[^;]+;/g, '') // Remove backdrop-filter
}
if (newRule.startsWith('::-webkit')) {
if (typeof CSS.supports !== 'function') return
// firefox doesn't like invalid selectors
const fullWebkitScrollbarSupport =
CSS.supports('selector(::-webkit-scrollbar)') &&
CSS.supports('selector(::-webkit-scrollbar-button)') &&
CSS.supports('selector(::-webkit-resizer)') &&
CSS.supports('selector(::-webkit-scrollbar-thumb)')
if (!fullWebkitScrollbarSupport) return
}
this.rules.push(
newRule.replaceAll(/var\(--shadowFilter\)[^;]*;/g, ''), // Remove shadowFilter references
)
},
}
stylesheets[id] = newStyleSheet
return newStyleSheet
}
export const adoptStyleSheets = throttle(() => {
if (supportsAdoptedStyleSheets) {
document.adoptedStyleSheets = Object.values(stylesheets)
.filter((x) => x.ready)
.sort((a, b) => a.priority - b.priority)
.map((sheet) => {
const css = new CSSStyleSheet()
sheet.rules.forEach((r) => {
try {
css.insertRule(r)
} catch (e) {
console.warn('Error inserting rule:', e, r)
}
})
return css
})
} else {
const holder = document.getElementById('custom-styles-holder')
for (let i = holder.sheet.cssRules.length - 1; i >= 0; --i) {
holder.sheet.deleteRule(i)
}
Object.values(stylesheets)
.filter((x) => x.ready)
.sort((a, b) => a.priority - b.priority)
.forEach((sheet) => {
sheet.rules.forEach((r) => holder.sheet.insertRule(r))
})
}
// Some older browsers do not support document.adoptedStyleSheets.
// In this case, we use the <style> elements.
// Since the <style> elements we need are already in the DOM, there
// is nothing to do here.
}, 500)
const EAGER_STYLE_ID = 'pleroma-eager-styles'
const LAZY_STYLE_ID = 'pleroma-lazy-styles'
const generateTheme = (inputRuleset, callbacks, debug) => {
const {
onNewRule = () => {
/* no-op */
},
onLazyFinished = () => {
/* no-op */
},
onEagerFinished = () => {
/* no-op */
},
} = callbacks
const themes3 = init({
inputRuleset,
debug,
})
getCssRules(themes3.eager, debug).forEach((rule) => {
// Hacks to support multiple selectors on same component
onNewRule(rule, false)
})
onEagerFinished()
// Optimization - instead of processing all lazy rules in one go, process them in small chunks
// so that UI can do other things and be somewhat responsive while less important rules are being
// processed
let counter = 0
const chunks = chunk(themes3.lazy, 200)
// let t0 = performance.now()
const processChunk = () => {
const chunk = chunks[counter]
Promise.all(chunk.map((x) => x())).then((result) => {
getCssRules(result.filter(Boolean), debug).forEach((rule) => {
onNewRule(rule, true)
})
// const t1 = performance.now()
// console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms')
// t0 = t1
counter += 1
if (counter < chunks.length) {
setTimeout(processChunk, 0)
} else {
onLazyFinished()
}
})
}
return { lazyProcessFunc: processChunk }
}
export const tryLoadCache = async () => {
console.info('Trying to load compiled theme data from cache')
const cache = await localforage.getItem('pleromafe-theme-cache')
if (!cache) return null
try {
if (
cache.engineChecksum === getEngineChecksum() &&
cache.checksum !== undefined &&
cache.checksum === useMergedConfigStore().mergedConfig.themeChecksum
) {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
cache.data[0].forEach((rule) => eagerStyles.addRule(rule))
cache.data[1].forEach((rule) => lazyStyles.addRule(rule))
eagerStyles.ready = true
lazyStyles.ready = true
console.info(`Loaded theme from cache`)
return true
} else {
console.warn("Checksums don't match, cache not usable, clearing")
localStorage.removeItem('pleroma-fe-theme-cache')
}
} catch (e) {
console.error('Failed to load theme cache:', e)
return false
}
}
export const applyTheme = (
input,
onEagerFinish = () => {
/* no-op */
},
onFinish = () => {
/* no-op */
},
debug,
) => {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
eagerStyles.clear()
lazyStyles.clear()
const { lazyProcessFunc } = generateTheme(
input,
{
onNewRule(rule, isLazy) {
if (isLazy) {
lazyStyles.addRule(rule)
} else {
eagerStyles.addRule(rule)
}
},
onEagerFinished() {
eagerStyles.ready = true
adoptStyleSheets()
onEagerFinish()
console.info(
'Eager part of theme finished, waiting for lazy part to finish to store cache',
)
},
onLazyFinished() {
lazyStyles.ready = true
adoptStyleSheets()
const data = [eagerStyles.rules, lazyStyles.rules]
const checksum = sum(data)
const cache = {
checksum,
engineChecksum: getEngineChecksum(),
data,
}
useSyncConfigStore().setSimplePrefAndSave({
path: 'themeChecksum',
value: checksum,
})
onFinish(cache)
localforage.setItem('pleromafe-theme-cache', cache)
console.info('Theme cache stored')
},
},
debug,
)
setTimeout(lazyProcessFunc, 0)
}
const extractStyleConfig = ({
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
themeEditorMinWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
forcedRoundness,
}) => {
const result = {
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
themeEditorMinWidth:
Number.parseInt(themeEditorMinWidth) === 0
? 'fit-content'
: themeEditorMinWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
}
switch (forcedRoundness) {
case 'disable':
break
case '0':
result.forcedRoundness = '0'
break
case '1':
result.forcedRoundness = '1px'
break
case '2':
result.forcedRoundness = '0.4rem'
break
default:
}
return result
}
const defaultStyleConfig = extractStyleConfig(ROOT_CONFIG)
export const applyStyleConfig = (input) => {
const config = extractStyleConfig(input)
if (config === defaultStyleConfig) {
return
}
const rules = Object.entries(config)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}: ${v}`)
.join(';')
const styleSheet = createStyleSheet('theme-holder', 30)
styleSheet.clear()
styleSheet.addRule(`:root { ${rules} }`)
// TODO find a way to make this not apply to theme previews
if (Object.hasOwn(config, 'forcedRoundness')) {
styleSheet.addRule(` *:not(.preview-block) {
--roundness: var(--forcedRoundness) !important;
}`)
}
styleSheet.ready = true
adoptStyleSheets()
}
export const getResourcesIndex = async (url, parser = (x) => x) => {
const cache = 'no-store'
const customUrl = url.replace(/\.(\w+)$/, '.custom.$1')
let builtin
let custom
const resourceTransform = (resources) => {
return Object.entries(resources).map(([k, v]) => {
if (typeof v === 'object') {
return [k, () => v]
} else if (typeof v === 'string') {
return [
k,
() =>
promisedRequest({
url: v,
cache,
})
.then(({ data: text }) => parser(text))
.catch((e) => {
console.error(e)
return null
}),
]
} else {
console.error(`Unknown resource format - ${k} is a ${typeof v}`)
return [k, null]
}
})
}
try {
const { data: builtinData } = await promisedRequest({ url, cache })
builtin = resourceTransform(builtinData)
} catch {
builtin = []
console.warn(`Builtin resources at ${url} unavailable`)
}
try {
const { data: customData } = await promisedRequest({
url: customUrl,
cache,
})
custom = resourceTransform(customData)
} catch {
custom = []
console.warn(`Custom resources at ${customUrl} unavailable`)
}
const total = [...custom, ...builtin]
if (total.length === 0) {
- throw new Error(`Resource at ${url} and ${customUrl} completely unavailable. Panicking`)
+ throw new Error(
+ `Resource at ${url} and ${customUrl} completely unavailable. Panicking`,
+ )
}
return Object.fromEntries(total)
}
diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js
index 53c7b0cf2c..ef8200a4d4 100644
--- a/src/services/sw/sw.js
+++ b/src/services/sw/sw.js
@@ -1,189 +1,187 @@
/* global process */
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding)
.replaceAll('-', '+')
.replaceAll('_', '/')
const rawData = window.atob(base64)
return Uint8Array.from([...rawData].map((char) => char.codePointAt(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)
- throw new Error('Web Push is disabled in config')
- if (!vapidPublicKey)
- throw new Error('VAPID public key is not found')
+ if (!isEnabled) throw new Error('Web Push is disabled in config')
+ if (!vapidPublicKey) throw new Error('VAPID public key is not found')
const subscribeOptions = {
userVisibleOnly: false,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
}
return registration.pushManager.subscribe(subscribeOptions)
}
async function unsubscribePush(registration) {
return registration.pushManager.getSubscription().then((subscription) => {
if (subscription === null) {
return 'No subscription'
}
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()) {
getOrCreateServiceWorker()
.then((registration) => {
return unsubscribePush(registration).then((result) => [
registration,
result,
])
})
.then(([, unsubResult]) => {
if (unsubResult === 'No subscription') return
if (!unsubResult) {
console.warn("Push subscription cancellation wasn't successful")
}
return deleteSubscriptionFromBackEnd(token)
})
.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 }
diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js
index 4650622259..694e41e12f 100644
--- a/src/services/theme_data/theme_data_3.service.js
+++ b/src/services/theme_data/theme_data_3.service.js
@@ -1,780 +1,780 @@
import { brightness, convert } from 'chromatism'
import sum from 'hash-sum'
import { flattenDeep, sortBy } from 'lodash'
import {
alphaBlend,
getTextColor,
mixrgb,
relativeLuminance,
rgba2css,
} from '../color_convert/color_convert.js'
import { deserializeShadow } from './iss_deserializer.js'
import {
findRules,
genericRuleToSelector,
getAllPossibleCombinations,
normalizeCombination,
unroll,
} from './iss_utils.js'
import {
colorFunctions,
process,
shadowFunctions,
} from './theme3_slot_functions.js'
// Ensuring the order of components
const components = {
Root: null,
Text: null,
FunText: null,
Link: null,
Icon: null,
Border: null,
PanelHeader: null,
Panel: null,
Chat: null,
ChatMessage: null,
Button: null,
}
export const findShadow = (shadows, { dynamicVars, staticVars }) => {
return (shadows || []).map((shadow) => {
let targetShadow
if (typeof shadow === 'string') {
if (shadow.startsWith('$')) {
targetShadow = process(
shadow,
shadowFunctions,
{ findColor, findShadow },
{ dynamicVars, staticVars },
)
} else if (shadow.startsWith('--')) {
// modifiers are completely unsupported here
const variableSlot = shadow.substring(2)
return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
} else {
targetShadow = deserializeShadow(shadow)
}
} else {
targetShadow = shadow
}
const shadowArray = Array.isArray(targetShadow)
? targetShadow
: [targetShadow]
return shadowArray.map((s) => ({
...s,
color: findColor(s.color, { dynamicVars, staticVars }),
}))
})
}
export const findColor = (color, { dynamicVars, staticVars }) => {
try {
if (
typeof color !== 'string' ||
(!color.startsWith('--') && !color.startsWith('$'))
)
return color
let targetColor = null
if (color.startsWith('--')) {
// Modifier support is pretty much for v2 themes only
const [variable, modifier] = color.split(/,/g).map((str) => str.trim())
const variableSlot = variable.substring(2)
if (variableSlot === 'stack') {
const { r, g, b } = dynamicVars.stacked
targetColor = { r, g, b }
} else if (variableSlot.startsWith('parent')) {
if (variableSlot === 'parent') {
const { r, g, b } = dynamicVars.lowerLevelBackground ?? {}
targetColor = { r, g, b }
} else {
const virtualSlot = variableSlot.replace(/^parent/, '')
targetColor = convert(
dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot],
).rgb
}
} else {
const staticVar = staticVars[variableSlot]
const dynamicVar = dynamicVars[variableSlot]
if (!staticVar && !dynamicVar) {
console.warn(
`Couldn't find variable "${variableSlot}", falling back to magenta. Variables are:
Static:
${JSON.stringify(staticVars, null, 2)}
Dynamic:
${JSON.stringify(dynamicVars, null, 2)}`,
dynamicVars,
variableSlot,
dynamicVars[variableSlot],
)
}
targetColor = convert(staticVar ?? dynamicVar ?? '#FF00FF').rgb
}
if (modifier) {
const effectiveBackground =
dynamicVars.lowerLevelBackground ?? targetColor
const isLightOnDark =
relativeLuminance(convert(effectiveBackground).rgb) < 0.5
const mod = isLightOnDark ? 1 : -1
targetColor = brightness(
Number.parseFloat(modifier) * mod,
targetColor,
).rgb
}
}
if (color.startsWith('$')) {
try {
targetColor = process(
color,
colorFunctions,
{ findColor },
{ dynamicVars, staticVars },
)
} catch (e) {
console.error(
'Failure executing color function',
e,
'\n Function: ' + color,
)
targetColor = '#FF00FF'
}
}
// Color references other color
return targetColor
} catch (e) {
throw new Error(`Couldn't find color "${color}", variables are:
Static:
${JSON.stringify(staticVars, null, 2)}
Dynamic:
${JSON.stringify(dynamicVars, null, 2)}\nError: ${e}`)
}
}
const getTextColorAlpha = (
directives,
intendedTextColor,
dynamicVars,
staticVars,
) => {
const opacity = directives.textOpacity
const backgroundColor = convert(dynamicVars.lowerLevelBackground).rgb
const textColor = convert(
findColor(intendedTextColor, { dynamicVars, staticVars }),
).rgb
if (opacity === null || opacity === undefined || opacity >= 1) {
return convert(textColor).hex
}
if (opacity === 0) {
return convert(backgroundColor).hex
}
const opacityMode = directives.textOpacityMode
switch (opacityMode) {
case 'fake':
return convert(alphaBlend(textColor, opacity, backgroundColor)).hex
case 'mixrgb':
return convert(mixrgb(backgroundColor, textColor)).hex
default:
return rgba2css({ a: opacity, ...textColor })
}
}
// Loading all style.js[on] files dynamically
const componentsContext = import.meta.glob(
['/src/**/*.style.js', '/src/**/*.style.json'],
{ eager: true },
)
Object.keys(componentsContext).forEach((key) => {
const component = componentsContext[key].default
if (components[component.name] != null) {
console.warn(
`Component in file ${key} is trying to override existing component ${component.name}! You have collisions/duplicates!`,
)
}
components[component.name] = component
})
Object.keys(components).forEach((key) => {
if (key === 'Root') return
components.Root.validInnerComponents =
components.Root.validInnerComponents || []
components.Root.validInnerComponents.push(key)
})
Object.keys(components).forEach((key) => {
const component = components[key]
const { validInnerComponents = [] } = component
validInnerComponents.forEach((inner) => {
const child = components[inner]
component.possibleChildren = component.possibleChildren || []
component.possibleChildren.push(child)
child.possibleParents = child.possibleParents || []
child.possibleParents.push(component)
})
})
const engineChecksum = sum(components)
const ruleToSelector = genericRuleToSelector(components)
export const getEngineChecksum = () => engineChecksum
/**
* Initializes and compiles the theme according to the ruleset
*
* @param {Object[]} inputRuleset - set of rules to compile theme against. Acts as an override to
* component default rulesets
* @param {string} ultimateBackgroundColor - Color that will be the "final" background for
* calculating contrast ratios and making text automatically accessible. Really used for cases when
* stuff is transparent.
* @param {boolean} debug - print out debug information in console, mostly just performance stuff
* @param {boolean} liteMode - use validInnerComponentsLite instead of validInnerComponents, meant to
* generatate theme previews and such that need to be compiled faster and don't require a lot of other
* components present in "normal" mode
* @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme
* previews since states are the biggest factor for compilation time and are completely unnecessary
* when previewing multiple themes at same time
*/
export const init = ({
inputRuleset,
ultimateBackgroundColor,
debug = false,
liteMode = false,
editMode = false,
onlyNormalState = false,
initialStaticVars = {},
}) => {
const rootComponentName = 'Root'
if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
const staticVars = { ...initialStaticVars }
const stacked = {}
const computed = {}
const rulesetUnsorted = [
...Object.values(components)
.map((c) =>
(c.defaultRules || []).map((r) => ({
source: 'Built-in',
component: c.name,
...r,
})),
)
- .flat()
+ .flat(),
...inputRuleset,
].map((rule) => {
normalizeCombination(rule)
let currentParent = rule.parent
while (currentParent) {
normalizeCombination(currentParent)
currentParent = currentParent.parent
}
return rule
})
const ruleset = rulesetUnsorted
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
const parentsA = unroll(a).length
const parentsB = unroll(b).length
let aScore = 0
let bScore = 0
aScore += parentsA * 1000
bScore += parentsB * 1000
aScore += a.variant !== 'normal' ? 100 : 0
bScore += b.variant !== 'normal' ? 100 : 0
aScore += a.state.filter((x) => x !== 'normal').length * 1000
bScore += b.state.filter((x) => x !== 'normal').length * 1000
aScore += a.component === 'Text' ? 1 : 0
bScore += b.component === 'Text' ? 1 : 0
// Debug
a._specificityScore = aScore
b._specificityScore = bScore
if (aScore === bScore) {
return ai - bi
}
return aScore - bScore
})
.map(({ data }) => data)
if (!ultimateBackgroundColor) {
console.warn(
'No ultimate background color provided, falling back to panel color',
)
const rootRule = ruleset.findLast(
(x) => x.component === 'Root' && x.directives?.['--bg'],
)
ultimateBackgroundColor = rootRule.directives['--bg'].split('|')[1].trim()
}
const virtualComponents = new Set(
Object.values(components)
.filter((c) => c.virtual)
.map((c) => c.name),
)
const transparentComponents = new Set(
Object.values(components)
.filter((c) => c.transparent)
.map((c) => c.name),
)
const nonEditableComponents = new Set(
Object.values(components)
.filter((c) => c.notEditable)
.map((c) => c.name),
)
const extraCompileComponents = new Set([])
Object.values(components).forEach((component) => {
const relevantRules = ruleset.filter((r) => r.component === component.name)
const backgrounds = relevantRules
.map((r) => r.directives.background)
.filter(Boolean)
const opacities = relevantRules
.map((r) => r.directives.opacity)
.filter(Boolean)
if (
backgrounds.some((x) => x.match(/--parent/)) ||
opacities.some((x) => x != null && x < 1)
) {
extraCompileComponents.add(component.name)
}
})
const processCombination = (combination) => {
try {
const selector = ruleToSelector(combination, true)
const cssSelector = ruleToSelector(combination)
const parentSelector = selector.split(/ /g).slice(0, -1).join(' ')
const soloSelector = selector.split(/ /g).slice(-1)[0]
const lowerLevelSelector = parentSelector
let lowerLevelBackground = computed[lowerLevelSelector]?.background
if (editMode && !lowerLevelBackground) {
// FIXME hack for editor until it supports handling component backgrounds
lowerLevelBackground = '#00FFFF'
}
const lowerLevelVirtualDirectives =
computed[lowerLevelSelector]?.virtualDirectives
const lowerLevelVirtualDirectivesRaw =
computed[lowerLevelSelector]?.virtualDirectivesRaw
const dynamicVars = computed[selector] || {
lowerLevelSelector,
lowerLevelBackground,
lowerLevelVirtualDirectives,
lowerLevelVirtualDirectivesRaw,
}
// Inheriting all of the applicable rules
const existingRules = ruleset.filter(findRules(combination))
const computedDirectives = existingRules
.map((r) => r.directives)
.reduce((acc, directives) => ({ ...acc, ...directives }), {})
const computedRule = {
...combination,
directives: computedDirectives,
}
computed[selector] = computed[selector] || {}
computed[selector].computedRule = computedRule
computed[selector].dynamicVars = dynamicVars
// avoid putting more stuff into actual CSS
computed[selector].virtualDirectives = {}
// but still be able to access it i.e. from --parent
computed[selector].virtualDirectivesRaw =
computed[lowerLevelSelector]?.virtualDirectivesRaw || {}
if (virtualComponents.has(combination.component)) {
const virtualName = [
'--',
combination.component.toLowerCase(),
combination.variant === 'normal'
? ''
: combination.variant[0].toUpperCase() +
combination.variant.slice(1).toLowerCase(),
...sortBy(combination.state.filter((x) => x !== 'normal')).map(
(state) => state[0].toUpperCase() + state.slice(1).toLowerCase(),
),
].join('')
let inheritedTextColor = computedDirectives.textColor
let inheritedTextAuto = computedDirectives.textAuto
let inheritedTextOpacity = computedDirectives.textOpacity
let inheritedTextOpacityMode = computedDirectives.textOpacityMode
const lowerLevelTextSelector = [
...selector.split(/ /g).slice(0, -1),
soloSelector,
].join(' ')
const lowerLevelTextRule = computed[lowerLevelTextSelector]
if (
inheritedTextColor == null ||
inheritedTextOpacity == null ||
inheritedTextOpacityMode == null
) {
inheritedTextColor =
computedDirectives.textColor ?? lowerLevelTextRule.textColor
inheritedTextAuto =
computedDirectives.textAuto ?? lowerLevelTextRule.textAuto
inheritedTextOpacity =
computedDirectives.textOpacity ?? lowerLevelTextRule.textOpacity
inheritedTextOpacityMode =
computedDirectives.textOpacityMode ??
lowerLevelTextRule.textOpacityMode
}
const newTextRule = {
...computedRule,
directives: {
...computedRule.directives,
textColor: inheritedTextColor,
textAuto: inheritedTextAuto ?? 'preserve',
textOpacity: inheritedTextOpacity,
textOpacityMode: inheritedTextOpacityMode,
},
}
dynamicVars.inheritedBackground = lowerLevelBackground
dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb
const intendedTextColor = convert(
findColor(inheritedTextColor, { dynamicVars, staticVars }),
).rgb
const textColor =
newTextRule.directives.textAuto === 'no-auto'
? intendedTextColor
: getTextColor(
convert(stacked[lowerLevelSelector]).rgb,
intendedTextColor,
newTextRule.directives.textAuto === 'preserve',
)
const virtualDirectives = {
...(computed[lowerLevelSelector].virtualDirectives || {}),
}
const virtualDirectivesRaw = {
...(computed[lowerLevelSelector].virtualDirectivesRaw || {}),
}
// Storing color data in lower layer to use as custom css properties
virtualDirectives[virtualName] = getTextColorAlpha(
newTextRule.directives,
textColor,
dynamicVars,
)
virtualDirectivesRaw[virtualName] = textColor
computed[lowerLevelSelector].virtualDirectives = virtualDirectives
computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw
return {
dynamicVars,
selector: cssSelector.split(/ /g).slice(0, -1).join(' '),
...combination,
directives: {},
virtualDirectives,
virtualDirectivesRaw,
}
} else {
computed[selector] = computed[selector] || {}
// TODO: DEFAULT TEXT COLOR
const lowerLevelStackedBackground =
stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb
if (computedDirectives.background) {
let inheritRule = null
const variantRules = ruleset.filter(
findRules({
component: combination.component,
variant: combination.variant,
parent: combination.parent,
}),
)
const lastVariantRule = variantRules[variantRules.length - 1]
const lastVariantSelector = ruleToSelector(lastVariantRule, true)
if (lastVariantRule && lastVariantSelector !== selector) {
inheritRule = lastVariantRule
} else {
const normalRules = ruleset.filter(
findRules({
component: combination.component,
parent: combination.parent,
}),
)
const lastNormalRule = normalRules[normalRules.length - 1]
inheritRule = lastNormalRule
}
const inheritSelector = ruleToSelector(
{ ...inheritRule, parent: combination.parent },
true,
)
const inheritedBackground = computed[inheritSelector].background
dynamicVars.inheritedBackground = inheritedBackground
const rgb = convert(
findColor(computedDirectives.background, {
dynamicVars,
staticVars,
}),
).rgb
if (!stacked[selector]) {
let blend
const alpha = computedDirectives.opacity ?? 1
if (alpha >= 1) {
blend = rgb
} else if (alpha <= 0) {
blend = lowerLevelStackedBackground
} else {
blend = alphaBlend(
rgb,
computedDirectives.opacity,
lowerLevelStackedBackground,
)
}
stacked[selector] = blend
computed[selector].background = {
...rgb,
a: computedDirectives.opacity ?? 1,
}
}
}
if (computedDirectives.shadow) {
dynamicVars.shadow = flattenDeep(
findShadow(flattenDeep(computedDirectives.shadow), {
dynamicVars,
staticVars,
}),
)
}
if (!stacked[selector]) {
computedDirectives.background = 'transparent'
computedDirectives.opacity = 0
stacked[selector] = lowerLevelStackedBackground
computed[selector].background = {
...lowerLevelStackedBackground,
a: 0,
}
}
dynamicVars.stacked = stacked[selector]
dynamicVars.background = computed[selector].background
const dynamicSlots = Object.entries(computedDirectives).filter(([k]) =>
k.startsWith('--'),
)
dynamicSlots.forEach(([k, v]) => {
const [type, value] = v.split('|').map((x) => x.trim()) // woah, Extreme!
switch (type) {
case 'color': {
const color = findColor(value, { dynamicVars, staticVars })
dynamicVars[k] = color
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = color
}
break
}
case 'shadow': {
const shadow = value
.split(/,/g)
.map((s) => s.trim())
.filter(Boolean)
dynamicVars[k] = shadow
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = shadow
}
break
}
case 'generic': {
dynamicVars[k] = value
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = value
}
break
}
}
})
const rule = {
dynamicVars,
selector: cssSelector,
...combination,
directives: computedDirectives,
}
return rule
}
} catch (e) {
const { component, variant, state } = combination
throw new Error(
`Error processing combination ${component}.${variant}:${state.join(':')}: ${e}`,
)
}
}
const processInnerComponent = (component, parent) => {
const combinations = []
const { states: originalStates = {}, variants: originalVariants = {} } =
component
let validInnerComponents
if (editMode) {
const temp =
component.validInnerComponentsLite ||
component.validInnerComponents ||
[]
validInnerComponents = temp.filter(
(c) => virtualComponents.has(c) && !nonEditableComponents.has(c),
)
} else if (liteMode) {
validInnerComponents =
component.validInnerComponentsLite ||
component.validInnerComponents ||
[]
} else if (
component.name === 'Root' ||
component.states != null ||
component.background?.includes('--parent')
) {
validInnerComponents = component.validInnerComponents || []
} else {
validInnerComponents =
component.validInnerComponents?.filter(
(c) =>
virtualComponents.has(c) ||
transparentComponents.has(c) ||
extraCompileComponents.has(c),
) || []
}
// Normalizing states and variants to always include "normal"
const states = { normal: '', ...originalStates }
const variants = { normal: '', ...originalVariants }
const innerComponents = validInnerComponents.map((name) => {
const result = components[name]
if (result === undefined)
console.error(
`Component ${component.name} references a component ${name} which does not exist!`,
)
return result
})
// Optimization: we only really need combinations without "normal" because all states implicitly have it
const permutationStateKeys = Object.keys(states).filter(
(s) => s !== 'normal',
)
const stateCombinations =
onlyNormalState && !virtualComponents.has(component.name)
? [['normal']]
: [
['normal'],
...getAllPossibleCombinations(permutationStateKeys)
.map((combination) => ['normal', ...combination])
.filter((combo) => {
// Optimization: filter out some hard-coded combinations that don't make sense
if (combo.includes('disabled')) {
return !(
combo.includes('hover') ||
combo.includes('focused') ||
combo.includes('pressed')
)
}
return true
}),
]
const stateVariantCombination = Object.keys(variants)
.map((variant) => {
return stateCombinations.map((state) => ({ variant, state }))
})
.flat()
stateVariantCombination.forEach((combination) => {
combination.component = component.name
combination.lazy = component.lazy || parent?.lazy
combination.parent = parent
if (!liteMode && combination.state.includes('hover')) {
combination.lazy = true
}
if (
!liteMode &&
parent?.component !== 'Root' &&
!virtualComponents.has(component.name) &&
!transparentComponents.has(component.name) &&
extraCompileComponents.has(component.name)
) {
combination.lazy = true
}
combinations.push(combination)
innerComponents.forEach((innerComponent) => {
combinations.push(...processInnerComponent(innerComponent, combination))
})
})
return combinations
}
const t0 = performance.now()
const combinations = processInnerComponent(
components[rootComponentName] ?? components.Root,
)
const t1 = performance.now()
if (debug) {
console.debug('Tree traveral took ' + (t1 - t0) + ' ms')
}
const result = combinations
.map((combination) => {
if (combination.lazy) {
return async () => processCombination(combination)
} else {
return processCombination(combination)
}
})
.filter(Boolean)
const t2 = performance.now()
if (debug) {
console.debug('Eager processing took ' + (t2 - t1) + ' ms')
}
// optimization to traverse big-ass array only once instead of twice
const eager = []
const lazy = []
result.forEach((x) => {
if (typeof x === 'function') {
lazy.push(x)
} else {
eager.push(x)
}
})
return {
lazy,
eager,
staticVars,
engineChecksum,
themeChecksum: sum([lazy, eager]),
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Aug 8, 3:27 PM (1 d, 18 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1723652
Default Alt Text
(71 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment