Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85629754
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
137 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 4ccfa4d41d..5ba5e8d76b 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -1,622 +1,622 @@
/* global process */
import vClickOutside from 'click-outside-vue3'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import Status from 'src/components/status/status.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import { config } from '@fortawesome/fontawesome-svg-core'
import {
FontAwesomeIcon,
FontAwesomeLayers,
} from '@fortawesome/vue-fontawesome'
config.autoAddCss = false
import App from '../App.vue'
import FaviconService from '../services/favicon_service/favicon_service.js'
import { applyStyleConfig } from '../services/style_setter/style_setter.js'
import { initServiceWorker, updateFocus } from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import routes from './routes'
import { useAuthFlowStore } from 'src/stores/auth_flow'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import {
INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
INSTANCE_IDENTIY_EXTERNAL,
} from 'src/modules/default_config_state.js'
let staticInitialResults = null
const parsedInitialResults = () => {
if (!document.getElementById('initial-results')) {
return null
}
if (!staticInitialResults) {
staticInitialResults = JSON.parse(
document.getElementById('initial-results').textContent,
)
}
return staticInitialResults
}
const decodeUTF8Base64 = (data) => {
const rawData = atob(data)
const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
const text = new TextDecoder().decode(array)
return text
}
const preloadFetch = async (request) => {
const data = parsedInitialResults()
if (!data || !data[request]) {
return window.fetch(request)
}
const decoded = decodeUTF8Base64(data[request])
const requestData = JSON.parse(decoded)
return {
ok: true,
json: () => requestData,
text: () => requestData,
}
}
const getInstanceConfig = async ({ store }) => {
try {
const res = await preloadFetch('/api/v1/instance')
if (res.ok) {
const data = await res.json()
const textLimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
useInstanceCapabilitiesStore().set(
'pleromaExtensionsAvailable',
data.pleroma,
)
useInstanceStore().set({
path: 'limits.textLimit',
value: textLimit,
})
useInstanceStore().set({
path: 'accountApprovalRequired',
value: data.approval_required,
})
useInstanceStore().set({
path: 'birthdayRequired',
value: !!data.pleroma?.metadata.birthday_required,
})
useInstanceStore().set({
path: 'birthdayMinAge',
value: data.pleroma?.metadata.birthday_min_age || 0,
})
if (vapidPublicKey) {
useInstanceStore().set({
path: 'vapidPublicKey',
value: vapidPublicKey,
})
}
} else {
throw res
}
} catch (error) {
console.error('Could not load instance config, potentially fatal')
console.error(error)
}
// We should check for scrobbles support here but it requires userId
// so instead we check for it where it's fetched (statuses.js)
}
const getBackendProvidedConfig = async () => {
try {
const res = await window.fetch('/api/pleroma/frontend_configurations')
if (res.ok) {
const data = await res.json()
return data.pleroma_fe
} else {
throw res
}
} catch (error) {
console.error(
'Could not load backend-provided frontend config, potentially fatal',
)
console.error(error)
}
}
const getStaticConfig = async () => {
try {
const res = await window.fetch('/static/config.json')
if (res.ok) {
return res.json()
} else {
throw res
}
} catch (error) {
console.warn(
'Failed to load static/config.json, continuing without it.',
error,
)
return {}
}
}
const setSettings = async ({ apiConfig, staticConfig, store }) => {
const overrides = window.___pleromafe_dev_overrides || {}
const env = window.___pleromafe_mode.NODE_ENV
// This takes static config and overrides properties that are present in apiConfig
let config = {}
if (overrides.staticConfigPreference && env === 'development') {
console.warn('OVERRIDING API CONFIG WITH STATIC CONFIG')
config = Object.assign({}, apiConfig, staticConfig)
} else {
config = Object.assign({}, staticConfig, apiConfig)
}
Object.keys(INSTANCE_IDENTITY_DEFAULT_DEFINITIONS).forEach((source) => {
if (source === 'name') return
if (INSTANCE_IDENTIY_EXTERNAL.has(source)) return
useInstanceStore().set({
value:
config[source] ?? INSTANCE_IDENTITY_DEFAULT_DEFINITIONS[source].default,
path: `instanceIdentity.${source}`,
})
})
Object.keys(INSTANCE_DEFAULT_CONFIG_DEFINITIONS).forEach((source) =>
useInstanceStore().set({
value:
config[source] ?? INSTANCE_DEFAULT_CONFIG_DEFINITIONS[source].default,
path: `prefsStorage.${source}`,
}),
)
useAuthFlowStore().setInitialStrategy(config.loginMethod)
}
const getTOS = async ({ store }) => {
try {
const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) {
const html = await res.text()
useInstanceStore().set({ path: 'instanceIdentity.tos', value: html })
} else {
throw res
}
} catch (e) {
console.warn("Can't load TOS\n", e)
}
}
const getInstancePanel = async ({ store }) => {
try {
const res = await preloadFetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
useInstanceStore().set({
path: 'instanceIdentity.instanceSpecificPanelContent',
value: html,
})
} else {
throw res
}
} catch (e) {
console.warn("Can't load instance panel\n", e)
}
}
const getStickers = async ({ store }) => {
try {
const res = await window.fetch('/static/stickers.json')
if (res.ok) {
const values = await res.json()
const stickers = (
await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
let meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta,
}
}),
)
).sort((a, b) => {
return a.meta.title.localeCompare(b.meta.title)
})
useEmojiStore().setStickers(stickers)
} else {
throw res
}
} catch (e) {
console.warn("Can't load stickers\n", e)
}
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map((uri) => uri.split('/').pop())
useInstanceStore().set({
path: 'staffAccounts',
value: nicknames,
})
}
const getNodeInfo = async ({ store }) => {
try {
let res = await preloadFetch('/nodeinfo/2.1.json')
if (!res.ok) res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
useInstanceStore().set({
path: 'instanceIdentity.name',
value: metadata.nodeName,
})
useInstanceStore().set({
path: 'registrationOpen',
value: data.openRegistrations,
})
useInstanceCapabilitiesStore().set(
'mediaProxyAvailable',
features.includes('media_proxy'),
)
useInstanceCapabilitiesStore().set(
'safeDM',
features.includes('safe_dm_mentions'),
)
useInstanceCapabilitiesStore().set(
'shoutAvailable',
features.includes('chat'),
)
useInstanceCapabilitiesStore().set(
'pleromaChatMessagesAvailable',
features.includes('pleroma_chat_messages'),
)
useInstanceCapabilitiesStore().set(
'pleromaCustomEmojiReactionsAvailable',
features.includes('pleroma_custom_emoji_reactions') ||
features.includes('custom_emoji_reactions'),
)
useInstanceCapabilitiesStore().set(
'pleromaBookmarkFoldersAvailable',
features.includes('pleroma:bookmark_folders'),
)
useInstanceCapabilitiesStore().set(
'gopherAvailable',
features.includes('gopher'),
)
useInstanceCapabilitiesStore().set(
'pollsAvailable',
features.includes('polls'),
)
useInstanceCapabilitiesStore().set(
'editingAvailable',
features.includes('editing'),
)
useInstanceCapabilitiesStore().set(
'mailerEnabled',
metadata.mailerEnabled,
)
useInstanceCapabilitiesStore().set(
'quotingAvailable',
features.includes('quote_posting'),
)
useInstanceCapabilitiesStore().set(
'groupActorAvailable',
features.includes('pleroma:group_actors'),
)
useInstanceCapabilitiesStore().set(
'blockExpiration',
features.includes('pleroma:block_expiration'),
)
useInstanceStore().set({
path: 'localBubbleInstances',
value: metadata.localBubbleInstances ?? [],
})
useInstanceCapabilitiesStore().set(
'localBubble',
(metadata.localBubbleInstances ?? []).length > 0,
)
useInstanceStore().set({
path: 'limits.pollLimits',
value: metadata.pollLimits,
})
const uploadLimits = metadata.uploadLimits
useInstanceStore().set({
path: 'limits.uploadlimit',
value: parseInt(uploadLimits.general),
})
useInstanceStore().set({
path: 'limits.avatarlimit',
value: parseInt(uploadLimits.avatar),
})
useInstanceStore().set({
path: 'limits.backgroundlimit',
value: parseInt(uploadLimits.background),
})
useInstanceStore().set({
path: 'limits.bannerlimit',
value: parseInt(uploadLimits.banner),
})
useInstanceStore().set({
path: 'limits.fieldsLimits',
value: metadata.fieldsLimits,
})
useInstanceStore().set({
path: 'restrictedNicknames',
value: metadata.restrictedNicknames,
})
useInstanceCapabilitiesStore().set('postFormats', metadata.postFormats)
const suggestions = metadata.suggestions
useInstanceCapabilitiesStore().set(
'suggestionsEnabled',
suggestions.enabled,
)
// this is unused, why?
useInstanceCapabilitiesStore().set('suggestionsWeb', suggestions.web)
const software = data.software
useInstanceStore().set({
path: 'backendVersion',
value: software.version,
})
useInstanceStore().set({
path: 'backendRepository',
value: software.repository,
})
const priv = metadata.private
useInstanceStore().set({ path: 'privateMode', value: priv })
const frontendVersion = window.___pleromafe_commit_hash
useInstanceStore().set({
path: 'frontendVersion',
value: frontendVersion,
})
const federation = metadata.federation
useInstanceCapabilitiesStore().set(
'tagPolicyAvailable',
- typeof federation.mrf_policies === 'undefined'
+ federation.mrf_policies === undefined
? false
: metadata.federation.mrf_policies.includes('TagPolicy'),
)
useInstanceStore().set({
path: 'federationPolicy',
value: federation,
})
useInstanceStore().set({
path: 'federating',
value:
- typeof federation.enabled === 'undefined' ? true : federation.enabled,
+ federation.enabled === undefined ? true : federation.enabled,
})
const accountActivationRequired = metadata.accountActivationRequired
useInstanceStore().set({
path: 'accountActivationRequired',
value: accountActivationRequired,
})
const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts })
} else {
throw res
}
} catch (e) {
console.warn('Could not load nodeinfo', e)
}
}
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([
getBackendProvidedConfig({ store }),
getStaticConfig(),
])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig })
}
const checkOAuthToken = async ({ store }) => {
const oauth = useOAuthStore()
if (oauth.userToken) {
return store.dispatch('loginUser', oauth.userToken)
}
return Promise.resolve()
}
const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
const app = createApp(App)
// Must have app use pinia before we do anything that touches the store
// https://pinia.vuejs.org/core-concepts/plugins.html#Introduction
// "Plugins are only applied to stores created after the plugins themselves, and after pinia is passed to the app, otherwise they won't be applied."
app.use(pinia)
app.config.errorHandler = (error, instance, info) => {
console.error(
'Global Vue Error Handler caught an error:',
error,
instance,
info,
)
useInterfaceStore().setGlobalError({ error, instance, info })
}
const waitForAllStoresToLoad = async () => {
// the stores that do not persist technically do not need to be awaited here,
// but that involves either hard-coding the stores in some place (prone to errors)
// or writing another vite plugin to analyze which stores needs persisting (++load time)
const allStores = import.meta.glob('../stores/*.js', { eager: true })
if (process.env.NODE_ENV === 'development') {
// do some checks to avoid common errors
if (!Object.keys(allStores).length) {
throw new Error(
'No stores are available. Check the code in src/boot/after_store.js',
)
}
}
await Promise.all(
Object.entries(allStores).map(async ([name, mod]) => {
const isStoreName = (name) => name.startsWith('use')
if (process.env.NODE_ENV === 'development') {
if (Object.keys(mod).filter(isStoreName).length !== 1) {
throw new Error(
'Each store file must export exactly one store as a named export. Check your code in src/stores/',
)
}
}
const storeFuncName = Object.keys(mod).find(isStoreName)
if (storeFuncName && typeof mod[storeFuncName] === 'function') {
const p = mod[storeFuncName]().$persistLoaded
if (!(p instanceof Promise)) {
throw new Error(
`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`,
)
}
await p
} else {
throw new Error(
`Store module ${name} does not export a 'use...' function`,
)
}
}),
)
}
try {
await waitForAllStoresToLoad()
} catch (e) {
console.error('Cannot load stores:', e)
storageError = e
}
if (storageError) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'errors.storage_unavailable',
level: 'error',
})
}
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
window.syncConfig = useSyncConfigStore()
window.mergedConfig = useMergedConfigStore()
window.localConfig = useLocalConfigStore()
window.highlightConfig = useUserHighlightStore()
FaviconService.initFaviconService()
initServiceWorker(store)
window.addEventListener('focus', () => updateFocus())
const overrides = window.___pleromafe_dev_overrides || {}
const server =
- typeof overrides.target !== 'undefined'
+ overrides.target !== undefined
? overrides.target
: window.location.origin
useInstanceStore().set({ path: 'server', value: server })
await setConfig({ store })
try {
await useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
} catch (e) {
window.splashError(e)
return Promise.reject(e)
}
applyStyleConfig(useMergedConfigStore().mergedConfig, i18n.global)
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([
checkOAuthToken({ store }),
getInstancePanel({ store }),
getNodeInfo({ store }),
getInstanceConfig({ store }),
]).catch((e) => Promise.reject(e))
getTOS({ store })
getStickers({ store })
const router = createRouter({
history: createWebHistory(),
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some((m) => m.meta.dontScroll)) {
return false
}
return savedPosition || { left: 0, top: 0 }
},
})
useI18nStore().setI18n(i18n)
app.use(router)
app.use(store)
app.use(i18n)
// Little thing to get out of invalid theme state
window.resetThemes = () => {
useInterfaceStore().resetThemeV3()
useInterfaceStore().resetThemeV3Palette()
useInterfaceStore().resetThemeV2()
}
app.use(vClickOutside)
app.use(VBodyScrollLock)
app.use(VueVirtualScroller)
app.component('FAIcon', FontAwesomeIcon)
app.component('FALayers', FontAwesomeLayers)
app.component('Status', Status)
app.component('RichContent', RichContent)
app.component('StillImage', StillImage)
// remove after vue 3.3
app.config.unwrapInjectedRef = true
app.mount('#app')
return app
}
export default afterStoreSetup
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
index ca7a025ece..6ac51902b7 100644
--- a/src/components/chat/chat.js
+++ b/src/components/chat/chat.js
@@ -1,433 +1,433 @@
import { throttle } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import ChatMessage from 'src/components/chat_message/chat_message.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import chatService from '../../services/chat_service/chat_service.js'
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
import {
getNewTopPosition,
getScrollPosition,
isBottomedOut,
isScrollable,
} from './chat_layout_utils.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
chatMessages,
getOrCreateChat,
sendChatMessage,
} from 'src/api/chats.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown, faChevronLeft)
const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
const SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500
const MAX_RETRIES = 10
const Chat = {
components: {
ChatMessage,
ChatTitle,
PostStatusForm,
},
data() {
return {
jumpToBottomButtonVisible: false,
hoveredMessageChainId: undefined,
lastScrollPosition: {},
scrollableContainerHeight: '100%',
errorLoadingChat: false,
messageRetriers: {},
}
},
created() {
this.startFetching()
window.addEventListener('resize', this.handleResize)
},
mounted() {
window.addEventListener('scroll', this.handleScroll)
- if (typeof document.hidden !== 'undefined') {
+ if (document.hidden !== undefined) {
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
}
this.$nextTick(() => {
this.handleResize()
})
},
unmounted() {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleResize)
- if (typeof document.hidden !== 'undefined')
+ if (document.hidden !== undefined)
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.$store.dispatch('clearCurrentChat')
},
computed: {
recipient() {
return this.currentChat && this.currentChat.account
},
recipientId() {
return this.$route.params.recipient_id
},
formPlaceholder() {
if (this.recipient) {
return this.$t('chats.message_user', {
nickname: this.recipient.screen_name_ui,
})
} else {
return ''
}
},
chatViewItems() {
return chatService.getView(this.currentChatMessageService)
},
newMessageCount() {
return (
this.currentChatMessageService &&
this.currentChatMessageService.newMessageCount
)
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
...mapGetters([
'currentChat',
'currentChatMessageService',
'findOpenedChatByRecipientId',
]),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
currentUser: (state) => state.users.currentUser,
}),
},
watch: {
chatViewItems() {
// We don't want to scroll to the bottom on a new message when the user is viewing older messages.
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => {
if (bottomedOutBeforeUpdate) {
this.scrollDown()
}
})
},
$route: function () {
this.startFetching()
},
mastoUserSocketStatus(newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
},
},
methods: {
// Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
onMessageHover({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
},
onFilesDropped() {
this.$nextTick(() => {
this.handleResize()
})
},
handleVisibilityChange() {
this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
},
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
handleResize(opts = {}) {
const { delayed = false } = opts
if (delayed) {
setTimeout(() => {
this.handleResize({ ...opts, delayed: false })
}, SAFE_RESIZE_TIME_OFFSET)
return
}
this.$nextTick(() => {
const { offsetHeight = undefined } = getScrollPosition()
const diff = offsetHeight - this.lastScrollPosition.offsetHeight
if (diff !== 0 && !this.bottomedOut()) {
this.$nextTick(() => {
window.scrollBy({ top: -Math.trunc(diff) })
})
}
this.lastScrollPosition = getScrollPosition()
})
},
scrollDown(options = {}) {
const { behavior = 'auto', forceRead = false } = options
this.$nextTick(() => {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior,
})
})
if (forceRead) {
this.readChat()
}
},
readChat() {
if (
!(
this.currentChatMessageService && this.currentChatMessageService.maxId
)
) {
return
}
if (document.hidden) {
return
}
const lastReadId = this.currentChatMessageService.maxId
this.$store.dispatch('readChat', {
id: this.currentChat.id,
lastReadId,
})
},
bottomedOut(offset) {
return isBottomedOut(offset)
},
reachedTop() {
return window.scrollY <= 0
},
cullOlderCheck() {
window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.$store.dispatch(
'cullOlderMessages',
this.currentChatMessageService.chatId,
)
}
}, 5000)
},
handleScroll: throttle(function () {
this.lastScrollPosition = getScrollPosition()
if (!this.currentChat) {
return
}
if (this.reachedTop()) {
this.fetchChat({ maxId: this.currentChatMessageService.minId })
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
this.cullOlderCheck()
if (this.newMessageCount > 0) {
// Use a delay before marking as read to prevent situation where new messages
// arrive just as you're leaving the view and messages that you didn't actually
// get to see get marked as read.
window.setTimeout(() => {
// Don't mark as read if the element doesn't exist, user has left chat view
if (this.$el) this.readChat()
}, MARK_AS_READ_DELAY)
}
} else {
this.jumpToBottomButtonVisible = true
}
}, 200),
handleScrollUp(positionBeforeLoading) {
const positionAfterLoading = getScrollPosition()
window.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
})
},
fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
const chatMessageService = this.currentChatMessageService
if (!chatMessageService) {
return
}
if (fetchLatest && this.streamingEnabled) {
return
}
const chatId = chatMessageService.chatId
const fetchOlderMessages = !!maxId
const sinceId = fetchLatest && chatMessageService.maxId
return chatMessages({
id: chatId,
maxId,
sinceId,
credentials: useOAuthStore().token,
}).then(({ data: messages }) => {
// Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) {
chatService.clear(chatMessageService)
}
const positionBeforeUpdate = getScrollPosition()
this.$store
.dispatch('addChatMessages', { chatId, messages })
.then(() => {
this.$nextTick(() => {
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
// In vertical screens, the first batch of fetched messages may not always take the
// full height of the scrollable container.
// If this is the case, we want to fetch the messages until the scrollable container
// is fully populated so that the user has the ability to scroll up and load the history.
if (!isScrollable() && messages.length > 0) {
this.fetchChat({
maxId: this.currentChatMessageService.minId,
})
}
})
})
})
},
async startFetching() {
let chat = this.findOpenedChatByRecipientId(this.recipientId)
if (!chat) {
try {
const { data } = await getOrCreateChat({
accountId: this.recipientId,
credentials: useOAuthStore().token,
})
chat = data
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
}
if (chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching()
}
},
doStartFetching() {
this.$store.dispatch('startFetchingCurrentChat', {
fetcher: () =>
promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000),
})
this.fetchChat({ isFirstFetch: true })
},
handleAttachmentPosting() {
this.$nextTick(() => {
this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize
// to account for the potential delay in the DOM update.
this.scrollDown({ forceRead: true })
})
},
sendMessage({ status, media, idempotencyKey }) {
const params = {
id: this.currentChat.id,
content: status,
idempotencyKey,
}
if (media[0]) {
params.mediaId = media[0].id
}
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: this.currentChat.id,
content: status,
userId: this.currentUser.id,
idempotencyKey,
})
this.$store
.dispatch('addChatMessages', {
chatId: this.currentChat.id,
messages: [fakeMessage],
})
.then(() => {
this.handleAttachmentPosting()
})
return this.doSendMessage({
params,
fakeMessage,
retriesLeft: MAX_RETRIES,
})
},
doSendMessage({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return
sendChatMessage({
...params,
credentials: useOAuthStore().token,
})
.then(({ data }) => {
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
updateMaxId: false,
messages: [{ ...data, fakeId: fakeMessage.id }],
})
return data
})
.catch((error) => {
console.error('Error sending message', error)
this.$store.dispatch('handleMessageError', {
chatId: this.currentChat.id,
fakeId: fakeMessage.id,
isRetry: retriesLeft !== MAX_RETRIES,
})
if (
(error.statusCode >= 500 && error.statusCode < 600) ||
error.message === 'Failed to fetch'
) {
this.messageRetriers[fakeMessage.id] = setTimeout(
() => {
this.doSendMessage({
params,
fakeMessage,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
)
}
return {}
})
return Promise.resolve(fakeMessage)
},
goBack() {
this.$router.push({
name: 'chats',
params: { username: this.currentUser.screen_name },
})
},
},
}
export default Chat
diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue
index 208bdf2dca..d6667c28ff 100644
--- a/src/components/color_input/color_input.vue
+++ b/src/components/color_input/color_input.vue
@@ -1,159 +1,159 @@
<template>
<div
class="color-input style-control"
:class="{ disabled: !present || disabled, '-compact': compact }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label }}
</label>
<Checkbox
v-if="typeof fallback !== 'undefined' && showOptionalCheckbox && !hideOptionalCheckbox"
:model-value="present"
:disabled="disabled"
class="opt"
@update:model-value="updateValue(typeof modelValue === 'undefined' ? fallback : undefined)"
/>
<div
class="input color-input-field"
:class="{ disabled: !present || disabled, unstyled }"
>
<input
:id="name + '-t'"
class="textColor unstyled"
:class="{ disabled: !present || disabled }"
type="text"
:value="modelValue ?? fallback"
:disabled="!present || disabled"
@input="updateValue($event.target.value)"
>
<div
v-if="validColor"
class="validIndicator"
:style="{backgroundColor: modelValue || fallback}"
/>
<div
v-else-if="transparentColor"
class="transparentIndicator"
/>
<div
v-else-if="computedColor"
class="computedIndicator"
:style="{backgroundColor: fallback}"
/>
<div
v-else
class="invalidIndicator"
/>
<label class="nativeColor">
<FAIcon icon="eye-dropper" />
<input
:id="name"
class="unstyled"
type="color"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
@input="updateValue($event.target.value)"
>
</label>
</div>
</div>
</template>
<script>
import { throttle } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEyeDropper } from '@fortawesome/free-solid-svg-icons'
library.add(faEyeDropper)
export default {
components: {
Checkbox,
},
props: {
// Name of color, used for identifying
name: {
required: true,
type: String,
},
// Readable label
label: {
required: false,
type: String,
default: '',
},
// use unstyled, uh, style
unstyled: {
required: false,
type: Boolean,
},
// Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined"
modelValue: {
required: false,
type: String,
default: undefined,
},
// Color fallback to use when value is not defeind
fallback: {
required: false,
type: String,
default: undefined,
},
// Disable the control
disabled: {
required: false,
type: Boolean,
default: false,
},
// Show "optional" tickbox, for when value might become mandatory
showOptionalCheckbox: {
required: false,
type: Boolean,
default: true,
},
// Force "optional" tickbox to hide
hideOptionalCheckbox: {
required: false,
type: Boolean,
default: false,
},
compact: {
required: false,
type: Boolean,
},
},
emits: ['update:modelValue'],
computed: {
present() {
- return typeof this.modelValue !== 'undefined'
+ return this.modelValue !== undefined
},
validColor() {
return hex2rgb(this.modelValue || this.fallback)
},
transparentColor() {
return this.modelValue === 'transparent'
},
computedColor() {
return (
this.modelValue &&
(this.modelValue.startsWith('--') || this.modelValue.startsWith('$'))
)
},
},
methods: {
updateValue: throttle(function (value) {
this.$emit('update:modelValue', value)
}, 100),
},
}
</script>
<style lang="scss" src="./color_input.scss"></style>
diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue
index 761aeb2169..e6b736fa33 100644
--- a/src/components/opacity_input/opacity_input.vue
+++ b/src/components/opacity_input/opacity_input.vue
@@ -1,49 +1,49 @@
<template>
<div
class="opacity-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label || $t('settings.style.themes3.editor.opacity') }}
</label>
<Checkbox
v-if="typeof fallback !== 'undefined'"
:model-value="present"
:disabled="disabled"
class="opt"
@update:model-value="$emit('update:modelValue', !present ? fallback : undefined)"
/>
<input
:id="name"
class="input input-number"
type="number"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
max="1"
min="0"
step=".05"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
export default {
components: {
Checkbox,
},
props: ['name', 'label', 'modelValue', 'fallback', 'disabled'],
emits: ['update:modelValue'],
computed: {
present() {
- return typeof this.modelValue !== 'undefined'
+ return this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/range_input/range_input.vue b/src/components/range_input/range_input.vue
index 91d3dcc3bc..40c427784e 100644
--- a/src/components/range_input/range_input.vue
+++ b/src/components/range_input/range_input.vue
@@ -1,75 +1,75 @@
<template>
<div
class="range-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:id="name + '-label'"
:for="name"
class="label"
>
{{ label }}
</label>
<input
v-if="typeof fallback !== 'undefined'"
:id="name + '-o'"
:aria-labelledby="name + '-label'"
class="input -checkbox opt visible-for-screenreader-only"
type="checkbox"
:checked="present"
@change="$emit('update:modelValue', !present ? fallback : undefined)"
>
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
:for="name + '-o'"
:aria-hidden="true"
/>
<input
:id="name"
class="input input-number"
type="range"
:value="modelValue || fallback"
:disabled="!present || disabled"
:max="max || hardMax || 100"
:min="min || hardMin || 0"
:step="step || 1"
@input="$emit('update:modelValue', $event.target.value)"
>
<input
:id="name + '-numeric'"
class="input input-number"
type="number"
:aria-labelledby="name + '-label'"
:value="modelValue || fallback"
:disabled="!present || disabled"
:max="hardMax"
:min="hardMin"
:step="step || 1"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
export default {
props: [
'name',
'modelValue',
'fallback',
'disabled',
'label',
'max',
'min',
'step',
'hardMin',
'hardMax',
],
emits: ['update:modelValue'],
computed: {
present() {
- return typeof this.modelValue !== 'undefined'
+ return this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/roundness_input/roundness_input.vue b/src/components/roundness_input/roundness_input.vue
index caf21763b2..36fa8fd474 100644
--- a/src/components/roundness_input/roundness_input.vue
+++ b/src/components/roundness_input/roundness_input.vue
@@ -1,49 +1,49 @@
<template>
<div
class="roundness-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label }}
</label>
<Checkbox
v-if="typeof fallback !== 'undefined'"
:model-value="present"
:disabled="disabled"
class="opt"
@update:model-value="$emit('update:modelValue', !present ? fallback : undefined)"
/>
<input
:id="name"
class="input input-number"
type="number"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
max="999"
min="0"
step="1"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
export default {
components: {
Checkbox,
},
props: ['name', 'label', 'modelValue', 'fallback', 'disabled'],
emits: ['update:modelValue'],
computed: {
present() {
- return typeof this.modelValue !== 'undefined'
+ return this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js
index 3b76708fed..c2f5279f6c 100644
--- a/src/components/settings_modal/helpers/setting.js
+++ b/src/components/settings_modal/helpers/setting.js
@@ -1,420 +1,420 @@
import { cloneDeep, get, isEqual, set } from 'lodash'
import DraftButtons from './draft_buttons.vue'
import LocalSettingIndicator from './local_setting_indicator.vue'
import ModifiedIndicator from './modified_indicator.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
export default {
components: {
ModifiedIndicator,
DraftButtons,
LocalSettingIndicator,
},
props: {
modelValue: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
path: {
type: [String, Array],
required: false,
},
showDescription: {
type: Boolean,
required: false,
},
descriptionPathOverride: {
type: [String, Array],
required: false,
},
suggestions: {
type: [String, Array],
required: false,
},
subgroup: {
type: String,
required: false,
},
disabled: {
type: Boolean,
default: false,
},
local: {
type: Boolean,
default: false,
},
parentPath: {
type: [String, Array],
},
parentInvert: {
type: Boolean,
default: false,
},
expert: {
type: [Number, String],
default: 0,
},
source: {
type: String,
default: undefined,
},
hideDraftButtons: {
// this is for the weird backend hybrid (Boolean|String or Boolean|Number) settings
required: false,
type: Boolean,
},
hideLabel: {
type: Boolean,
},
hideDescription: {
type: Boolean,
},
swapDescriptionAndLabel: {
type: Boolean,
},
backendDescriptionPath: {
type: [String, Array],
},
overrideBackendDescription: {
type: Boolean,
},
overrideBackendDescriptionLabel: {
type: [Boolean, String],
},
draftMode: {
type: Boolean,
default: undefined,
},
timedApplyMode: {
type: Boolean,
default: false,
},
},
inject: {
defaultSource: {
default: 'default',
},
defaultDraftMode: {
default: false,
},
},
data() {
return {
localDraft: null,
}
},
created() {
if (
this.realDraftMode &&
(this.realSource !== 'admin' || this.path == null)
) {
this.draft = cloneDeep(this.state)
}
},
computed: {
draft: {
get() {
if (this.realSource === 'admin' || this.path == null) {
return get(useAdminSettingsStore().draft, this.canonPath)
} else {
return this.localDraft
}
},
set(value) {
if (this.realSource === 'admin' || this.path == null) {
useAdminSettingsStore().updateAdminDraft({
path: this.canonPath,
value,
})
} else {
this.localDraft = value
}
},
},
state() {
if (this.path == null) {
return this.modelValue
}
const value = get(this.configSource, this.canonPath)
if (value === undefined) {
return this.defaultState
} else {
return value
}
},
visibleState() {
return this.realDraftMode ? this.draft : this.state
},
realSource() {
return this.source || this.defaultSource
},
realDraftMode() {
- return typeof this.draftMode === 'undefined'
+ return this.draftMode === undefined
? this.defaultDraftMode
: this.draftMode
},
backendDescription() {
return get(useAdminSettingsStore().descriptions, this.descriptionPath)
},
backendDescriptionLabel() {
if (this.realSource !== 'admin') return ''
if (
this.overrideBackendDescriptionLabel !== '' &&
typeof this.overrideBackendDescriptionLabel === 'string'
) {
return this.overrideBackendDescriptionLabel
}
if (!this.backendDescription || this.overrideBackendDescriptionLabel) {
return this.$t(
[
'admin_dash',
'temp_overrides',
...this.canonPath.map((p) => p.replace(/\./g, '_DOT_')),
'label',
].join('.'),
)
} else {
return this.swapDescriptionAndLabel
? this.backendDescription?.description
: this.backendDescription?.label
}
},
backendDescriptionDescription() {
if (this.description) return this.description
if (this.realSource !== 'admin') return ''
if (this.hideDescription) return null
if (!this.backendDescription || this.overrideBackendDescription) {
return this.$t(
[
'admin_dash',
'temp_overrides',
...this.canonPath.map((p) => p.replace(/\./g, '_DOT_')),
'description',
].join('.'),
)
} else {
return this.swapDescriptionAndLabel
? this.backendDescription?.label
: this.backendDescription?.description
}
},
backendDescriptionSuggestions() {
return this.backendDescription?.suggestions || this.suggestions
},
shouldBeDisabled() {
if (this.path == null) {
return this.disabled
}
let parentValue = null
if (this.parentPath !== undefined && this.realSource === 'admin') {
if (this.realDraftMode) {
parentValue = get(useAdminSettingsStore().draft, this.parentPath)
} else {
parentValue = get(this.configSource, this.parentPath)
}
}
return (
this.disabled ||
(parentValue !== null
? this.parentInvert
? parentValue
: !parentValue
: false)
)
},
configSource() {
switch (this.realSource) {
case 'profile':
return this.$store.state.profileConfig
case 'admin':
return useAdminSettingsStore().config
default:
return useMergedConfigStore().mergedConfig
}
},
configSink() {
if (this.path == null) {
return (k, v) => this.$emit('update:modelValue', v)
}
switch (this.realSource) {
case 'profile':
return (k, v) =>
this.$store.dispatch('setProfileOption', { name: k, value: v })
case 'admin':
return (k, v) =>
useAdminSettingsStore().pushAdminSetting({ path: k, value: v })
default:
return (readPath, value) => {
const writePath = `${readPath}`
if (!this.timedApplyMode) {
if (this.local) {
useLocalConfigStore().set({
path: writePath,
value,
})
} else {
useSyncConfigStore().setSimplePrefAndSave({
path: writePath,
value,
})
}
} else {
if (useInterfaceStore().temporaryChangesTimeoutId !== null) {
console.error("Can't track more than one temporary change")
return
}
const oldValue = get(this.configSource, readPath)
if (this.local) {
useLocalConfigStore().setTemporarily({ path: writePath, value })
} else {
useSyncConfigStore().setPreference({ path: writePath, value })
}
const confirm = () => {
if (this.local) {
useLocalConfigStore().set({ path: writePath, value })
} else {
useSyncConfigStore().pushSyncConfig()
}
useInterfaceStore().clearTemporaryChanges()
}
const revert = () => {
if (this.local) {
useLocalConfigStore().unsetTemporarily({
path: writePath,
value,
})
} else {
useSyncConfigStore().setPreference({
path: writePath,
value: oldValue,
})
}
useInterfaceStore().clearTemporaryChanges()
}
useInterfaceStore().setTemporaryChanges({ confirm, revert })
}
}
}
},
defaultState() {
switch (this.realSource) {
case 'profile':
return {}
default: {
return get(useMergedConfigStore().mergedConfigDefault, this.path)
}
}
},
isProfileSetting() {
return this.realSource === 'profile'
},
isLocalSetting() {
return this.local
},
isChanged() {
if (this.path == null) return false
switch (this.realSource) {
case 'profile':
case 'admin':
return false
default:
return this.state !== this.defaultState
}
},
canonPath() {
if (this.path == null) return null
return Array.isArray(this.path) ? this.path : this.path.split('.')
},
descriptionPath() {
if (this.path == null) return null
if (this.descriptionPathOverride) return this.descriptionPathOverride
const path = Array.isArray(this.path) ? this.path : this.path.split('.')
if (this.subgroup) {
return [
...path.slice(0, path.length - 1),
':subgroup,' + this.subgroup,
...path.slice(path.length - 1),
]
}
return path
},
isDirty() {
if (this.path == null) return false
if (this.realSource === 'admin' && this.canonPath.length > 3) {
return false // should not show draft buttons for "grouped" values
} else {
return this.realDraftMode && !isEqual(this.draft, this.state)
}
},
canHardReset() {
return (
this.realSource === 'admin' &&
useAdminSettingsStore().modifiedPaths?.has(this.canonPath.join(' -> '))
)
},
matchesExpertLevel() {
const settingExpertLevel = this.expert || 0
const userToggleExpert =
useMergedConfigStore().mergedConfig.expertLevel || 0
return settingExpertLevel <= userToggleExpert
},
},
methods: {
getValue(e) {
return e.target.value
},
update(e) {
if (this.realDraftMode) {
this.draft = this.getValue(e)
} else {
this.configSink(this.path, this.getValue(e))
}
},
commitDraft() {
if (this.realDraftMode) {
this.configSink(this.path, this.draft)
}
},
reset() {
if (this.realDraftMode) {
this.draft = cloneDeep(this.state)
} else {
set(
useMergedConfigStore().mergedConfig,
this.path,
cloneDeep(this.defaultState),
)
}
},
hardReset() {
switch (this.realSource) {
case 'admin':
return this.$store
.dispatch('resetAdminSetting', { path: this.path })
.then(() => {
this.draft = this.state
})
default:
console.warn('Hard reset not implemented yet!')
}
},
},
}
diff --git a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
index 83f993c86c..cce4a764f6 100644
--- a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
+++ b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
@@ -1,834 +1,834 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import FontControl from 'src/components/font_control/font_control.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import RangeInput from 'src/components/range_input/range_input.vue'
import Select from 'src/components/select/select.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Preview from './theme_preview.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import {
getContrastRatioLayers,
hex2rgb,
relativeLuminance,
rgb2hex,
} from 'src/services/color_convert/color_convert.js'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
import {
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
import {
getCssRules,
getScopedVersion,
} from 'src/services/theme_data/css_utils.js'
import { SLOT_INHERITANCE } from 'src/services/theme_data/pleromafe.js'
import {
CURRENT_VERSION,
colors2to3,
DEFAULT_SHADOWS,
generateColors,
generateFonts,
generateRadii,
generateShadows,
getLayers,
getOpacitySlot,
OPACITIES,
shadows2to3,
} from 'src/services/theme_data/theme_data.service.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
// List of color values used in v1
const v1OnlyNames = [
'bg',
'fg',
'text',
'link',
'cRed',
'cGreen',
'cBlue',
'cOrange',
].map((_) => _ + 'ColorLocal')
const colorConvert = (color) => {
if (color.startsWith('--') || color === 'transparent') {
return color
} else {
return hex2rgb(color)
}
}
export default {
data() {
return {
themeImporter: newImporter({
validator: this.importValidator,
onImport: this.onImport,
onImportFailure: this.onImportFailure,
}),
themeExporter: newExporter({
filename: 'pleroma_theme',
getExportedObject: () => this.exportedTheme,
}),
availableStyles: [],
selected: '',
selectedTheme: useMergedConfigStore().mergedConfig.theme,
themeWarning: undefined,
tempImportFile: undefined,
engineVersion: 0,
previewTheme: {},
shadowsInvalid: true,
colorsInvalid: true,
radiiInvalid: true,
keepColor: false,
keepShadows: false,
keepOpacity: false,
keepRoundness: false,
keepFonts: false,
...Object.keys(SLOT_INHERITANCE)
.map((key) => [key, ''])
.reduce(
(acc, [key, val]) => ({ ...acc, [key + 'ColorLocal']: val }),
{},
),
...Object.keys(OPACITIES)
.map((key) => [key, ''])
.reduce(
(acc, [key, val]) => ({ ...acc, [key + 'OpacityLocal']: val }),
{},
),
shadowSelected: undefined,
shadowsLocal: {},
fontsLocal: {},
btnRadiusLocal: '',
inputRadiusLocal: '',
checkboxRadiusLocal: '',
panelRadiusLocal: '',
avatarRadiusLocal: '',
avatarAltRadiusLocal: '',
attachmentRadiusLocal: '',
tooltipRadiusLocal: '',
chatMessageRadiusLocal: '',
}
},
created() {
const currentIndex = useInstanceStore().themesIndex
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = useInterfaceStore().fetchThemesIndex()
}
promise.then((themesIndex) => {
Object.values(themesIndex).forEach((themeFunc) => {
themeFunc().then(
(themeData) => themeData && this.availableStyles.push(themeData),
)
})
})
},
mounted() {
- if (typeof this.shadowSelected === 'undefined') {
+ if (this.shadowSelected === undefined) {
this.shadowSelected = this.shadowsAvailable[0]
}
},
computed: {
themeWarningHelp() {
if (!this.themeWarning) return
const t = this.$t
const pre = 'settings.style.switcher.help.'
const { origin, themeEngineVersion, type, noActionsPossible } =
this.themeWarning
if (origin === 'file') {
// Loaded v2 theme from file
if (themeEngineVersion === 2 && type === 'wrong_version') {
return t(pre + 'v2_imported')
}
if (themeEngineVersion > CURRENT_VERSION) {
return (
t(pre + 'future_version_imported') +
' ' +
(noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present'))
)
}
if (themeEngineVersion < CURRENT_VERSION) {
return (
t(pre + 'future_version_imported') +
' ' +
(noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present'))
)
}
} else if (origin === 'localStorage') {
if (type === 'snapshot_source_mismatch') {
return t(pre + 'snapshot_source_mismatch')
}
// FE upgraded from v2
if (themeEngineVersion === 2) {
return t(pre + 'upgraded_from_v2')
}
// Admin downgraded FE
if (themeEngineVersion > CURRENT_VERSION) {
return (
t(pre + 'fe_downgraded') +
' ' +
(noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone'))
)
}
// Admin upgraded FE
if (themeEngineVersion < CURRENT_VERSION) {
return (
t(pre + 'fe_upgraded') +
' ' +
(noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone'))
)
}
}
},
selectedVersion() {
return Array.isArray(this.selectedTheme) ? 1 : 2
},
currentColors() {
return Object.keys(SLOT_INHERITANCE)
.map((key) => [key, this[key + 'ColorLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentOpacity() {
return Object.keys(OPACITIES)
.map((key) => [key, this[key + 'OpacityLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentRadii() {
return {
btn: this.btnRadiusLocal,
input: this.inputRadiusLocal,
checkbox: this.checkboxRadiusLocal,
panel: this.panelRadiusLocal,
avatar: this.avatarRadiusLocal,
avatarAlt: this.avatarAltRadiusLocal,
tooltip: this.tooltipRadiusLocal,
attachment: this.attachmentRadiusLocal,
chatMessage: this.chatMessageRadiusLocal,
}
},
// This needs optimization maybe
previewContrast() {
try {
if (!this.previewTheme.colors.bg) return {}
const colors = this.previewTheme.colors
const opacity = this.previewTheme.opacity
if (!colors.bg) return {}
const hints = (ratio) => ({
text: ratio.toPrecision(3) + ':1',
// AA level, AAA level
aa: ratio >= 4.5,
aaa: ratio >= 7,
// same but for 18pt+ texts
laa: ratio >= 3,
laaa: ratio >= 4.5,
})
const colorsConverted = Object.entries(colors).reduce(
(acc, [key, value]) => ({ ...acc, [key]: colorConvert(value) }),
{},
)
const ratios = Object.entries(SLOT_INHERITANCE).reduce(
(acc, [key, value]) => {
const slotIsBaseText = key === 'text' || key === 'link'
const slotIsText =
slotIsBaseText ||
(typeof value === 'object' && value !== null && value.textColor)
if (!slotIsText) return acc
const { layer, variant } = slotIsBaseText ? { layer: 'bg' } : value
const background = variant || layer
const opacitySlot = getOpacitySlot(background)
const textColors = [
key,
...(background === 'bg'
? ['cRed', 'cGreen', 'cBlue', 'cOrange']
: []),
]
const layers = getLayers(
layer,
variant || layer,
opacitySlot,
colorsConverted,
opacity,
)
// Temporary patch for null-y value errors
if (layers.flat().some((v) => v == null)) return acc
return {
...acc,
...textColors.reduce((acc, textColorKey) => {
const newKey = slotIsBaseText
? 'bg' + textColorKey[0].toUpperCase() + textColorKey.slice(1)
: textColorKey
return {
...acc,
[newKey]: getContrastRatioLayers(
colorsConverted[textColorKey],
layers,
colorsConverted[textColorKey],
),
}
}, {}),
}
},
{},
)
return Object.entries(ratios).reduce((acc, [k, v]) => {
acc[k] = hints(v)
return acc
}, {})
} catch (e) {
console.warn('Failure computing contrasts', e)
return {}
}
},
themeDataUsed() {
return useInterfaceStore().themeDataUsed
},
shadowsAvailable() {
return Object.keys(DEFAULT_SHADOWS).sort()
},
currentShadowOverriden: {
get() {
return !!this.currentShadow
},
set(val) {
if (val) {
this.shadowsLocal[this.shadowSelected] = (
this.currentShadowFallback || []
).map((s) => ({
name: null,
x: 0,
y: 0,
blur: 0,
spread: 0,
inset: false,
color: '#000000',
alpha: 1,
...s,
}))
} else {
delete this.shadowsLocal[this.shadowSelected]
}
},
},
currentShadowFallback() {
return (this.previewTheme.shadows || {})[this.shadowSelected]
},
currentShadow: {
get() {
return this.shadowsLocal[this.shadowSelected]
},
set(v) {
this.shadowsLocal[this.shadowSelected] = v
},
},
themeValid() {
return !this.shadowsInvalid && !this.colorsInvalid && !this.radiiInvalid
},
exportedTheme() {
const saveEverything =
!this.keepFonts &&
!this.keepShadows &&
!this.keepOpacity &&
!this.keepRoundness &&
!this.keepColor
const source = {
themeEngineVersion: CURRENT_VERSION,
}
if (this.keepFonts || saveEverything) {
source.fonts = this.fontsLocal
}
if (this.keepShadows || saveEverything) {
source.shadows = this.shadowsLocal
}
if (this.keepOpacity || saveEverything) {
source.opacity = this.currentOpacity
}
if (this.keepColor || saveEverything) {
source.colors = this.currentColors
}
if (this.keepRoundness || saveEverything) {
source.radii = this.currentRadii
}
const theme = {
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme,
}
return {
// To separate from other random JSON files and possible future source formats
_pleroma_theme_version: 2,
theme,
source,
}
},
isActive() {
const tabSwitcher = this.$parent
return tabSwitcher ? tabSwitcher.isActive('theme') : false
},
},
components: {
ColorInput,
OpacityInput,
RangeInput,
ContrastRatio,
ShadowControl,
FontControl,
TabSwitcher,
Preview,
Checkbox,
Select,
},
methods: {
loadTheme(
{ theme, source, _pleroma_theme_version: fileVersion },
origin,
forceUseSource = false,
) {
this.dismissWarning()
const version =
origin === 'localStorage' && !theme.colors ? 'l1' : fileVersion
const snapshotEngineVersion = (theme || {}).themeEngineVersion
const themeEngineVersion = (source || {}).themeEngineVersion || 2
const versionsMatch = themeEngineVersion === CURRENT_VERSION
const sourceSnapshotMismatch =
theme !== undefined &&
source !== undefined &&
themeEngineVersion !== snapshotEngineVersion
// Force loading of source if user requested it or if snapshot
// is unavailable
const forcedSourceLoad = (source && forceUseSource) || !theme
if (
!(versionsMatch && !sourceSnapshotMismatch) &&
!forcedSourceLoad &&
version !== 'l1' &&
origin !== 'defaults'
) {
if (sourceSnapshotMismatch && origin === 'localStorage') {
this.themeWarning = {
origin,
themeEngineVersion,
type: 'snapshot_source_mismatch',
}
} else if (!theme) {
this.themeWarning = {
origin,
noActionsPossible: true,
themeEngineVersion,
type: 'no_snapshot_old_version',
}
} else if (!versionsMatch) {
this.themeWarning = {
origin,
noActionsPossible: !source,
themeEngineVersion,
type: 'wrong_version',
}
}
}
this.normalizeLocalState(theme, version, source, forcedSourceLoad)
},
forceLoadLocalStorage() {
this.loadThemeFromLocalStorage(true)
},
dismissWarning() {
this.themeWarning = undefined
this.tempImportFile = undefined
},
forceLoad() {
const { origin } = this.themeWarning
switch (origin) {
case 'localStorage':
this.loadThemeFromLocalStorage(true)
break
case 'file':
this.onImport(this.tempImportFile, true)
break
}
this.dismissWarning()
},
forceSnapshot() {
const { origin } = this.themeWarning
switch (origin) {
case 'localStorage':
this.loadThemeFromLocalStorage(false, true)
break
case 'file':
console.error('Forcing snapshot from file is not supported yet')
break
}
this.dismissWarning()
},
loadThemeFromLocalStorage(confirmLoadSource = false) {
const theme = this.themeDataUsed?.source
if (theme) {
this.loadTheme(
{
theme,
},
'localStorage',
confirmLoadSource,
)
}
},
setCustomTheme() {
useInterfaceStore().setTheme({
themeFileVersion: this.selectedVersion,
themeEngineVersion: CURRENT_VERSION,
shadows: this.shadowsLocal,
fonts: this.fontsLocal,
opacity: this.currentOpacity,
colors: this.currentColors,
radii: this.currentRadii,
})
},
updatePreviewColors() {
const result = generateColors({
opacity: this.currentOpacity,
colors: this.currentColors,
})
this.previewTheme.colors = result.theme.colors
this.previewTheme.opacity = result.theme.opacity
},
updatePreviewShadows() {
this.previewTheme.shadows = generateShadows(
{
shadows: this.shadowsLocal,
opacity: this.previewTheme.opacity,
themeEngineVersion: this.engineVersion,
},
this.previewTheme.colors,
relativeLuminance(this.previewTheme.colors.bg) < 0.5 ? 1 : -1,
).theme.shadows
},
importTheme() {
this.themeImporter.importData()
},
exportTheme() {
this.themeExporter.exportData()
},
onImport(parsed, forceSource = false) {
this.tempImportFile = parsed
this.loadTheme(parsed, 'file', forceSource)
},
onImportFailure() {
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
importValidator(parsed) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
},
clearAll() {
this.loadThemeFromLocalStorage()
},
// Clears all the extra stuff when loading V1 theme
clearV1() {
Object.keys(this.$data)
.filter((_) => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal'))
.filter((_) => !v1OnlyNames.includes(_))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearRoundness() {
Object.keys(this.$data)
.filter((_) => _.endsWith('RadiusLocal'))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearOpacity() {
Object.keys(this.$data)
.filter((_) => _.endsWith('OpacityLocal'))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearShadows() {
this.shadowsLocal = {}
},
clearFonts() {
this.fontsLocal = {}
},
/**
* This applies stored theme data onto form. Supports three versions of data:
* v3 (version >= 3) - newest version of themes which supports snapshots for better compatiblity
* v2 (version = 2) - newer version of themes.
* v1 (version = 1) - older version of themes (import from file)
* v1l (version = l1) - older version of theme (load from local storage)
* v1 and v1l differ because of way themes were stored/exported.
* @param {Object} theme - theme data (snapshot)
* @param {Number} version - version of data. 0 means try to guess based on data. "l1" means v1, locastorage type
* @param {Object} source - theme source - this will be used if compatible
* @param {Boolean} source - by default source won't be used if version doesn't match since it might render differently
* this allows importing source anyway
*/
normalizeLocalState(theme, version = 0, source, forceSource = false) {
let input
if (typeof source !== 'undefined') {
if (forceSource || source?.themeEngineVersion === CURRENT_VERSION) {
input = source
version = source.themeEngineVersion
} else {
input = theme
}
} else {
input = theme
}
const radii = input.radii || input
const opacity = input.opacity
const shadows = input.shadows || {}
const fonts = input.fonts || {}
const colors = !input.themeEngineVersion
? colors2to3(input.colors || input)
: input.colors || input
if (version === 0) {
if (input.version) version = input.version
// Old v1 naming: fg is text, btn is foreground
if (
- typeof colors.text === 'undefined' &&
- typeof colors.fg !== 'undefined'
+ colors.text === undefined &&
+ colors.fg !== undefined
) {
version = 1
}
// New v2 naming: text is text, fg is foreground
if (
- typeof colors.text !== 'undefined' &&
- typeof colors.fg !== 'undefined'
+ colors.text !== undefined &&
+ colors.fg !== undefined
) {
version = 2
}
}
this.engineVersion = version
// Stuff that differs between V1 and V2
if (version === 1) {
this.fgColorLocal = rgb2hex(colors.btn)
this.textColorLocal = rgb2hex(colors.fg)
}
if (!this.keepColor) {
this.clearV1()
const keys = new Set(version !== 1 ? Object.keys(SLOT_INHERITANCE) : [])
if (version === 1 || version === 'l1') {
keys
.add('bg')
.add('link')
.add('cRed')
.add('cBlue')
.add('cGreen')
.add('cOrange')
}
keys.forEach((key) => {
const color = colors[key]
const hex = rgb2hex(colors[key])
this[key + 'ColorLocal'] = hex === '#aN' ? color : hex
})
}
if (opacity && !this.keepOpacity) {
this.clearOpacity()
Object.entries(opacity).forEach(([k, v]) => {
- if (typeof v === 'undefined' || v === null || Number.isNaN(v)) return
+ if (v === undefined || v === null || Number.isNaN(v)) return
this[k + 'OpacityLocal'] = v
})
}
if (!this.keepRoundness) {
this.clearRoundness()
Object.entries(radii).forEach(([k, v]) => {
// 'Radius' is kept mostly for v1->v2 localstorage transition
const key = k.endsWith('Radius') ? k.split('Radius')[0] : k
this[key + 'RadiusLocal'] = v
})
}
if (!this.keepShadows) {
this.clearShadows()
if (version === 2) {
this.shadowsLocal = shadows2to3(shadows, this.previewTheme.opacity)
} else {
this.shadowsLocal = shadows
}
this.updatePreviewColors()
this.updatePreviewShadows()
this.shadowSelected = this.shadowsAvailable[0]
}
if (!this.keepFonts) {
this.clearFonts()
this.fontsLocal = fonts
}
},
updateTheme3Preview() {
const theme2 = convertTheme2To3(this.previewTheme)
const theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
})
const sheet = createStyleSheet('theme-tab-overall-preview', 90)
const rule = getScopedVersion(getCssRules(theme3.eager), '&').join('\n')
sheet.clear()
sheet.addRule('#theme-preview {\n' + rule + '\n}')
sheet.ready = true
adoptStyleSheets()
},
},
watch: {
themeDataUsed() {
this.loadThemeFromLocalStorage()
},
currentRadii() {
try {
this.previewTheme.radii = generateRadii({
radii: this.currentRadii,
}).theme.radii
this.radiiInvalid = false
} catch (e) {
this.radiiInvalid = true
console.warn(e)
}
},
shadowsLocal: {
handler() {
try {
this.updatePreviewShadows()
this.shadowsInvalid = false
} catch (e) {
this.shadowsInvalid = true
console.warn(e)
}
},
deep: true,
},
fontsLocal: {
handler() {
try {
this.previewTheme.fonts = generateFonts({
fonts: this.fontsLocal,
}).theme.fonts
this.fontsInvalid = false
} catch (e) {
this.fontsInvalid = true
console.warn(e)
}
},
deep: true,
},
currentColors() {
try {
this.updatePreviewColors()
this.colorsInvalid = false
} catch (e) {
this.colorsInvalid = true
console.warn(e)
}
},
currentOpacity() {
try {
this.updatePreviewColors()
} catch (e) {
console.warn(e)
}
},
selected() {
this.selectedTheme = Object.entries(this.availableStyles).find(
([, s]) => {
if (Array.isArray(s)) {
return s[0] === this.selected
} else {
return s.name === this.selected
}
},
)[1]
},
selectedTheme() {
this.dismissWarning()
if (this.selectedVersion === 1) {
if (!this.keepRoundness) {
this.clearRoundness()
}
if (!this.keepShadows) {
this.clearShadows()
}
if (!this.keepOpacity) {
this.clearOpacity()
}
if (!this.keepColor) {
this.clearV1()
this.bgColorLocal = this.selectedTheme[1]
this.fgColorLocal = this.selectedTheme[2]
this.textColorLocal = this.selectedTheme[3]
this.linkColorLocal = this.selectedTheme[4]
this.cRedColorLocal = this.selectedTheme[5]
this.cGreenColorLocal = this.selectedTheme[6]
this.cBlueColorLocal = this.selectedTheme[7]
this.cOrangeColorLocal = this.selectedTheme[8]
}
} else if (this.selectedVersion >= 2) {
this.normalizeLocalState(
this.selectedTheme.theme,
2,
this.selectedTheme.source,
)
}
},
},
}
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 50b2acd90e..8c5f4f2516 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,342 +1,342 @@
import { debounce, keyBy, throttle } from 'lodash'
import { mapState } from 'pinia'
import Conversation from 'src/components/conversation/conversation.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 ScrollTopButton from 'src/components/scroll_top_button/scroll_top_button.vue'
import TimelineMenu from 'src/components/timeline_menu/timeline_menu.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCheck,
faCircleNotch,
faCirclePlus,
faCog,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck)
const Timeline = {
props: [
'timeline',
'timelineName',
'title',
'userId',
'listId',
'statusId',
'bookmarkFolderId',
'tag',
'embedded',
'count',
'pinnedStatusIds',
'inProfile',
'footerSlipgate', // reference to an element where we should put our footer
],
data() {
return {
showScrollTop: false,
paused: false,
unfocused: false,
bottomedOut: false,
virtualScrollIndex: 0,
blockingClicks: false,
}
},
components: {
ScrollTopButton,
Conversation,
TimelineMenu,
QuickFilterSettings,
QuickViewSettings,
},
computed: {
filteredVisibleStatuses() {
return this.timeline.visibleStatuses.filter(
(status) =>
this.timelineName !== 'user' ||
(status.id >= this.timeline.minId &&
status.id <= this.timeline.maxId),
)
},
filteredPinnedStatusIds() {
return (this.pinnedStatusIds || []).filter(
(statusId) => this.timeline.statusesObject[statusId],
)
},
newStatusCount() {
return this.timeline.newStatusCount
},
showLoadButton() {
return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0
},
loadButtonString() {
if (this.timeline.flushMarker !== 0) {
return this.$t('timeline.reload')
} else {
return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
}
},
mobileLoadButtonString() {
if (this.timeline.flushMarker !== 0) {
return '+'
} else {
return this.newStatusCount > 99 ? '∞' : this.newStatusCount
}
},
classes() {
let rootClasses = !this.embedded
? ['panel', 'panel-default']
: ['-embedded']
if (this.blockingClicks)
rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: rootClasses,
header: ['timeline-heading'].concat(
!this.embedded ? ['panel-heading', '-sticky'] : ['panel-body'],
),
body: ['timeline-body'].concat(
!this.embedded ? ['panel-body'] : ['panel-body'],
),
footer: ['timeline-footer'].concat(
!this.embedded ? ['panel-footer'] : ['panel-body'],
),
}
},
// id map of statuses which need to be hidden in the main list due to pinning logic
pinnedStatusIdsObject() {
return keyBy(this.pinnedStatusIds)
},
statusesToDisplay() {
const amount = this.timeline.visibleStatuses.length
const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80))
const nonPinnedIndex =
this.virtualScrollIndex - this.filteredPinnedStatusIds.length
const min = Math.max(0, nonPinnedIndex - statusesPerSide)
const max = Math.min(amount, nonPinnedIndex + statusesPerSide)
return this.timeline.visibleStatuses.slice(min, max).map((_) => _.id)
},
virtualScrollingEnabled() {
return useMergedConfigStore().mergedConfig.virtualScrolling
},
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
created() {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
const showImmediately = this.timeline.visibleStatuses.length === 0
window.addEventListener('scroll', this.handleScroll)
if (store.state.api.fetchers[this.timelineName]) {
return false
}
timelineFetcher.fetchAndUpdate({
store,
credentials,
timeline: this.timelineName,
showImmediately,
userId: this.userId,
listId: this.listId,
statusId: this.statusId,
bookmarkFolderId: this.bookmarkFolderId,
tag: this.tag,
})
},
mounted() {
- if (typeof document.hidden !== 'undefined') {
+ if (document.hidden !== undefined) {
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.unfocused = document.hidden
}
window.addEventListener('keydown', this.handleShortKey)
setTimeout(this.determineVisibleStatuses, 250)
},
unmounted() {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('keydown', this.handleShortKey)
- if (typeof document.hidden !== 'undefined')
+ if (document.hidden !== undefined)
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.$store.commit('setLoading', {
timeline: this.timelineName,
value: false,
})
},
methods: {
stopBlockingClicks: debounce(function () {
this.blockingClicks = false
}, 1000),
blockClicksTemporarily() {
if (!this.blockingClicks) {
this.blockingClicks = true
}
this.stopBlockingClicks()
},
handleShortKey(e) {
// Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
if (e.key === '.') this.showNewStatuses()
},
showNewStatuses() {
if (this.timeline.flushMarker !== 0) {
this.$store.commit('clearTimeline', {
timeline: this.timelineName,
excludeUserId: true,
})
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
if (this.timelineName === 'user') {
this.$store.dispatch('fetchPinnedStatuses', this.userId)
}
this.fetchOlderStatuses()
} else {
this.blockClicksTemporarily()
this.$store.commit('showNewStatuses', { timeline: this.timelineName })
this.paused = false
}
window.scrollTo({ top: 0 })
},
fetchOlderStatuses: throttle(
function () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setLoading', { timeline: this.timelineName, value: true })
timelineFetcher
.fetchAndUpdate({
store,
credentials,
timeline: this.timelineName,
older: true,
showImmediately: true,
userId: this.userId,
listId: this.listId,
statusId: this.statusId,
bookmarkFolderId: this.bookmarkFolderId,
tag: this.tag,
})
.then(({ statuses }) => {
if (statuses && statuses.length === 0) {
this.bottomedOut = true
}
})
.finally(() =>
store.commit('setLoading', {
timeline: this.timelineName,
value: false,
}),
)
},
1000,
this,
),
determineVisibleStatuses() {
if (!this.$refs.timeline) return
if (!this.virtualScrollingEnabled) return
const statuses = this.$refs.timeline.children
const cappedScrollIndex = Math.max(
0,
Math.min(this.virtualScrollIndex, statuses.length - 1),
)
if (statuses.length === 0) return
const height = Math.max(document.body.offsetHeight, window.pageYOffset)
const centerOfScreen = window.pageYOffset + window.innerHeight * 0.5
// Start from approximating the index of some visible status by using the
// the center of the screen on the timeline.
let approxIndex = Math.floor(statuses.length * (centerOfScreen / height))
let err = statuses[approxIndex].getBoundingClientRect().y
// if we have a previous scroll index that can be used, test if it's
// closer than the previous approximation, use it if so
const virtualScrollIndexY =
statuses[cappedScrollIndex].getBoundingClientRect().y
if (Math.abs(err) > virtualScrollIndexY) {
approxIndex = cappedScrollIndex
err = virtualScrollIndexY
}
// if the status is too far from viewport, check the next/previous ones if
// they happen to be better
while (err < -20 && approxIndex < statuses.length - 1) {
err += statuses[approxIndex].offsetHeight
approxIndex++
}
while (err > window.innerHeight + 100 && approxIndex > 0) {
approxIndex--
err -= statuses[approxIndex].offsetHeight
}
// this status is now the center point for virtual scrolling and visible
// statuses will be nearby statuses before and after it
this.virtualScrollIndex = approxIndex
},
scrollLoad() {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
this.timeline.loading === false &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
this.fetchOlderStatuses()
}
},
handleScroll: throttle(function (e) {
this.determineVisibleStatuses()
this.scrollLoad(e)
}, 200),
handleVisibilityChange() {
this.unfocused = document.hidden
},
},
watch: {
filteredVisibleStatuses() {
this.determineVisibleStatuses()
},
newStatusCount(count) {
if (!useMergedConfigStore().mergedConfig.streaming) {
return
}
if (count > 0) {
// only 'stream' them when you're scrolled to the top
const doc = document.documentElement
const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0)
if (
top < 15 &&
!this.paused &&
!(
this.unfocused &&
useMergedConfigStore().mergedConfig.pauseOnUnfocused
)
) {
this.showNewStatuses()
} else {
this.paused = true
}
}
},
},
}
export default Timeline
diff --git a/src/components/video_attachment/video_attachment.js b/src/components/video_attachment/video_attachment.js
index 6904091694..5e702723d2 100644
--- a/src/components/video_attachment/video_attachment.js
+++ b/src/components/video_attachment/video_attachment.js
@@ -1,53 +1,53 @@
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const VideoAttachment = {
props: ['attachment', 'controls'],
data() {
return {
blocksSuspend: false,
// Start from true because removing "loop" property seems buggy in Vue
hasAudio: true,
}
},
computed: {
loopVideo() {
if (useMergedConfigStore().mergedConfig.loopVideoSilentOnly) {
return !this.hasAudio
}
return useMergedConfigStore().mergedConfig.loopVideo
},
},
methods: {
onPlaying(e) {
this.setHasAudio(e)
if (this.loopVideo) {
this.$emit('play', { looping: true })
return
}
this.$emit('play')
},
onPaused() {
this.$emit('pause')
},
setHasAudio(e) {
const target = e.srcElement || e.target
// If hasAudio is false, we've already marked this video to not have audio,
// a video can't gain audio out of nowhere so don't bother checking again.
if (!this.hasAudio) return
- if (typeof target.webkitAudioDecodedByteCount !== 'undefined') {
+ if (target.webkitAudioDecodedByteCount !== undefined) {
// non-zero if video has audio track
if (target.webkitAudioDecodedByteCount > 0) return
}
- if (typeof target.mozHasAudio !== 'undefined') {
+ if (target.mozHasAudio !== undefined) {
// true if video has audio track
if (target.mozHasAudio) return
}
- if (typeof target.audioTracks !== 'undefined') {
+ if (target.audioTracks !== undefined) {
if (target.audioTracks.length > 0) return
}
this.hasAudio = false
},
},
}
export default VideoAttachment
diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js
index 9840f97263..26f279764f 100644
--- a/src/services/color_convert/color_convert.js
+++ b/src/services/color_convert/color_convert.js
@@ -1,297 +1,297 @@
import { contrastRatio, convert, invertLightness } from 'chromatism'
// useful for visualizing color when debugging
// const consoleColor = (color) => console.debug('%c##########', 'background: ' + color + '; color: ' + color)
/**
* Convert r, g, b values into hex notation. All components are [0-255]
*
* @param {Number|String|Object} r - Either red component, {r,g,b} object, or hex string
* @param {Number} [g] - Green component
* @param {Number} [b] - Blue component
*/
export const rgb2hex = (r, g, b) => {
if (r === null || typeof r === 'undefined') {
return undefined
}
// TODO: clean up this mess
if (r[0] === '#' || r === 'transparent') {
return r
}
if (typeof r === 'object') {
;({ r, g, b } = r)
}
;[r, g, b] = [r, g, b].map((val) => {
val = Math.ceil(val)
val = val < 0 ? 0 : val
val = val > 255 ? 255 : val
return val
})
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
/**
* Converts 8-bit RGB component into linear component
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml
* https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation
*
* @param {Number} bit - color component [0..255]
* @returns {Number} linear component [0..1]
*/
const c2linear = (bit) => {
// W3C gives 0.03928 while wikipedia states 0.04045
// what those magical numbers mean - I don't know.
// something about gamma-correction, i suppose.
// Sticking with W3C example.
const c = bit / 255
if (c < 0.03928) {
return c / 12.92
} else {
return Math.pow((c + 0.055) / 1.055, 2.4)
}
}
/**
* Calculates relative luminance for given color
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml
*
* @param {Object} srgb - sRGB color
* @returns {Number} relative luminance
*/
export const relativeLuminance = (srgb) => {
const r = c2linear(srgb.r)
const g = c2linear(srgb.g)
const b = c2linear(srgb.b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/**
* Generates color ratio between two colors. Order is unimporant
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
*
* @param {Object} a - sRGB color
* @param {Object} b - sRGB color
* @returns {Number} color ratio
*/
export const getContrastRatio = (a, b) => {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
const [l1, l2] = la > lb ? [la, lb] : [lb, la]
return (l1 + 0.05) / (l2 + 0.05)
}
/**
* Same as `getContrastRatio` but for multiple layers in-between
*
* @param {Object} text - text color (topmost layer)
* @param {[Object, Number]} layers[] - layers between text and bedrock
* @param {Object} bedrock - layer at the very bottom
*/
export const getContrastRatioLayers = (text, layers, bedrock) => {
return getContrastRatio(alphaBlendLayers(bedrock, layers), text)
}
/**
* Blending of two solid colors with a user-defined operator: origin +- value
*
* @param {Object} origin - base color
* @param {Object} value - modification argument
* @param {string} operator - math operator to use
*/
export const arithmeticBlend = (origin, value, operator) => {
const func = (a, b) => {
switch (operator) {
case '+':
return Math.min(a + b, 255)
case '-':
return Math.max(a - b, 0)
default:
return a
}
}
return {
r: func(origin.r, value.r),
g: func(origin.g, value.g),
b: func(origin.b, value.b),
}
}
/**
* This performs alpha blending between solid background and semi-transparent foreground
*
* @param {Object} fg - top layer color
* @param {Number} fga - top layer's alpha
* @param {Object} bg - bottom layer color
* @returns {Object} sRGB of resulting color
*/
export const alphaBlend = (fg, fga, bg) => {
if (fga === 1 || typeof fga === 'undefined') {
return fg
}
// Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
// for opaque bg and transparent fg
return {
r: fg.r * fga + bg.r * (1 - fga),
g: fg.g * fga + bg.g * (1 - fga),
b: fg.b * fga + bg.b * (1 - fga),
}
}
/**
* Same as `alphaBlend` but for multiple layers in-between
*
* @param {Object} bedrock - layer at the very bottom
* @param {[Object, Number]} layers[] - layers between text and bedrock
*/
export const alphaBlendLayers = (bedrock, layers) =>
layers.reduce((acc, [color, opacity]) => {
return alphaBlend(color, opacity, acc)
}, bedrock)
export const invert = (rgb) => {
return {
r: 255 - rgb.r,
g: 255 - rgb.g,
b: 255 - rgb.b,
}
}
/**
* Converts #rrggbb hex notation into an {r, g, b} object
*
* @param {String} hex - #rrggbb string
* @returns {Object} rgb representation of the color, values are 0-255
*/
export const hex2rgb = (hex) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null
}
/**
* Old somewhat weird function for mixing two colors together
*
* @param {Object} a - one color (rgb)
* @param {Object} b - other color (rgb)
* @returns {Object} result
*/
export const mixrgb = (a, b) => {
return {
r: (a.r + b.r) / 2,
g: (a.g + b.g) / 2,
b: (a.b + b.b) / 2,
}
}
/**
* Converts rgb object into a CSS rgba() color
*
* @param {Object} color - rgb
* @returns {String} CSS rgba() color
*/
export const rgba2css = function (rgba) {
const base = {
r: 0,
g: 0,
b: 0,
a: 1,
}
if (rgba !== null) {
if (rgba.r !== undefined && !isNaN(rgba.r)) {
base.r = rgba.r
}
if (rgba.g !== undefined && !isNaN(rgba.g)) {
base.g = rgba.g
}
if (rgba.b !== undefined && !isNaN(rgba.b)) {
base.b = rgba.b
}
if (rgba.a !== undefined && !isNaN(rgba.a)) {
base.a = rgba.a
}
} else {
base.r = 255
base.g = 255
base.b = 255
}
return `rgba(${Math.floor(base.r)}, ${Math.floor(base.g)}, ${Math.floor(base.b)}, ${base.a})`
}
/**
* Get text color for given background color and intended text color
* This checks if text and background don't have enough color and inverts
* text color's lightness if needed. If text color is still not enough it
* will fall back to black or white
*
* @param {Object} bg - background color
* @param {Object} text - intended text color
* @param {Boolean} preserve - try to preserve intended text color's hue/saturation (i.e. no BW)
*/
export const getTextColor = function (bg, text, preserve) {
const originalContrast = getContrastRatio(bg, text)
if (!preserve) {
if (originalContrast < 4.5) {
// B&W
return contrastRatio(bg, text).rgb
}
}
const originalColor = convert(text).hex
const invertedColor = invertLightness(originalColor).hex
const invertedContrast = getContrastRatio(bg, convert(invertedColor).rgb)
let workColor
if (invertedContrast > originalContrast) {
workColor = invertedColor
} else {
workColor = originalColor
}
let contrast = getContrastRatio(bg, text)
const result = convert(rgb2hex(workColor)).hsl
const delta = result.l >= 50 ? 1 : -1
const multiplier = 1
while (contrast < 4.5 && result.l > 20 && result.l < 80) {
result.l += delta * multiplier
result.l = Math.min(100, Math.max(0, result.l))
contrast = getContrastRatio(bg, convert(result).rgb)
}
- const base = typeof text.a !== 'undefined' ? { a: text.a } : {}
+ const base = text.a !== undefined ? { a: text.a } : {}
return Object.assign(convert(result).rgb, base)
}
/**
* Converts color to CSS Color value
*
* @param {Object|String} input - color
* @param {Number} [a] - alpha value
* @returns {String} a CSS Color value
*/
export const getCssColor = (input, a) => {
let rgb = {}
if (typeof input === 'object') {
rgb = input
} else if (typeof input === 'string') {
if (input.startsWith('#')) {
rgb = hex2rgb(input)
} else {
return input
}
}
return rgba2css({ ...rgb, a })
}
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 94a5681bee..8721189d13 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,426 +1,426 @@
import { parseLinkHeader } from '@web3-storage/parse-link-header'
import escapeHtml from 'escape-html'
import { unescape as lodashUnescape } from 'lodash'
import punycode from 'punycode.js'
import { fileType } from '../file_type/file_type.service.js'
import { isStatusNotification } from '../notification_utils/notification_utils.js'
/** NOTICE! **
* Do not initialize UI-generated data here.
* It will override existing data.
*
* i.e. user.pinnedStatusIds was set to [] here
* UI code would update it with data but upon next user fetch
* it would be reverted back to []
*/
export const parseUser = (data) => {
const output = {}
output._original = data // used for server-side settings
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = !Object.hasOwn(data, 'avatar')
output.inLists = null
output.id = String(data.id)
output.screen_name = data.acct
output.fqn = data.fqn
output.statusnet_profile_url = data.url
if (Object.hasOwn(data, 'mute_expires_at')) {
output.mute_expires_at =
data.mute_expires_at == null ? false : data.mute_expires_at
}
if (Object.hasOwn(data, 'block_expires_at')) {
output.block_expires_at =
data.block_expires_at == null ? false : data.block_expires_at
}
// There's nothing else to get
if (mastoShort) {
return output
}
output.emoji = data.emojis
output.name = escapeHtml(data.display_name)
output.name_html = output.name
output.name_unescaped = data.display_name
output.description = data.note
// TODO cleanup this shit, output.description is overriden with source data
output.description_html = data.note
output.fields = data.fields
output.fields_html = data.fields.map((field) => {
return {
name: escapeHtml(field.name),
value: field.value,
}
})
output.fields_text = data.fields.map((field) => {
return {
name: unescape(field.name.replace(/<[^>]*>/g, '')),
value: unescape(field.value.replace(/<[^>]*>/g, '')),
}
})
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
// Same, utilize header_static?
output.cover_photo = data.header
output.friends_count = data.following_count
output.bot = data.bot
output.privileges = []
if (data.pleroma) {
if (data.pleroma.settings_store) {
output.storage = data.pleroma.settings_store['pleroma-fe']
output.user_highlight = data.pleroma.settings_store['user_highlight']
}
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.favicon = data.pleroma.favicon
output.token = data.pleroma.chat_token
if (relationship) {
output.relationship = relationship
}
output.allow_following_move = data.pleroma.allow_following_move
output.hide_favorites = data.pleroma.hide_favorites
output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count
output.hide_followers_count = data.pleroma.hide_followers_count
output.rights = {
moderator: data.pleroma.is_moderator,
admin: data.pleroma.is_admin,
}
// TODO: Clean up in UI? This is duplication from what BE does for qvitterapi
if (output.rights.admin) {
output.role = 'admin'
} else if (output.rights.moderator) {
output.role = 'moderator'
} else {
output.role = 'member'
}
output.birthday = data.pleroma.birthday
if (data.pleroma.privileges) {
output.privileges = new Set(data.pleroma.privileges)
} else if (data.pleroma.is_admin) {
output.privileges = new Set([
'users_read',
'users_manage_invites',
'users_manage_activation_state',
'users_manage_tags',
'users_manage_credentials',
'users_delete',
'messages_read',
'messages_delete',
'instances_delete',
'reports_manage_reports',
'moderation_log_read',
'announcements_manage_announcements',
'emoji_manage_emoji',
'statistics_read',
])
} else if (data.pleroma.is_moderator) {
output.privileges = new Set(['messages_delete', 'reports_manage_reports'])
} else {
output.privileges = new Set()
}
}
if (data.source) {
output.description = data.source.note
output.default_scope = data.source.privacy
output.fields = data.source.fields
if (data.source.pleroma) {
output.no_rich_text = data.source.pleroma.no_rich_text
output.show_role =
typeof data.source.pleroma.show_role === 'boolean'
? data.source.pleroma.show_role
: true
output.discoverable = data.source.pleroma.discoverable
output.show_birthday = data.pleroma.show_birthday
output.actor_type = data.source.pleroma.actor_type
}
}
// TODO: handle is_local
output.is_local = !output.screen_name.includes('@')
output.created_at = new Date(data.created_at)
output.locked = data.locked
output.last_status_at = new Date(data.last_status_at)
output.followers_count = data.followers_count
output.statuses_count = data.statuses_count
if (data.pleroma) {
output.follow_request_count = data.pleroma.follow_request_count
output.tags = new Set(data.pleroma.tags)
// deactivated was changed to is_active in Pleroma 2.3.0
// so check if is_active is present
output.deactivated =
- typeof data.pleroma.is_active !== 'undefined'
+ data.pleroma.is_active !== undefined
? !data.pleroma.is_active // new backend
: data.pleroma.deactivated // old backend
output.notification_settings = data.pleroma.notification_settings
output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || new Set()
output.rights = output.rights || {}
output.notification_settings = output.notification_settings || {}
// Convert punycode to unicode for UI
output.screen_name_ui = output.screen_name
if (output.screen_name && output.screen_name.includes('@')) {
const parts = output.screen_name.split('@')
const unicodeDomain = punycode.toUnicode(parts[1])
if (unicodeDomain !== parts[1]) {
// Add some identifier so users can potentially spot spoofing attempts:
// lain.com and xn--lin-6cd.com would appear identical otherwise.
output.screen_name_ui_contains_non_ascii = true
output.screen_name_ui = [parts[0], unicodeDomain].join('@')
} else {
output.screen_name_ui_contains_non_ascii = false
}
}
return output
}
export const parseAttachment = (data) => {
const output = {}
// Not exactly same...
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta // not present in BE yet
output.id = data.id
if (data.type !== 'unknown') {
// treat gifv like it is "video"
output.type = data.type === 'gifv' ? 'video' : data.type
} else {
output.type = fileType(output.mimetype)
}
output.url = data.url
output.large_thumb_url = data.preview_url
output.description = lodashUnescape(data.description)
return output
}
export const parseSource = (data) => {
const output = {}
output.text = data.text
output.spoiler_text = data.spoiler_text
output.content_type = data.content_type
return output
}
export const parseStatus = (data) => {
const output = {}
output.favorited = data.favourited
output.fave_num = data.favourites_count
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.bookmarked = data.bookmarked
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
output.raw_html = data.content
output.emojis = data.emojis
output.tags = data.tags
output.edited_at = data.edited_at
const { pleroma } = data
if (data.pleroma) {
output.text = pleroma.content
? data.pleroma.content['text/plain']
: data.content
output.summary = pleroma.spoiler_text
? data.pleroma.spoiler_text['text/plain']
: data.spoiler_text
output.statusnet_conversation_id = data.pleroma.conversation_id
output.is_local = pleroma.local
output.in_reply_to_screen_name = pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible =
pleroma.parent_visible === undefined ? true : pleroma.parent_visible
output.quote_visible = pleroma.quote_visible || true
output.quotes_count = pleroma.quotes_count
output.bookmark_folder_id = pleroma.bookmark_folder
} else {
output.text = data.content
output.summary = data.spoiler_text
}
const quoteRaw = pleroma?.quote || data.quote
const quoteData = quoteRaw ? parseStatus(quoteRaw) : undefined
output.quote = quoteData
output.quote_id =
data.quote?.id ?? data.quote_id ?? quoteData?.id ?? pleroma?.quote_id
output.quote_url = data.quote?.url ?? quoteData?.url ?? pleroma?.quote_url
output.in_reply_to_status_id = data.in_reply_to_id
output.in_reply_to_user_id = data.in_reply_to_account_id
output.replies_count = data.replies_count
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.reblog)
}
output.summary_raw_html = escapeHtml(data.spoiler_text)
output.external_url = data.uri || data.url
output.poll = data.poll
if (output.poll) {
output.poll.options = (output.poll.options || []).map((field) => ({
...field,
title_html: escapeHtml(field.title),
}))
}
output.pinned = data.pinned
output.muted = data.muted
output.id = String(data.id)
output.visibility = data.visibility
output.card = data.card
output.created_at = new Date(data.created_at)
// Converting to string, the right way.
output.in_reply_to_status_id = output.in_reply_to_status_id
? String(output.in_reply_to_status_id)
: null
output.in_reply_to_user_id = output.in_reply_to_user_id
? String(output.in_reply_to_user_id)
: null
output.user = parseUser(data.account)
output.attentions = (data.mentions || []).map(parseUser)
output.attachments = (data.media_attachments || []).map(parseAttachment)
const retweetedStatus = data.reblog
if (retweetedStatus) {
output.retweeted_status = parseStatus(retweetedStatus)
}
output.favoritedBy = []
output.rebloggedBy = []
if (Object.hasOwn(data, 'originalStatus')) {
Object.assign(output, data.originalStatus)
}
return output
}
export const parseNotification = (data) => {
const mastoDict = {
favourite: 'like',
reblog: 'repeat',
}
const output = {}
output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen
// TODO: null check should be a temporary fix, I guess.
// Investigate why backend does this.
output.status =
isStatusNotification(output.type) && data.status !== null
? parseStatus(data.status)
: null
output.target = output.type !== 'move' ? null : parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
output.emoji_url = data.emoji_url
if (data.report) {
output.report = data.report
output.report.content = data.report.content
output.report.acct = parseUser(data.report.account)
output.report.actor = parseUser(data.report.actor)
output.report.statuses = data.report.statuses.map(parseStatus)
}
output.created_at = new Date(data.created_at)
output.id = parseInt(data.id)
return output
}
export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
const flakeId = opts.flakeId
const parsedLinkHeader = parseLinkHeader(linkHeader)
if (!parsedLinkHeader) return
const maxId = parsedLinkHeader.next?.max_id
const minId = parsedLinkHeader.prev?.min_id
return {
maxId: flakeId ? maxId : parseInt(maxId, 10),
minId: flakeId ? minId : parseInt(minId, 10),
}
}
export const parseChat = (chat) => {
const output = {}
output.id = chat.id
output.account = parseUser(chat.account)
output.unread = chat.unread
output.lastMessage = parseChatMessage(chat.last_message)
output.updated_at = new Date(chat.updated_at)
return output
}
export const parseChatMessage = (message) => {
if (!message) {
return
}
if (message.isNormalized) {
return message
}
const output = message
output.id = message.id
output.created_at = new Date(message.created_at)
output.chat_id = message.chat_id
output.emojis = message.emojis
output.content = message.content
if (message.attachment) {
output.attachments = [parseAttachment(message.attachment)]
} else {
output.attachments = []
}
output.pending = !!message.pending
output.error = false
output.idempotency_key = message.idempotency_key
output.isNormalized = true
return output
}
diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js
index 7dcb97fed4..201cd9ab4f 100644
--- a/src/services/theme_data/theme_data.service.js
+++ b/src/services/theme_data/theme_data.service.js
@@ -1,840 +1,840 @@
import { brightness, contrastRatio, convert } from 'chromatism'
import {
alphaBlendLayers,
getCssColor,
getTextColor,
relativeLuminance,
rgb2hex,
rgba2css,
} from '../color_convert/color_convert.js'
import { DEFAULT_OPACITY, LAYERS, SLOT_INHERITANCE } from './pleromafe.js'
/*
* # What's all this?
* Here be theme engine for pleromafe. All of this supposed to ease look
* and feel customization, making widget styles and make developer's life
* easier when it comes to supporting themes. Like many other theme systems
* it operates on color definitions, or "slots" - for example you define
* "button" color slot and then in UI component Button's CSS you refer to
* it as a CSS3 Variable.
*
* Some applications allow you to customize colors for certain things.
* Some UI toolkits allow you to define colors for each type of widget.
* Most of them are pretty barebones and have no assistance for common
* problems and cases, and in general themes themselves are very hard to
* maintain in all aspects. This theme engine tries to solve all of the
* common problems with themes.
*
* You don't have redefine several similar colors if you just want to
* change one color - all color slots are derived from other ones, so you
* can have at least one or two "basic" colors defined and have all other
* components inherit and modify basic ones.
*
* You don't have to test contrast ratio for colors or pick text color for
* each element even if you have light-on-dark elements in dark-on-light
* theme.
*
* You don't have to maintain order of code for inheriting slots from othet
* slots - dependency graph resolving does it for you.
*/
/* This indicates that this version of code outputs similar theme data and
* should be incremented if output changes - for instance if getTextColor
* function changes and older themes no longer render text colors as
* author intended previously.
*/
export const CURRENT_VERSION = 3
export const getLayersArray = (layer, data = LAYERS) => {
const array = [layer]
let parent = data[layer]
while (parent) {
array.unshift(parent)
parent = data[parent]
}
return array
}
export const getLayers = (
layer,
variant = layer,
opacitySlot,
colors,
opacity,
) => {
return getLayersArray(layer).map((currentLayer) => [
currentLayer === layer ? colors[variant] : colors[currentLayer],
currentLayer === layer ? opacity[opacitySlot] || 1 : opacity[currentLayer],
])
}
const getDependencies = (key, inheritance) => {
const data = inheritance[key]
if (typeof data === 'string' && data.startsWith('--')) {
return [data.substring(2)]
} else {
if (data === null) return []
const { depends, layer, variant } = data
const layerDeps = layer
? getLayersArray(layer).map((currentLayer) => {
return currentLayer === layer ? variant || layer : currentLayer
})
: []
if (Array.isArray(depends)) {
return [...depends, ...layerDeps]
} else {
return [...layerDeps]
}
}
}
/**
* Sorts inheritance object topologically - dependant slots come after
* dependencies
*
* @property {Object} inheritance - object defining the nodes
* @property {Function} getDeps - function that returns dependencies for
* given value and inheritance object.
* @returns {String[]} keys of inheritance object, sorted in topological
* order. Additionally, dependency-less nodes will always be first in line
*/
export const topoSort = (
inheritance = SLOT_INHERITANCE,
getDeps = getDependencies,
) => {
// This is an implementation of https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
const allKeys = Object.keys(inheritance)
const whites = new Set(allKeys)
const grays = new Set()
const blacks = new Set()
const unprocessed = [...allKeys]
const output = []
const step = (node) => {
if (whites.has(node)) {
// Make node "gray"
whites.delete(node)
grays.add(node)
// Do step for each node connected to it (one way)
getDeps(node, inheritance).forEach(step)
// Make node "black"
grays.delete(node)
blacks.add(node)
// Put it into the output list
output.push(node)
} else if (grays.has(node)) {
output.push(node)
} else if (blacks.has(node)) {
// do nothing
} else {
throw new Error('Unintended condition in topoSort!')
}
}
while (unprocessed.length > 0) {
step(unprocessed.pop())
}
// The index thing is to make sorting stable on browsers
// where Array.sort() isn't stable
return output
.map((data, index) => ({ data, index }))
.sort(({ data: a, index: ai }, { data: b, index: bi }) => {
const depsA = getDeps(a, inheritance).length
const depsB = getDeps(b, inheritance).length
if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return ai - bi
if (depsA === 0 && depsB !== 0) return -1
if (depsB === 0 && depsA !== 0) return 1
return 0 // failsafe, shouldn't happen?
})
.map(({ data }) => data)
}
const expandSlotValue = (value) => {
if (typeof value === 'object') return value
return {
depends: value.startsWith('--') ? [value.substring(2)] : [],
default: value.startsWith('#') ? value : undefined,
}
}
/**
* retrieves opacity slot for given slot. This goes up the depenency graph
* to find which parent has opacity slot defined for it.
* TODO refactor this
*/
export const getOpacitySlot = (
k,
inheritance = SLOT_INHERITANCE,
getDeps = getDependencies,
) => {
const value = expandSlotValue(inheritance[k])
if (value.opacity === null) return
if (value.opacity) return value.opacity
const findInheritedOpacity = (key, visited = [k]) => {
const depSlot = getDeps(key, inheritance)[0]
if (depSlot === undefined) return
const dependency = inheritance[depSlot]
if (dependency === undefined) return
if (dependency.opacity || dependency === null) {
return dependency.opacity
} else if (dependency.depends && visited.includes(depSlot)) {
return findInheritedOpacity(depSlot, [...visited, depSlot])
} else {
return null
}
}
if (value.depends) {
return findInheritedOpacity(k)
}
}
/**
* retrieves layer slot for given slot. This goes up the depenency graph
* to find which parent has opacity slot defined for it.
* this is basically copypaste of getOpacitySlot except it checks if key is
* in LAYERS
* TODO refactor this
*/
export const getLayerSlot = (
k,
inheritance = SLOT_INHERITANCE,
getDeps = getDependencies,
) => {
const value = expandSlotValue(inheritance[k])
if (LAYERS[k]) return k
if (value.layer === null) return
if (value.layer) return value.layer
const findInheritedLayer = (key, visited = [k]) => {
const depSlot = getDeps(key, inheritance)[0]
if (depSlot === undefined) return
const dependency = inheritance[depSlot]
if (dependency === undefined) return
if (dependency.layer || dependency === null) {
return dependency.layer
} else if (dependency.depends) {
return findInheritedLayer(dependency, [...visited, depSlot])
} else {
return null
}
}
if (value.depends) {
return findInheritedLayer(k)
}
}
/**
* topologically sorted SLOT_INHERITANCE
*/
export const SLOT_ORDERED = topoSort(
Object.entries(SLOT_INHERITANCE)
.sort(
([, aV], [, bV]) =>
((aV && aV.priority) || 0) - ((bV && bV.priority) || 0),
)
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}),
)
/**
* All opacity slots used in color slots, their default values and affected
* color slots.
*/
export const OPACITIES = Object.entries(SLOT_INHERITANCE).reduce((acc, [k]) => {
const opacity = getOpacitySlot(k, SLOT_INHERITANCE, getDependencies)
if (opacity) {
return {
...acc,
[opacity]: {
defaultValue: DEFAULT_OPACITY[opacity] || 1,
affectedSlots: [
...((acc[opacity] && acc[opacity].affectedSlots) || []),
k,
],
},
}
} else {
return acc
}
}, {})
/**
* Handle dynamic color
*/
export const computeDynamicColor = (sourceColor, getColor, mod) => {
if (typeof sourceColor !== 'string' || !sourceColor.startsWith('--'))
return sourceColor
let targetColor = null
// Color references other color
const [variable, modifier] = sourceColor.split(/,/g).map((str) => str.trim())
const variableSlot = variable.substring(2)
targetColor = getColor(variableSlot)
if (modifier) {
targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb
}
return targetColor
}
/**
* THE function you want to use. Takes provided colors and opacities
* value and uses inheritance data to figure out color needed for the slot.
*/
export const getColors = (sourceColors, sourceOpacity) =>
SLOT_ORDERED.reduce(
({ colors, opacity }, key) => {
const sourceColor = sourceColors[key]
const value = expandSlotValue(SLOT_INHERITANCE[key])
const deps = getDependencies(key, SLOT_INHERITANCE)
const isTextColor = !!value.textColor
const variant = value.variant || value.layer
let backgroundColor = null
if (isTextColor) {
backgroundColor = alphaBlendLayers(
{
...(colors[deps[0]] || convert(sourceColors[key] || '#FF00FF').rgb),
},
getLayers(
getLayerSlot(key) || 'bg',
variant || 'bg',
getOpacitySlot(variant),
colors,
opacity,
),
)
} else if (variant && variant !== key) {
backgroundColor = colors[variant] || convert(sourceColors[variant]).rgb
} else {
backgroundColor = colors.bg || convert(sourceColors.bg)
}
const isLightOnDark = relativeLuminance(backgroundColor) < 0.5
const mod = isLightOnDark ? 1 : -1
let outputColor = null
if (sourceColor) {
// Color is defined in source color
let targetColor = sourceColor
if (targetColor === 'transparent') {
// We take only layers below current one
const layers = getLayers(
getLayerSlot(key),
key,
getOpacitySlot(key) || key,
colors,
opacity,
).slice(0, -1)
targetColor = {
...alphaBlendLayers(convert('#FF00FF').rgb, layers),
a: 0,
}
} else if (
typeof sourceColor === 'string' &&
sourceColor.startsWith('--')
) {
targetColor = computeDynamicColor(
sourceColor,
(variableSlot) =>
colors[variableSlot] || sourceColors[variableSlot],
mod,
)
} else if (
typeof sourceColor === 'string' &&
sourceColor.startsWith('#')
) {
targetColor = convert(targetColor).rgb
}
outputColor = { ...targetColor }
} else if (value.default) {
// same as above except in object form
outputColor = convert(value.default).rgb
} else {
// calculate color
const defaultColorFunc = (mod, dep) => ({ ...dep })
const colorFunc = value.color || defaultColorFunc
if (value.textColor) {
if (value.textColor === 'bw') {
outputColor = contrastRatio(backgroundColor).rgb
} else {
let color = { ...colors[deps[0]] }
if (value.color) {
color = colorFunc(mod, ...deps.map((dep) => ({ ...colors[dep] })))
}
outputColor = getTextColor(
backgroundColor,
{ ...color },
value.textColor === 'preserve',
)
}
} else {
// background color case
outputColor = colorFunc(
mod,
...deps.map((dep) => ({ ...colors[dep] })),
)
}
}
if (!outputColor) {
throw new Error("Couldn't generate color for " + key)
}
const opacitySlot = value.opacity || getOpacitySlot(key)
const ownOpacitySlot = value.opacity
if (ownOpacitySlot === null) {
outputColor.a = 1
} else if (sourceColor === 'transparent') {
outputColor.a = 0
} else {
const opacityOverriden =
ownOpacitySlot && sourceOpacity[opacitySlot] !== undefined
const dependencySlot = deps[0]
const dependencyColor = dependencySlot && colors[dependencySlot]
if (
!ownOpacitySlot &&
dependencyColor &&
!value.textColor &&
ownOpacitySlot !== null
) {
// Inheriting color from dependency (weird, i know)
// except if it's a text color or opacity slot is set to 'null'
outputColor.a = dependencyColor.a
} else if (!dependencyColor && !opacitySlot) {
// Remove any alpha channel if no dependency and no opacitySlot found
delete outputColor.a
} else {
// Otherwise try to assign opacity
if (dependencyColor && dependencyColor.a === 0) {
// transparent dependency shall make dependents transparent too
outputColor.a = 0
} else {
// Otherwise check if opacity is overriden and use that or default value instead
outputColor.a = Number(
opacityOverriden
? sourceOpacity[opacitySlot]
: (OPACITIES[opacitySlot] || {}).defaultValue,
)
}
}
}
if (Number.isNaN(outputColor.a) || outputColor.a === undefined) {
outputColor.a = 1
}
if (opacitySlot) {
return {
colors: { ...colors, [key]: outputColor },
opacity: { ...opacity, [opacitySlot]: outputColor.a },
}
} else {
return {
colors: { ...colors, [key]: outputColor },
opacity,
}
}
},
{ colors: {}, opacity: {} },
)
export const composePreset = (colors, radii, shadows, fonts) => {
return {
rules: {
...shadows.rules,
...colors.rules,
...radii.rules,
...fonts.rules,
},
theme: {
...shadows.theme,
...colors.theme,
...radii.theme,
...fonts.theme,
},
}
}
export const generatePreset = (input) => {
const colors = generateColors(input)
return composePreset(
colors,
generateRadii(input),
generateShadows(input, colors.theme.colors, colors.mod),
generateFonts(input),
)
}
export const getCssShadow = (input, usesDropShadow) => {
if (input.length === 0) {
return 'none'
}
return input
.filter((_) => (usesDropShadow ? _.inset : _))
.map((shad) =>
[shad.x, shad.y, shad.blur, shad.spread]
.map((_) => _ + 'px')
.concat([
getCssColor(shad.color, shad.alpha),
shad.inset ? 'inset' : '',
])
.join(' '),
)
.join(', ')
}
export const getCssShadowFilter = (input) => {
if (input.length === 0) {
return 'none'
}
return (
input
// drop-shadow doesn't support inset or spread
.filter((shad) => !shad.inset && Number(shad.spread) === 0)
.map((shad) =>
[
shad.x,
shad.y,
// drop-shadow's blur is twice as strong compared to box-shadow
shad.blur / 2,
]
.map((_) => _ + 'px')
.concat([getCssColor(shad.color, shad.alpha)])
.join(' '),
)
.map((_) => `drop-shadow(${_})`)
.join(' ')
)
}
export const generateColors = (themeData) => {
const sourceColors = !themeData.themeEngineVersion
? colors2to3(themeData.colors || themeData)
: themeData.colors || themeData
const { colors, opacity } = getColors(sourceColors, themeData.opacity || {})
const htmlColors = Object.entries(colors).reduce(
(acc, [k, v]) => {
if (!v) return acc
acc.solid[k] = rgb2hex(v)
- acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgba2css(v)
+ acc.complete[k] = v.a === undefined ? rgb2hex(v) : rgba2css(v)
return acc
},
{ complete: {}, solid: {} },
)
return {
rules: {
colors: Object.entries(htmlColors.complete)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}: ${v}`)
.join(';'),
},
theme: {
colors: htmlColors.solid,
opacity,
},
}
}
export const generateRadii = (input) => {
let inputRadii = input.radii || {}
// v1 -> v2
- if (typeof input.btnRadius !== 'undefined') {
+ if (input.btnRadius !== undefined) {
inputRadii = Object.entries(input)
.filter(([k]) => k.endsWith('Radius'))
.reduce((acc, e) => {
acc[e[0].split('Radius')[0]] = e[1]
return acc
}, {})
}
const radii = Object.entries(inputRadii)
.filter(([, v]) => v)
.reduce(
(acc, [k, v]) => {
acc[k] = v
return acc
},
{
btn: 4,
input: 4,
checkbox: 2,
panel: 10,
avatar: 5,
avatarAlt: 50,
tooltip: 2,
attachment: 5,
chatMessage: inputRadii.panel,
},
)
return {
rules: {
radii: Object.entries(radii)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}Radius: ${v}px`)
.join(';'),
},
theme: {
radii,
},
}
}
export const generateFonts = (input) => {
const fonts = Object.entries(input.fonts || {})
.filter(([, v]) => v)
.reduce(
(acc, [k, v]) => {
acc[k] = Object.entries(v)
.filter(([, v]) => v)
.reduce((acc, [k, v]) => {
acc[k] = v
return acc
}, acc[k])
return acc
},
{
interface: {
family: 'sans-serif',
},
input: {
family: 'inherit',
},
post: {
family: 'inherit',
},
postCode: {
family: 'monospace',
},
},
)
return {
rules: {
fonts: Object.entries(fonts)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}Font: ${v}`)
.join(';'),
},
theme: {
fonts,
},
}
}
const border = (top, shadow) => ({
x: 0,
y: top ? 1 : -1,
blur: 0,
spread: 0,
color: shadow ? '#000000' : '#FFFFFF',
alpha: 0.2,
inset: true,
})
const buttonInsetFakeBorders = [border(true, false), border(false, true)]
const inputInsetFakeBorders = [border(true, true), border(false, false)]
const hoverGlow = {
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '--faint',
alpha: 1,
}
export const DEFAULT_SHADOWS = {
panel: [
{
x: 1,
y: 1,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6,
},
],
topBar: [
{
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6,
},
],
popup: [
{
x: 2,
y: 2,
blur: 3,
spread: 0,
color: '#000000',
alpha: 0.5,
},
],
avatar: [
{
x: 0,
y: 1,
blur: 8,
spread: 0,
color: '#000000',
alpha: 0.7,
},
],
avatarStatus: [],
panelHeader: [],
button: [
{
x: 0,
y: 0,
blur: 2,
spread: 0,
color: '#000000',
alpha: 1,
},
...buttonInsetFakeBorders,
],
buttonHover: [hoverGlow, ...buttonInsetFakeBorders],
buttonPressed: [hoverGlow, ...inputInsetFakeBorders],
input: [
...inputInsetFakeBorders,
{
x: 0,
y: 0,
blur: 2,
inset: true,
spread: 0,
color: '#000000',
alpha: 1,
},
],
}
export const generateShadows = (input, colors) => {
// TODO this is a small hack for `mod` to work with shadows
// this is used to get the "context" of shadow, i.e. for `mod` properly depend on background color of element
const hackContextDict = {
button: 'btn',
panel: 'bg',
top: 'topBar',
popup: 'popover',
avatar: 'bg',
panelHeader: 'panel',
input: 'input',
}
const cleanInputShadows = Object.fromEntries(
Object.entries(input.shadows || {}).map(([name, shadowSlot]) => [
name,
// defaulting color to black to avoid potential problems
shadowSlot.map((shadowDef) => ({ color: '#000000', ...shadowDef })),
]),
)
const inputShadows =
cleanInputShadows && !input.themeEngineVersion
? shadows2to3(cleanInputShadows, input.opacity)
: cleanInputShadows || {}
const shadows = Object.entries({
...DEFAULT_SHADOWS,
...inputShadows,
}).reduce((shadowsAcc, [slotName, shadowDefs]) => {
const slotFirstWord = slotName.replace(/[A-Z].*$/, '')
const colorSlotName = hackContextDict[slotFirstWord]
const isLightOnDark =
relativeLuminance(convert(colors[colorSlotName]).rgb) < 0.5
const mod = isLightOnDark ? 1 : -1
const newShadow = shadowDefs.reduce(
(shadowAcc, def) => [
...shadowAcc,
{
...def,
color: rgb2hex(
computeDynamicColor(
def.color,
(variableSlot) => convert(colors[variableSlot]).rgb,
mod,
),
),
},
],
[],
)
return { ...shadowsAcc, [slotName]: newShadow }
}, {})
return {
rules: {
shadows: Object.entries(shadows)
// TODO for v2.2: if shadow doesn't have non-inset shadows with spread > 0 - optionally
// convert all non-inset shadows into filter: drop-shadow() to boost performance
.map(([k, v]) =>
[
`--${k}Shadow: ${getCssShadow(v)}`,
`--${k}ShadowFilter: ${getCssShadowFilter(v)}`,
`--${k}ShadowInset: ${getCssShadow(v, true)}`,
].join(';'),
)
.join(';'),
},
theme: {
shadows,
},
}
}
/**
* This handles compatibility issues when importing v2 theme's shadows to current format
*
* Back in v2 shadows allowed you to use dynamic colors however those used pure CSS3 variables
*/
export const shadows2to3 = (shadows, opacity) => {
return Object.entries(shadows).reduce(
(shadowsAcc, [slotName, shadowDefs]) => {
const isDynamic = ({ color = '#000000' }) => color.startsWith('--')
const getOpacity = ({ color }) =>
opacity[getOpacitySlot(color.substring(2).split(',')[0])]
const newShadow = shadowDefs.reduce(
(shadowAcc, def) => [
...shadowAcc,
{
...def,
alpha: isDynamic(def) ? getOpacity(def) || 1 : def.alpha,
},
],
[],
)
return { ...shadowsAcc, [slotName]: newShadow }
},
{},
)
}
export const colors2to3 = (colors) => {
return Object.entries(colors).reduce((acc, [slotName, color]) => {
const btnPositions = ['', 'Panel', 'TopBar']
switch (slotName) {
case 'lightBg':
return { ...acc, highlight: color }
case 'btnText':
return {
...acc,
...btnPositions.reduce(
(statePositionAcc, position) => ({
...statePositionAcc,
['btn' + position + 'Text']: color,
}),
{},
),
}
default:
return { ...acc, [slotName]: color }
}
}, {})
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Aug 9, 4:45 AM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1724214
Default Alt Text
(137 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment