Page MenuHomePhorge

No OneTemporary

Size
600 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/build/emojis_plugin.js b/build/emojis_plugin.js
index 9872f53317..b21c100480 100644
--- a/build/emojis_plugin.js
+++ b/build/emojis_plugin.js
@@ -1,73 +1,73 @@
import { access } from 'node:fs/promises'
import { resolve } from 'node:path'
import { languages } from '../src/i18n/languages.js'
const annotationsImportPrefix = '@kazvmoe-infra/unicode-emoji-json/annotations/'
const specialAnnotationsLocale = {
ja_easy: 'ja',
}
const internalToAnnotationsLocale = (internal) =>
specialAnnotationsLocale[internal] || internal
// This gets all the annotations that are accessible (whose language
// can be chosen in the settings). Data for other languages are
// discarded because there is no way for it to be fetched.
const getAllAccessibleAnnotations = async (projectRoot) => {
const imports = (
await Promise.all(
languages.map(async (lang) => {
const destLang = internalToAnnotationsLocale(lang)
const importModule = `${annotationsImportPrefix}${destLang}.json`
const importFile = resolve(projectRoot, 'node_modules', importModule)
try {
await access(importFile)
return `'${lang}': () => import('${importModule}')`
} catch (e) {
if (e.message.match(/ENOENT/)) {
console.warn(`Missing emoji annotations locale: ${destLang}`)
} else {
console.error('test', e.message)
}
return
}
}),
)
)
- .filter((k) => k)
+ .filter(Boolean)
.join(',\n')
return `
export const annotationsLoader = {
${imports}
}
`
}
const emojiAnnotationsId = 'virtual:pleroma-fe/emoji-annotations'
const emojiAnnotationsIdResolved = '\0' + emojiAnnotationsId
const emojisPlugin = () => {
let projectRoot
return {
name: 'emojis-plugin',
configResolved(conf) {
projectRoot = conf.root
},
resolveId(id) {
if (id === emojiAnnotationsId) {
return emojiAnnotationsIdResolved
}
return null
},
async load(id) {
if (id === emojiAnnotationsIdResolved) {
return await getAllAccessibleAnnotations(projectRoot)
}
return null
},
}
}
export default emojisPlugin
diff --git a/src/api/admin.js b/src/api/admin.js
index d4a9e8ff96..8bbee8b4f0 100644
--- a/src/api/admin.js
+++ b/src/api/admin.js
@@ -1,491 +1,491 @@
import { promisedRequest } from './helpers.js'
const REPORTS = '/api/v1/pleroma/admin/reports'
const CONFIG_URL = '/api/v1/pleroma/admin/config'
const DESCRIPTIONS_URL = '/api/v1/pleroma/admin/config/descriptions'
const ANNOUNCEMENTS_URL = (id = '') =>
`/api/v1/pleroma/admin/announcements/${id}`
const FRONTENDS_URL = '/api/v1/pleroma/admin/frontends'
const FRONTENDS_INSTALL_URL = '/api/v1/pleroma/admin/frontends/install'
const USERS_URL = (nickname = '') => `/api/v1/pleroma/admin/users/${nickname}`
const USERS_URL_LIST = ({
page,
pageSize,
filters = {},
query = '',
name = '',
email = '',
}) => {
const {
local = false,
external = false,
active = false,
needApproval = false,
unconfirmed = false,
deactivated = false,
isAdmin = true,
isModerator = true,
} = filters
const filters_str = [
local && 'local',
external && 'external',
active && 'active',
needApproval && 'need_approval',
unconfirmed && 'unconfirmed',
deactivated && 'deactivated',
isAdmin && 'is_admin',
isModerator && 'is_moderator',
]
.filter(Boolean)
.join(',')
return `/api/v1/pleroma/admin/users?page=${page}&page_size=${pageSize}&filters=${filters_str}&query=${query}&name=${name}&email=${email}`
}
const TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PERMISSION_GROUP_URL = (right) =>
`/api/pleroma/admin/users/permission_group/${right}`
const ACTIVATE_USERS_URL = '/api/pleroma/admin/users/activate'
const DEACTIVATE_USERS_URL = '/api/pleroma/admin/users/deactivate'
const SUGGEST_USERS_URL = '/api/pleroma/admin/users/suggest'
const UNSUGGEST_USERS_URL = '/api/pleroma/admin/users/unsuggest'
const APPROVE_USERS_URL = '/api/v1/pleroma/admin/users/approve'
const CONFIRM_USERS_URL = '/api/v1/pleroma/admin/users/confirm_email'
const RESEND_CONFIRMATION_EMAIL_URL =
'/api/v1/pleroma/admin/users/resend_confirmation_email'
const LIST_STATUSES_URL = ({ id, page, pageSize, godmode, withReblogs }) =>
`/api/v1/pleroma/admin/users/${id}/statuses?page_size=${pageSize}&page=${page}&godmode=${godmode}&with_reblogs=${withReblogs}`
const CHANGE_STATUS_SCOPE_URL = (id) => `/api/v1/pleroma/admin/statuses/${id}`
const REQUIRE_PASSWORD_CHANGE_URL =
'/api/v1/pleroma/admin/users/force_password_reset'
const DISABLE_MFA_URL = '/api/v1/pleroma/admin/users/disable_mfa'
const EMOJI_RELOAD_URL = '/api/pleroma/admin/reload_emoji'
const EMOJI_IMPORT_FS_URL = '/api/pleroma/emoji/packs/import'
const EMOJI_PACK_URL = (name) => `/api/v1/pleroma/emoji/pack?name=${name}`
const EMOJI_PACKS_DL_REMOTE_URL = '/api/v1/pleroma/emoji/packs/download'
const EMOJI_PACKS_DL_REMOTE_ZIP_URL = '/api/v1/pleroma/emoji/packs/download_zip'
const EMOJI_PACKS_LS_REMOTE_URL = (url, page, pageSize) =>
`/api/v1/pleroma/emoji/packs/remote?url=${url}&page=${page}&page_size=${pageSize}`
const EMOJI_UPDATE_FILE_URL = (name) =>
`/api/v1/pleroma/emoji/packs/files?name=${name}`
//
export const setUsersTags = ({
tags,
credentials,
value,
screen_names: nicknames,
}) =>
promisedRequest({
url: TAG_USER_URL,
method: value ? 'PUT' : 'DELETE',
credentials,
payload: {
nicknames,
tags,
},
})
export const setUsersRight = ({
right,
credentials,
value,
screen_names: nicknames,
}) =>
promisedRequest({
url: PERMISSION_GROUP_URL(right),
method: value ? 'POST' : 'DELETE',
credentials,
payload: {
nicknames,
},
})
export const setUsersActivationStatus = ({
credentials,
screen_names: nicknames,
value,
}) =>
promisedRequest({
url: value ? ACTIVATE_USERS_URL : DEACTIVATE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersApprovalStatus = ({
credentials,
screen_names: nicknames,
}) =>
promisedRequest({
url: APPROVE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersConfirmationStatus = ({
credentials,
screen_names: nicknames,
}) =>
promisedRequest({
url: CONFIRM_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersSuggestionStatus = ({
credentials,
screen_names: nicknames,
value,
}) =>
promisedRequest({
url: value ? SUGGEST_USERS_URL : UNSUGGEST_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const getUserData = ({ credentials, screen_name: nickname }) =>
promisedRequest({
url: USERS_URL(nickname),
method: 'GET',
credentials,
})
export const deleteAccounts = ({ credentials, screen_names: nicknames }) =>
promisedRequest({
url: USERS_URL(),
method: 'DELETE',
credentials,
payload: {
nicknames,
},
})
export const getAnnouncements = ({ id, credentials }) =>
promisedRequest({ url: ANNOUNCEMENTS_URL(id), credentials })
// the reported list is hardly useful because standards are for dating i guess,
// so make sure to fetchIfMissing right afterward using this call
export const listUsers = ({ opts, credentials }) =>
promisedRequest({
url: USERS_URL_LIST(opts),
credentials,
method: 'GET',
})
export const resendConfirmationEmail = ({
screen_names: nicknames,
credentials,
}) =>
promisedRequest({
url: RESEND_CONFIRMATION_EMAIL_URL,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
export const requirePasswordChange = ({
screen_names: nicknames,
credentials,
}) =>
promisedRequest({
url: REQUIRE_PASSWORD_CHANGE_URL,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
export const disableMFA = ({ screen_name: nickname, credentials }) =>
promisedRequest({
url: DISABLE_MFA_URL,
credentials,
method: 'PUT',
payload: {
nickname,
},
})
export const listStatuses = ({ opts, credentials }) =>
promisedRequest({
url: LIST_STATUSES_URL(opts),
credentials,
method: 'GET',
})
export const changeStatusScope = ({
opts: { id, sensitive, visibility },
credentials,
}) => {
const payload = {}
- if (typeof sensitive !== 'undefined') {
+ if (sensitive !== undefined) {
payload['sensitive'] = sensitive
}
- if (typeof visibility !== 'undefined') {
+ if (visibility !== undefined) {
payload['visibility'] = visibility
}
return promisedRequest({
url: CHANGE_STATUS_SCOPE_URL(id),
credentials,
method: 'PUT',
payload,
})
}
export const announcementToPayload = ({
content,
startsAt,
endsAt,
allDay,
}) => {
const payload = { content }
- if (typeof startsAt !== 'undefined') {
+ if (startsAt !== undefined) {
payload.starts_at = startsAt ? new Date(startsAt).toISOString() : null
}
- if (typeof endsAt !== 'undefined') {
+ if (endsAt !== undefined) {
payload.ends_at = endsAt ? new Date(endsAt).toISOString() : null
}
- if (typeof allDay !== 'undefined') {
+ if (allDay !== undefined) {
payload.all_day = allDay
}
return payload
}
export const postAnnouncement = ({
credentials,
content,
startsAt,
endsAt,
allDay,
}) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(),
credentials,
method: 'POST',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
export const editAnnouncement = ({
id,
credentials,
content,
startsAt,
endsAt,
allDay,
}) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(id),
credentials,
method: 'PATCH',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
export const deleteAnnouncement = ({ id, credentials }) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(id),
credentials,
method: 'DELETE',
})
export const setReportState = ({ id, state, credentials }) => {
return promisedRequest({
url: REPORTS,
credentials,
method: 'PATCH',
payload: {
reports: [
{
id,
state,
},
],
},
})
}
export const getInstanceDBConfig = ({ credentials }) =>
promisedRequest({
url: CONFIG_URL,
credentials,
})
export const getInstanceConfigDescriptions = ({ credentials }) =>
promisedRequest({
url: DESCRIPTIONS_URL,
credentials,
})
export const getAvailableFrontends = ({ credentials }) =>
promisedRequest({
url: FRONTENDS_URL,
credentials,
})
export const pushInstanceDBConfig = ({ credentials, payload }) =>
promisedRequest({
url: CONFIG_URL,
method: 'POST',
credentials,
payload,
})
export const installFrontend = ({ credentials, payload }) =>
promisedRequest({
url: FRONTENDS_INSTALL_URL,
credentials,
method: 'POST',
payload,
})
// Emoji packs
export const deleteEmojiPack = ({ name }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
method: 'DELETE',
})
export const reloadEmoji = ({ credentials }) =>
promisedRequest({
url: EMOJI_RELOAD_URL,
method: 'POST',
credentials,
})
export const importEmojiFromFS = ({ credentials }) =>
promisedRequest({
url: EMOJI_IMPORT_FS_URL,
credentials,
})
export const createEmojiPack = ({ name, credentials }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
method: 'POST',
credentials,
})
export const listRemoteEmojiPacks = ({
instance,
page,
pageSize,
credentials,
}) => {
if (!instance.startsWith('http')) {
instance = 'https://' + instance
}
return promisedRequest({
url: EMOJI_PACKS_LS_REMOTE_URL(instance, page, pageSize),
credentials,
})
}
export const downloadRemoteEmojiPack = ({
instance,
packName,
as,
credentials,
}) =>
promisedRequest({
url: EMOJI_PACKS_DL_REMOTE_URL,
credentials,
method: 'POST',
payload: {
url: instance,
name: packName,
as,
},
})
export const downloadRemoteEmojiPackZIP = ({
url,
packName,
file,
credentials,
}) => {
const data = new FormData()
if (file) data.set('file', file)
if (url) data.set('url', url)
data.set('name', packName)
return promisedRequest({
url: EMOJI_PACKS_DL_REMOTE_ZIP_URL,
method: 'POST',
formData: data,
})
}
export const saveEmojiPackMetadata = ({ name, newData, credentials }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
credentials,
method: 'PATCH',
payload: { metadata: newData },
})
export const addNewEmojiFile = ({ packName, file, shortcode, filename }) => {
const data = new FormData()
if (filename.trim() !== '') {
data.set('filename', filename)
}
if (shortcode.trim() !== '') {
data.set('shortcode', shortcode)
}
data.set('file', file)
return promisedRequest({
url: EMOJI_UPDATE_FILE_URL(packName),
method: 'POST',
formData: data,
})
}
export const updateEmojiFile = ({
packName,
shortcode,
newShortcode,
newFilename,
credentials,
force,
}) =>
promisedRequest({
url: EMOJI_UPDATE_FILE_URL(packName),
credentials,
method: 'PATCH',
payload: {
shortcode,
new_shortcode: newShortcode,
new_filename: newFilename,
force,
},
})
export const deleteEmojiFile = ({ packName, shortcode }) =>
promisedRequest({
url: `${EMOJI_UPDATE_FILE_URL(packName)}&shortcode=${shortcode}`,
method: 'DELETE',
})
diff --git a/src/api/chats.js b/src/api/chats.js
index 5d766dec40..b212380de7 100644
--- a/src/api/chats.js
+++ b/src/api/chats.js
@@ -1,94 +1,94 @@
import { paramsString, promisedRequest } from './helpers.js'
import {
parseChat,
parseChatMessage,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats'
const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}`
const PLEROMA_CHAT_MESSAGES_URL = (id, { maxId, sinceId, limit } = {}) =>
`/api/v1/pleroma/chats/${id}/messages${paramsString({ maxId, sinceId, limit })}`
const PLEROMA_CHAT_READ_URL = (id) => `/api/v1/pleroma/chats/${id}/read`
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) =>
`/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
export const chats = ({ credentials }) =>
promisedRequest({
url: PLEROMA_CHATS_URL,
credentials,
}).then(({ data }) => ({
- data: data.map(parseChat).filter((c) => c),
+ data: data.map(parseChat).filter(Boolean),
}))
export const getOrCreateChat = ({ accountId, credentials }) =>
promisedRequest({
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials,
}).then(({ data }) => ({ data: parseChat(data) }))
export const chatMessages = ({
id,
credentials,
maxId,
sinceId,
limit = 20,
}) => {
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
method: 'GET',
credentials,
}).then(({ data }) => ({
- data: data.map(parseChatMessage).filter((c) => c),
+ data: data.map(parseChatMessage).filter(Boolean),
}))
}
export const sendChatMessage = ({
id,
content,
mediaId = null,
idempotencyKey,
credentials,
}) => {
const payload = {
content,
}
if (mediaId) {
payload.media_id = mediaId
}
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id),
method: 'POST',
payload,
credentials,
headers,
}).then(({ data }) => ({
data: parseChatMessage(data),
}))
}
export const readChat = ({ id, lastReadId, credentials }) =>
promisedRequest({
url: PLEROMA_CHAT_READ_URL(id),
method: 'POST',
payload: {
last_read_id: lastReadId,
},
credentials,
})
export const deleteChatMessage = ({ chatId, messageId, credentials }) =>
promisedRequest({
url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
method: 'DELETE',
credentials,
})
diff --git a/src/api/helpers.js b/src/api/helpers.js
index cb2f2bdd5f..90956a4dc8 100644
--- a/src/api/helpers.js
+++ b/src/api/helpers.js
@@ -1,142 +1,138 @@
import { snakeCase } from 'lodash'
import { StatusCodeError } from 'src/services/errors/errors'
export const paramsString = (params = {}) => {
if (params == null || params === undefined) return ''
if (typeof params !== 'object' || Array.isArray(params)) {
throw new TypeError('Params are not an object!')
}
const entries = (() => {
if (params instanceof Map) {
return [...params.entries()]
} else {
return Object.entries(params)
}
})()
const arrays = []
const nonArrays = []
entries.forEach(([k, v]) => {
if (v == null) return // Drop nulls
if (
(typeof v === 'object' && !Array.isArray(v)) ||
typeof v === 'function'
) {
throw new TypeError('Param cannot be non-primitive!')
}
if (Array.isArray(v)) {
arrays.push([k, v])
} else {
nonArrays.push([k, v])
}
})
arrays.forEach(([k, array]) => {
array.forEach((v) => {
- if (
- typeof v === 'object' ||
- typeof v === 'function' ||
- typeof v === 'undefined'
- )
+ if (typeof v === 'object' || typeof v === 'function' || v === undefined)
throw new TypeError('Array param cannot contain non-primitives!')
})
})
if (nonArrays.length + arrays.length === 0) return ''
return (
'?' +
[
...nonArrays.map(([k, v]) => [snakeCase(k), v]),
// turning [a,[1,2,3]] into [[a[],1],[a[],2],[a[],3]]
...arrays.reduce(
(acc, [k, arrayValue]) => [
...acc,
...arrayValue.map((v) => [snakeCase(k) + '[]', v]),
],
[],
),
]
.map(([k, v]) => `${k}=${window.encodeURIComponent(v)}`)
.join('&')
)
}
export const promisedRequest = async ({
method,
url,
payload,
formData,
cache,
credentials,
headers = {},
}) => {
const options = {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...headers,
},
}
if (!formData) {
options.headers['Content-Type'] = 'application/json'
}
if (cache) {
options.cache = cache
}
if (formData || payload) {
options.body = formData || JSON.stringify(payload)
}
if (credentials) {
options.headers = {
...options.headers,
...authHeaders(credentials),
}
}
const response = await fetch(url, options)
const data = await (async () => {
const [contentType] = response.headers
.get('content-type')
.split(';')
.map((x) => x.toLowerCase().trim())
const contentLength = Number.parseInt(
response.headers.get('content-length'),
)
if (contentLength === 0) return null
switch (contentType) {
case 'text/plain':
return await response.text()
case 'application/json':
return await response.json()
default:
return await response.bytes()
}
})()
const { ok, status } = response
if (ok) {
return { response, status, data }
} else {
throw new StatusCodeError(response.status, data, { url, options }, response)
}
}
const authHeaders = (accessToken) => {
if (accessToken) {
return { Authorization: `Bearer ${accessToken}` }
} else {
return {}
}
}
diff --git a/src/components/chat_view/chat_view.js b/src/components/chat_view/chat_view.js
index f48b75c653..16b2ae1d72 100644
--- a/src/components/chat_view/chat_view.js
+++ b/src/components/chat_view/chat_view.js
@@ -1,647 +1,647 @@
import { get, maxBy, minBy, sortBy, throttle } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { nextTick } from 'vue'
import { mapState } from 'vuex'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
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 { useChatsStore } from 'src/stores/chats.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,
deleteChatMessage,
getOrCreateChat,
readChat,
sendChatMessage,
} from 'src/api/chats.js'
import { fetchConversation, fetchStatus } from 'src/api/public.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 isConfirmation = (storage, message) => {
if (!message.idempotency_key) return
return storage.idempotencyKeyIndex[message.idempotency_key]
}
const Chat = {
components: {
ChatMessageList,
ChatTitle,
PostStatusForm,
},
props: {
statusId: {
type: String,
default: null,
},
chatUserId: {
type: String,
default: null,
},
testMode: Boolean,
},
data() {
return {
// Main info
chat: null,
messages: [],
messagesIndex: {},
pendingMessages: [],
pendingMessagesIndex: {},
minId: undefined,
maxId: undefined,
// Conversation stuff
explicitReplyStatus: null,
// Unread stuff
newMessageCount: 0,
lastReadMessageId: null,
lastScrollPosition: {},
jumpToBottomButtonVisible: false,
// Internal network stuff
fetcher: null,
errorLoadingChat: false,
messageRetriers: {},
idempotencyKeyIndex: {},
}
},
created() {
if (this.testMode) return
this.startFetching()
},
mounted() {
window.addEventListener('resize', this.handleResize)
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,
)
},
computed: {
conversationId() {
const status = this.$store.state.statuses.allStatusesObject[this.statusId]
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
isConversation() {
return this.statusId !== null
},
recipient() {
return this.chat?.account
},
formPlaceholder() {
if (this.recipient) {
return this.$t('chats.message_user', {
nickname: this.recipient.screen_name_ui,
})
} else {
return ''
}
},
// Conversation stuff
lastStatus() {
return this.messages[this.messages.length - 1]
},
replyStatus() {
return this.explicitReplyStatus ?? this.lastStatus
},
// Global Stuff
streamingEnabled() {
if (this.isConversation) return false // Unsupported
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
currentUser: (state) => state.users.currentUser,
}),
},
watch: {
messages(old, neu) {
if (old.length === neu.length) return
// 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 = isBottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => {
if (bottomedOutBeforeUpdate) {
this.scrollDown()
}
})
},
async replyStatus(newVal) {
await nextTick() // wait for changes to propagate to postStatusForm
if (this.testMode) return
this.$refs.postStatusForm.update()
},
$route: async function (newVal) {
if (this.messagesIndex[newVal.params.statusId]) {
const focused = document.getElementById(
`chatmessage-${this.$route.params.statusId}`,
)
if (focused?.getBoundingClientRect == null) return
const bottomBoundary =
window.innerHeight - this.$refs.footer.clientHeight
const topBoundary =
this.$refs.header.clientHeight +
document.getElementById('nav').clientHeight
const margin = Number(
window
.getComputedStyle(this.$refs.messageList.$el)
.gap.replace('px', ''),
)
const rect = focused.getBoundingClientRect()
const scrollAmount = (() => {
if (rect.top < topBoundary) {
// Post is above screen, match its top to screen top
return rect.top - topBoundary - margin
} else if (rect.height >= bottomBoundary) {
// Post we want to see is taller than screen so match its top to screen top
return rect.top - topBoundary - margin
} else if (rect.bottom > bottomBoundary) {
// Post is below screen, match its bottom to screen bottom
return rect.bottom - bottomBoundary + margin
} else {
return 0
}
})()
if (scrollAmount !== 0) {
window.scrollBy(0, scrollAmount)
}
return
}
this.clear()
this.startFetching()
},
mastoUserSocketStatus(newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
},
},
methods: {
// Actions
async readChat() {
if (this.conversationId) return // Unsupported
if (!this.maxId || document.hidden) {
return
}
const lastReadId = this.maxId
const isNewMessage = this.lastReadMessageId !== lastReadId
if (!isNewMessage) return
if (!this.testMode) {
await readChat({
id: this.chat.id,
lastReadId,
credentials: useOAuthStore().token,
})
}
useChatsStore().readChat(this.chat.id)
this.lastReadMessageId = this.maxId
this.newMessageCount = 0
},
scrollDown(options = {}) {
const { behavior = 'auto', forceRead = false } = options
this.$nextTick(() => {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior,
})
})
if (forceRead) {
this.readChat()
}
},
cullOlder() {
const maxIndex = this.messages.length
const minIndex = maxIndex - 50
if (maxIndex <= 50) return
this.messages = sortBy(this.messages, ['id'])
this.minId = this.messages[minIndex].id
for (const message of this.messages) {
if (message.id < this.minId) {
delete this.messagesIndex[message.id]
delete this.idempotencyKeyIndex[message.idempotency_key]
}
}
this.messages = this.messages.slice(minIndex, maxIndex)
},
clear() {
this.messages = this.messages.filter((m) => m.error)
this.messagesIndex = this.messages.reduce(
(acc, m) => ({
...acc,
[m.id]: m,
}),
{},
)
this.newMessageCount = 0
this.lastReadMessageId = null
this.minId = undefined
this.maxId = undefined
},
async fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
if (fetchLatest && this.streamingEnabled) {
return
}
let messages
if (this.isConversation) {
const [
{ data: status },
{
data: { ancestors, descendants },
},
] = await Promise.all([
fetchStatus({
id: this.statusId,
credentials: useOAuthStore().token,
}),
fetchConversation({
id: this.statusId,
credentials: useOAuthStore().token,
}),
])
messages = [...ancestors, status, ...descendants]
} else {
const { data } = await chatMessages({
id: this.chat.id,
maxId,
sinceId: fetchLatest ? this.maxId : null,
credentials: useOAuthStore().token,
})
messages = data
}
// Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) {
this.clear()
}
const positionBeforeUpdate = getScrollPosition()
this.addMessages({ messages })
await nextTick()
if (isFirstFetch) {
this.scrollDown()
}
const fetchOlderMessages = !!maxId
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.
//
// Conversation fetching doesn't support pagination and spews out everything at once
// so we both can't and don't need to fetch previous posts
if (!this.isConversation && !isScrollable() && messages.length > 0) {
this.fetchChat({
maxId: this.minId,
})
}
},
async startFetching() {
if (!this.isConversation) {
try {
const { data } = await getOrCreateChat({
accountId: this.chatUserId,
credentials: useOAuthStore().token,
})
this.$store.commit('addNewUsers', [data.account])
data.account = this.$store.getters.findUser(data.account.id)
this.chat = data
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
}
if (this.isConversation || this.chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.doStartFetching()
}
},
doStartFetching() {
this.fetcher = promiseInterval(
() => this.fetchChat({ fetchLatest: true }),
5000,
)
this.fetchChat({ isFirstFetch: true })
},
addMessages({ messages: newMessages }) {
for (let i = 0; i < newMessages.length; i++) {
const message = newMessages[i]
// Sanity check
if (!this.isConversation && message.chat_id !== this.chat.id) {
console.warn(
`Chat message doesn't belong to current chat (id: ${this.chat.id})!!`,
message,
)
return
}
// Clear any known pending messages
if (message.idempotency_key) {
if (this.pendingMessagesIndex[message.idempotencyKeyIndex]) {
delete this.pendingMessagesIndex[message.idempotencyKeyIndex]
this.pendingMessages = this.pendingMessages.filter(
({ idempotency_key }) =>
idempotency_key !== message.idempotency_key,
)
}
}
if (!this.minId || (!message.pending && message.id < this.minId)) {
this.minId = message.id
}
if (!this.maxId || message.id > this.maxId) {
this.maxId = message.id
}
if (!this.messagesIndex[message.id] && !isConfirmation(this, message)) {
if (this.lastReadMessageId < message.id) {
this.newMessageCount++
}
this.messagesIndex[message.id] = message
this.messages.push(this.messagesIndex[message.id])
this.idempotencyKeyIndex[message.idempotency_key] = true
}
}
},
goBack() {
this.$router.back()
},
// Optimistic posting (chats only)
async sendMessage({ status, media, idempotencyKey }) {
const params = {
id: this.chat.id,
content: status,
idempotencyKey,
}
if (media[0]) {
params.mediaId = media[0].id
}
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: this.chat.id,
content: status,
userId: this.currentUser.id,
idempotencyKey,
})
this.pendingMessages.push(fakeMessage)
this.pendingMessagesIndex[idempotencyKey] = fakeMessage
this.handleAttachmentPosting()
return this.doSendMessage({
params,
retriesLeft: MAX_RETRIES,
})
},
async doSendMessage({ params, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return
try {
const { data } = await sendChatMessage({
...params,
credentials: useOAuthStore().token,
})
this.addMessages({
messages: [{ ...data }],
})
} catch (error) {
if (
error.name !== 'StatusCodeError' ||
error.message === 'Failed to fetch'
)
throw error
console.error('Error sending message', error)
this.handleMessageError({
chatId: this.chat.id,
idempotencyKey: params.idempotencyKey,
isRetry: retriesLeft !== MAX_RETRIES,
})
if (
(error.statusCode >= 500 && error.statusCode < 600) ||
error.message === 'Failed to fetch'
) {
this.messageRetriers[params.idempotencyKey] = setTimeout(
() => {
this.doSendMessage({
params,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
)
}
}
},
handleMessageError(idempotencyKey, isRetry) {
const fakeMessage = this.pendingMessagesIndex[idempotencyKey]
if (fakeMessage) {
fakeMessage.error = true
fakeMessage.pending = false
}
},
// Checks
hasReachedTop() {
return window.scrollY <= 0
},
cullOlderCheck() {
if (this.conversationId) return
window.setTimeout(() => {
if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.cullOlder()
}
}, 5000)
},
// Event handlers
onPosted(data) {
this.explicitReplyStatus = null
this.$router.push({
name: 'conversation2',
params: { statusId: data.id },
})
},
handleVisibilityChange() {
this.$nextTick(() => {
if (!document.hidden && isBottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
},
onFilesDropped() {
this.$nextTick(() => {
this.handleResize()
})
},
handleResize(opts = {}) {
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
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 && !isBottomedOut()) {
this.$nextTick(() => {
window.scrollBy({ top: -Math.trunc(diff) })
})
}
this.lastScrollPosition = getScrollPosition()
})
},
handleScroll: throttle(function () {
if (!this.chat) {
return
}
this.lastScrollPosition = getScrollPosition()
if (this.hasReachedTop()) {
this.fetchChat({ maxId: this.minId })
} else if (isBottomedOut(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),
})
},
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 })
})
},
// Ugly
// TODO move to ChatMessage
async deleteChatMessage({ chatId, messageId }) {
if (!this.testMode)
await deleteChatMessage({
chatId,
messageId,
credentials: useOAuthStore().token,
})
this.messages = this.messages.filter((m) => m.id !== messageId)
delete this.messagesIndex[messageId]
if (this.maxId === messageId) {
const lastMessage = maxBy(this.messages, 'id')
this.maxId = lastMessage.id
}
if (this.minId === messageId) {
const firstMessage = minBy(this.messages, 'id')
this.minId = firstMessage.id
}
},
},
}
export default Chat
diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue
index d6667c28ff..53396f5327 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"
+ v-if="fallback !== undefined && showOptionalCheckbox && !hideOptionalCheckbox"
:model-value="present"
:disabled="disabled"
class="opt"
- @update:model-value="updateValue(typeof modelValue === 'undefined' ? fallback : undefined)"
+ @update:model-value="updateValue(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 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/conversation/conversation.js b/src/components/conversation/conversation.js
index fcde9477c6..d27551c3c3 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,636 +1,635 @@
import { clone, filter, findIndex, get, reduce } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { fetchConversation, fetchStatus } from 'src/api/public.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return -1
} else if (!isSeqA && isSeqB) {
return 1
} else {
return idA < idB ? -1 : 1
}
}
const sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) =>
status.type === 'retweet' ||
status.id !== statusoid.retweeted_status.id,
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
- return conversation.filter((_) => _).sort(sortById)
+ return conversation.filter(Boolean).sort(sortById)
}
const conversation = {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
collapsable: {
// Whether conversation can be collapsed
// i.e. when it's not a page
type: Boolean,
default: false,
},
isPage: {
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
type: Boolean,
default: false,
},
pinnedStatusIdsObject: {
// Used for user profile, map of pinned statuses
type: Object,
default: null,
},
inProfile: {
// Whether conversation is rendered in a user profile
// used for overriding muted status
type: Boolean,
default: false,
},
profileUserId: {
// used with inProfile, user id of the profile
type: String,
default: null,
},
virtualHidden: {
// Whether conversation is suspended. Controls rendering of statuses
type: Boolean,
default: false,
},
},
data() {
return {
focused: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
inlineDivePosition: null,
loadStatusError: null,
unsuspendibleIds: new Set(),
}
},
created() {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
maxDepthToShowByDefault() {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
const maxDepth = this.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
displayStyle() {
return this.mergedConfig.conversationDisplay
},
treeViewIsSimple() {
return !this.mergedConfig.conversationTreeAdvanced
},
isTreeView() {
return this.displayStyle === 'tree'
},
isLinearView() {
return this.displayStyle !== 'tree'
},
shouldFadeAncestors() {
return this.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition() {
return this.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus() {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus() {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable() {
return this.unsuspendibleIds.size > 0
},
hideStatus() {
return this.virtualHidden && this.suspendable
},
status() {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
originalStatusId() {
if (this.status.retweeted_status) {
return this.status.retweeted_status.id
} else {
return this.statusId
}
},
conversationId() {
return this.getConversationId(this.statusId)
},
conversation() {
if (!this.status) {
return []
}
if (!this.isExpanded) {
return [this.status]
}
const conversation = clone(
this.$store.state.statuses.conversationsObject[this.conversationId],
)
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
},
statusMap() {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree() {
const reverseLookupTable = this.conversation.reduce(
(table, status, index) => {
table[status.id] = index
return table
},
{},
)
const threads = this.conversation.reduce(
(a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id).map((s) => s.id)
return a
},
{
forest: {},
},
)
const walk = (forest, topLevel, depth = 0, processed = {}) =>
topLevel
.map((id) => {
if (processed[id]) {
return []
}
processed[id] = true
return [
{
status: this.conversation[reverseLookupTable[id]],
id,
depth,
},
walk(forest, forest[id], depth + 1, processed),
- ].reduce((a, b) => a.concat(b), [])
+ ].flat()
})
- .reduce((a, b) => a.concat(b), [])
+ .flat()
const linearized = walk(
threads.forest,
this.topLevel.map((k) => k.id),
)
return linearized
},
replyIds() {
return this.conversation
.map((k) => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map((k) => k.id)
return res
}, {})
},
totalReplyCount() {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeSizeFor(cid))
.reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map((k) => k.id).map(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth() {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeDepthFor(cid))
.reduce((a, b) => (a > b ? a : b), 0)
return depths[id]
}
this.conversation.map((k) => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths() {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel() {
const topLevel = this.conversation.reduce(
(tl, cur) =>
- tl.filter(
- (k) =>
- this.getReplies(cur.id)
- .map((v) => v.id)
- .indexOf(k.id) === -1,
+ tl.filter((k) =>
+ this.getReplies(cur.id)
+ .map((v) => v.id)
+ .includes(k.id),
),
this.conversation,
)
return topLevel
},
otherTopLevelCount() {
return this.topLevel.length - 1
},
showingTopLevel() {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot() {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth() {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode() {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton() {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return (
this.isTreeView &&
this.isExpanded &&
this.diveMode &&
this.topLevel.length > 1
)
},
shouldShowAncestors() {
return (
this.isTreeView &&
this.isExpanded &&
this.ancestorsOf(this.diveRoot).length
)
},
replies() {
let i = 1
return reduce(
this.conversation,
(result, { id, in_reply_to_status_id: irid }) => {
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id,
})
}
i++
return result
},
{},
)
},
isExpanded() {
return !!(this.expanded || this.isPage)
},
hiddenStyle() {
const height = this.status?.virtualHeight || '120px'
return this.virtualHidden ? { height } : {}
},
threadDisplayStatus() {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
const status = (() => {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if (depth - this.diveDepth <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
}
})()
a[id] = status
return a
}, {})
},
canDive() {
return this.isTreeView && this.isExpanded
},
maybeFocused() {
return this.isExpanded ? this.focused : null
},
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
}),
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
PostStatusForm,
RichContent,
},
watch: {
statusId(newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
this.setFocused(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded(value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden() {
this.$store.dispatch('setVirtualHeight', {
statusId: this.statusId,
height: `${this.$el.clientHeight}px`,
})
},
},
methods: {
fetchConversation() {
if (this.status) {
fetchConversation({
id: this.statusId,
credentials: useOAuthStore().token,
}).then(({ data: { ancestors, descendants } }) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
this.setFocused(this.originalStatusId)
})
} else {
this.loadStatusError = null
fetchStatus({
id: this.statusId,
credentials: useOAuthStore().token,
})
.then(({ data: status }) => {
this.$store.dispatch('addNewStatuses', { statuses: [status] })
this.fetchConversation()
})
.catch((error) => {
console.error(error)
this.loadStatusError = error
})
}
},
getReplies(id) {
return this.replies[id] || []
},
setFocused(id) {
if (!id) return
this.focused = id
if (!this.streamingEnabled) {
this.$store.dispatch('fetchStatus', id)
}
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
toggleExpanded() {
this.expanded = !this.expanded
},
getConversationId(statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
setThreadDisplay(id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus,
}
},
toggleThreadDisplay(id) {
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively(id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id)
.map((k) => k.id)
.map((id) => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively(id) {
this.setThreadDisplayRecursively(id, 'showing')
},
leastVisibleAncestor(id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
// if the parent is showing it means cur is visible
if (this.threadDisplayStatus[parent] === 'showing') {
return cur
}
parent = this.parentOf(parent)
cur = this.parentOf(cur)
}
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined
},
diveIntoStatus(id) {
this.tryScrollTo(id)
},
diveToTopLevel() {
this.tryScrollTo(
this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id,
)
},
// only used when we are not on a page
undive() {
this.inlineDivePosition = null
this.setFocused(this.statusId)
},
tryScrollTo(id) {
if (!id) {
return
}
if (this.isPage) {
// set statusId
this.$router.push({ name: 'conversation', params: { id } })
} else {
this.inlineDivePosition = id
}
// Because the conversation can be unmounted when out of sight
// and mounted again when it comes into sight,
// the `mounted` or `created` function in `status` should not
// contain scrolling calls, as we do not want the page to jump
// when we scroll with an expanded conversation.
//
// Now the method is to rely solely on the `focused` watcher
// in `status` components.
// In linear views, all statuses are rendered at all times, but
// in tree views, it is possible that a change in active status
// removes and adds status components (e.g. an originally child
// status becomes an ancestor status, and thus they will be
// different).
// Here, let the components be rendered first, in order to trigger
// the `focused` watcher.
this.$nextTick(() => {
this.setFocused(id)
})
},
goToCurrent() {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById(id) {
return this.statusMap[id]
},
parentOf(id) {
const status = this.statusById(id)
if (!status) {
return undefined
}
const { in_reply_to_status_id: parentId } = status
if (!this.statusMap[parentId]) {
return undefined
}
return parentId
},
parentOrSelf(id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf(id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
ancestors.unshift(this.statusMap[cur])
cur = this.parentOf(cur)
}
return ancestors
},
topLevelAncestorOrSelfId(id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
cur = this.parentOf(cur)
parent = this.parentOf(parent)
}
return cur
},
resetDisplayState() {
this.undive()
this.threadDisplayStatusObject = {}
},
onStatusSuspendStateChange({ id, suspend }) {
if (!suspend) {
this.unsuspendibleIds.add(id)
} else {
this.unsuspendibleIds.delete(id)
}
},
onPosted(data) {
if (this.isPage) {
this.$router.push({ name: 'conversation', params: { id: data.id } })
}
},
},
}
export default conversation
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 0cce2494d3..d8cc7a9196 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -1,605 +1,605 @@
import { take } from 'lodash'
import Popover from 'src/components/popover/popover.vue'
import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import Completion from '../../services/completion/completion.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
library.add(faSmileBeam)
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
* without having to give up the comfort of <input/> and <textarea/> elements
*
* Intended usage is:
* <EmojiInput v-model="something">
* <input v-model="something"/>
* </EmojiInput>
*
* Works only with <input> and <textarea>. Intended to use with only one nested
* input. It will find first input or textarea and work with that, multiple
* nested children not tested. You HAVE TO duplicate v-model for both
* <emoji-input> and <input>/<textarea> otherwise it will not work.
*
* Be prepared for CSS troubles though because it still wraps component in a div
* while TRYING to make it look like nothing happened, but it could break stuff.
*/
const EmojiInput = {
emits: ['update:modelValue', 'shown'],
props: {
suggest: {
/**
* suggest: function (input: String) => Suggestion[]
*
* Function that takes input string which takes string (textAtCaret)
* and returns an array of Suggestions
*
* Suggestion is an object containing following properties:
* displayText: string. Main display text, what actual suggestion
* represents (user's screen name/emoji shortcode)
* replacement: string. Text that should replace the textAtCaret
* detailText: string, optional. Subtitle text, providing additional info
* if present (user's nickname)
* imageUrl: string, optional. Image to display alongside with suggestion,
* currently if no image is provided, replacement will be used (for
* unicode emojis)
*
* TODO: make it asynchronous when adding proper server-provided user
* suggestions
*
* For commonly used suggestors (emoji, users, both) use suggestor.js
*/
required: true,
type: Function,
},
modelValue: {
/**
* Used for v-model
*/
required: true,
type: String,
},
enableEmojiPicker: {
/**
* Enables emoji picker support, this implies that custom emoji are supported
*/
required: false,
type: Boolean,
default: false,
},
hideEmojiButton: {
/**
* intended to use with external picker trigger, i.e. you have a button outside
* input that will open up the picker, see triggerShowPicker()
*/
required: false,
type: Boolean,
default: false,
},
enableStickerPicker: {
/**
* Enables sticker picker support, only makes sense when enableEmojiPicker=true
*/
required: false,
type: Boolean,
default: false,
},
placement: {
/**
* Forces the panel to take a specific position relative to the input element.
* The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
*/
required: false,
type: String, // 'auto', 'top', 'bottom'
default: 'auto',
},
newlineOnCtrlEnter: {
required: false,
type: Boolean,
default: false,
},
},
data() {
return {
randomSeed: genRandomSeed(),
input: undefined,
caretEl: undefined,
highlighted: -1,
caret: 0,
focused: false,
blurTimeout: null,
temporarilyHideSuggestions: false,
disableClickOutside: false,
suggestions: [],
overlayStyle: {},
pickerShown: false,
}
},
components: {
Popover,
EmojiPicker,
UnicodeDomainIndicator,
ScreenReaderNotice,
},
computed: {
padEmoji() {
return useMergedConfigStore().mergedConfig.padEmoji
},
defaultCandidateIndex() {
return useMergedConfigStore().mergedConfig.autocompleteSelect ? 0 : -1
},
preText() {
return this.modelValue.slice(0, this.caret)
},
postText() {
return this.modelValue.slice(this.caret)
},
showSuggestions() {
return (
this.focused &&
this.suggestions &&
this.suggestions.length > 0 &&
!this.pickerShown &&
!this.temporarilyHideSuggestions
)
},
textAtCaret() {
return this.wordAtCaret?.word
},
wordAtCaret() {
if (this.modelValue && this.caret) {
const word =
Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word
}
},
languages() {
return ensureFinalFallback(
useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiNamesAndKeywords() {
return (emoji) => {
const names = [emoji.displayText]
const keywords = []
if (emoji.displayTextI18n) {
names.push(
this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args),
)
}
if (emoji.annotations) {
this.languages.forEach((lang) => {
names.push(emoji.annotations[lang]?.name)
keywords.push(...(emoji.annotations[lang]?.keywords || []))
})
}
return {
- names: names.filter((k) => k),
- keywords: keywords.filter((k) => k),
+ names: names.filter(Boolean),
+ keywords: keywords.filter(Boolean),
}
}
},
maybeLocalizedEmojiName() {
return (emoji) => {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
}
},
suggestionListId() {
return `suggestions-${this.randomSeed}`
},
suggestionItemId() {
return (index) => `suggestion-item-${index}-${this.randomSeed}`
},
},
mounted() {
const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
const input =
root.querySelector('.emoji-input > input') ||
root.querySelector('.emoji-input > textarea')
if (!input) return
this.input = input
this.caretEl = hiddenOverlayCaret
if (suggestorPopover.setAnchorEl) {
suggestorPopover.setAnchorEl(this.caretEl) // unit test compat
this.$refs.picker.setAnchorEl(this.caretEl)
} else {
console.warn('setAnchorEl not found, are we in a unit test?')
}
const style = getComputedStyle(this.input)
this.overlayStyle.padding = style.padding
this.overlayStyle.border = style.border
this.overlayStyle.margin = style.margin
this.overlayStyle.lineHeight = style.lineHeight
this.overlayStyle.fontFamily = style.fontFamily
this.overlayStyle.fontSize = style.fontSize
this.overlayStyle.wordWrap = style.wordWrap
this.overlayStyle.whiteSpace = style.whiteSpace
input.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus)
input.addEventListener('paste', this.onPaste)
input.addEventListener('keyup', this.onKeyUp)
input.addEventListener('keydown', this.onKeyDown)
input.addEventListener('click', this.onClickInput)
input.addEventListener('transitionend', this.onTransition)
input.addEventListener('input', this.onInput)
input.addEventListener('scroll', this.onInputScroll)
},
unmounted() {
const { input } = this
if (input) {
input.removeEventListener('blur', this.onBlur)
input.removeEventListener('focus', this.onFocus)
input.removeEventListener('paste', this.onPaste)
input.removeEventListener('keyup', this.onKeyUp)
input.removeEventListener('keydown', this.onKeyDown)
input.removeEventListener('click', this.onClickInput)
input.removeEventListener('transitionend', this.onTransition)
input.removeEventListener('input', this.onInput)
input.removeEventListener('scroll', this.onInputScroll)
}
},
watch: {
showSuggestions: function (newValue) {
this.$emit('shown', newValue)
if (newValue) {
this.$refs.suggestorPopover.showPopover()
} else {
this.$refs.suggestorPopover.hidePopover()
}
},
textAtCaret: async function (newWord) {
if (newWord === undefined) return
const firstchar = newWord.charAt(0)
if (newWord === firstchar) {
this.suggestions = []
return
}
const matchedSuggestions = await this.suggest(
newWord,
this.maybeLocalizedEmojiNamesAndKeywords,
)
// Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) {
this.suggestions = []
return
}
this.suggestions = take(matchedSuggestions, 5).map(
({ imageUrl, ...rest }) => ({
...rest,
img: imageUrl || '',
}),
)
this.highlighted = this.defaultCandidateIndex
this.$refs.screenReaderNotice.announce(
this.$t(
'tool_tip.autocomplete_available',
{ number: this.suggestions.length },
this.suggestions.length,
),
)
},
},
methods: {
onInputScroll(e) {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft,
})
this.setCaret(e)
},
triggerShowPicker() {
this.$nextTick(() => {
this.$refs.picker.showPicker()
this.scrollIntoView()
})
// This temporarily disables "click outside" handler
// since external trigger also means click originates
// from outside, thus preventing picker from opening
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
},
togglePicker() {
this.input.focus()
if (!this.pickerShown) {
this.scrollIntoView()
this.$refs.picker.showPicker()
} else {
this.$refs.picker.hidePicker()
}
},
replace(replacement) {
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement,
)
this.$emit('update:modelValue', newValue)
this.caret = 0
},
insert({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.modelValue.substring(0, this.caret) || ''
const after = this.modelValue.substring(this.caret) || ''
/* Using a bit more smart approach to padding emojis with spaces:
* - put a space before cursor if there isn't one already, unless we
* are at the beginning of post or in spam mode
* - put a space after emoji if there isn't one already unless we are
* in spam mode
*
* The idea is that when you put a cursor somewhere in between sentence
* inserting just ' :emoji: ' will add more spaces to post which might
* break the flow/spacing, as well as the case where user ends sentence
* with a space before adding emoji.
*
* Spam mode is intended for creating multi-part emojis and overall spamming
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
const spaceBefore =
surroundingSpace &&
!isSpaceRegex.exec(before.slice(-1)) &&
before.length &&
this.padEmoji > 0
? ' '
: ''
const spaceAfter =
surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji
? ' '
: ''
const newValue = [before, spaceBefore, insertion, spaceAfter, after].join(
'',
)
this.$emit('update:modelValue', newValue)
const position =
this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.focus()
}
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.caret = position
})
},
replaceText(e, suggestion) {
const len = this.suggestions.length || 0
if (this.textAtCaret.length === 1) {
return
}
if (len > 0 || suggestion) {
const chosenSuggestion =
suggestion || this.suggestions[this.highlighted]
const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement,
)
this.$emit('update:modelValue', newValue)
this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
this.input.focus()
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.caret = position
})
e.preventDefault()
}
},
cycleBackward(e) {
const len = this.suggestions.length || 0
this.highlighted -= 1
if (this.highlighted === -1) {
this.input.focus()
} else if (this.highlighted < -1) {
this.highlighted = len - 1
}
if (len > 0) {
e.preventDefault()
}
},
cycleForward(e) {
const len = this.suggestions.length || 0
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = -1
this.input.focus()
}
if (len > 0) {
e.preventDefault()
}
},
scrollIntoView() {
const rootRef = this.$refs.picker.$el
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const scrollerRef =
this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// We check where the bottom border of root element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const rootBottomBorder =
rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
// could also check top delta but there's no case for it
const targetScroll = currentScroll + bottomDelta
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
this.$nextTick(() => {
const { offsetHeight } = this.input
const { picker } = this.$refs
const pickerBottom = picker.$el.getBoundingClientRect().bottom
if (pickerBottom > window.innerHeight) {
picker.$el.style.top = 'auto'
picker.$el.style.bottom = offsetHeight + 'px'
}
})
},
onPickerShown() {
this.pickerShown = true
},
onPickerClosed() {
this.pickerShown = false
},
onBlur(e) {
// Clicking on any suggestion removes focus from autocomplete,
// preventing click handler ever executing.
this.blurTimeout = setTimeout(() => {
this.focused = false
this.setCaret(e)
}, 200)
},
onClick(e, suggestion) {
this.replaceText(e, suggestion)
},
onFocus(e) {
if (this.blurTimeout) {
clearTimeout(this.blurTimeout)
this.blurTimeout = null
}
this.focused = true
this.setCaret(e)
this.temporarilyHideSuggestions = false
},
onKeyUp(e) {
const { key } = e
this.setCaret(e)
// Setting hider in keyUp to prevent suggestions from blinking
// when moving away from suggested spot
if (key === 'Escape') {
this.temporarilyHideSuggestions = true
} else {
this.temporarilyHideSuggestions = false
}
},
onPaste(e) {
this.setCaret(e)
},
onKeyDown(e) {
const { ctrlKey, shiftKey, key } = e
if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
this.insert({ insertion: '\n', surroundingSpace: false })
// Ensure only one new line is added on macos
e.stopPropagation()
e.preventDefault()
// Scroll the input element to the position of the cursor
this.$nextTick(() => {
this.input.blur()
this.input.focus()
})
}
// Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') {
if (shiftKey) {
this.cycleBackward(e)
} else {
this.cycleForward(e)
}
}
if (key === 'ArrowUp') {
this.cycleBackward(e)
} else if (key === 'ArrowDown') {
this.cycleForward(e)
}
if (key === 'Enter') {
if (!ctrlKey) {
this.replaceText(e)
}
}
}
// Probably add optional keyboard controls for emoji picker?
// Escape hides suggestions, if suggestions are hidden it
// de-focuses the element (i.e. default browser behavior)
if (key === 'Escape') {
if (!this.temporarilyHideSuggestions) {
this.input.focus()
}
}
},
onInput(e) {
this.setCaret(e)
this.$emit('update:modelValue', e.target.value)
},
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
this.$emit('sticker-upload-Failed', e)
},
setCaret({ target: { selectionStart } }) {
this.caret = selectionStart
this.$nextTick(() => {
this.$refs.suggestorPopover?.updateStyles()
})
},
autoCompleteItemLabel(suggestion) {
if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText
} else {
return this.maybeLocalizedEmojiName(suggestion)
}
},
},
}
export default EmojiInput
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index 6889fb8b05..a0e30bf6de 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,443 +1,443 @@
import { chunk, debounce, trim } from 'lodash'
import { defineAsyncComponent } from 'vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBasketballBall,
faBoxOpen,
faBus,
faCode,
faFlag,
faIceCream,
faLightbulb,
faPaw,
faSmile,
faSmileBeam,
faStickyNote,
faUser,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faBoxOpen,
faStickyNote,
faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faCode,
faFlag,
)
const UNICODE_EMOJI_GROUP_ICON = {
'smileys-and-emotion': 'smile',
'people-and-body': 'user',
'animals-and-nature': 'paw',
'food-and-drink': 'ice-cream',
'travel-and-places': 'bus',
activities: 'basketball-ball',
objects: 'lightbulb',
symbols: 'code',
flags: 'flag',
}
const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
const res = [emoji.displayText, nameLocalizer(emoji)]
if (emoji.annotations) {
languages.forEach((lang) => {
const keywords = emoji.annotations[lang]?.keywords || []
const name = emoji.annotations[lang]?.name
- res.push(...keywords.concat([name]).filter((k) => k))
+ res.push(...keywords.concat([name]).filter(Boolean))
})
}
return res
}
const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
const orderedEmojiList = []
for (const emoji of list) {
const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
.map((k) => k.toLowerCase().indexOf(keywordLowercase))
.filter((k) => k > -1)
const indexOfKeyword = indices.length ? Math.min(...indices) : -1
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
}
orderedEmojiList[indexOfKeyword].push(emoji)
}
}
return orderedEmojiList.flat()
}
const getOffset = (elem) => {
const style = elem.style.transform
const res = /translateY\((\d+)px\)/.exec(style)
if (!res) {
return 0
}
return res[1]
}
const toHeaderId = (id) => {
return id.replace(/^row-\d+-/, '')
}
const EmojiPicker = {
props: {
enableStickerPicker: {
required: false,
type: Boolean,
default: true,
},
hideCustomEmoji: {
required: false,
type: Boolean,
default: false,
},
},
inject: {
popoversZLayer: {
default: '',
},
},
data() {
return {
keyword: '',
activeGroup: 'custom',
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
customEmojiTimeout: null,
hideCustomEmojiInPicker: false,
// Lazy-load only after the first time `showing` becomes true.
contentLoaded: false,
popoverShown: false,
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: [],
emojiSize: 0,
width: 0,
}
},
components: {
StickerPicker: defineAsyncComponent(
() => import('src/components/sticker_picker/sticker_picker.vue'),
),
Checkbox,
Popover,
},
methods: {
groupScroll(e) {
e.currentTarget.scrollLeft += e.deltaY + e.deltaX
},
updateEmojiSize() {
const css = window.getComputedStyle(this.$refs.popover.$el)
const fontSize = css.getPropertyValue('font-size') || '1rem'
const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem'
const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '').trim()
const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, ''))
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '').trim()
const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
let fontSizeMultiplier
if (fontSizeUnit.endsWith('em')) {
fontSizeMultiplier = fontSizeValue
} else {
fontSizeMultiplier = fontSizeValue / 14
}
let emojiSizeReal
if (emojiSizeUnit.endsWith('em')) {
emojiSizeReal = emojiSizeValue * fontSizeMultiplier * 14
} else {
emojiSizeReal = emojiSizeValue
}
const fullEmojiSize = emojiSizeReal + 2 * 0.2 * fontSizeMultiplier * 14
this.emojiSize = fullEmojiSize
},
togglePicker() {
if (this.popoverShown) {
this.hidePicker()
} else {
this.showPicker()
}
},
showPicker() {
this.$refs.popover.showPopover()
this.$nextTick(() => {
this.onShowing()
})
},
hidePicker() {
this.$refs.popover.hidePopover()
},
setAnchorEl(el) {
this.$refs.popover.setAnchorEl(el)
},
setGroupRef(name) {
return (el) => {
this.groupRefs[name] = el
}
},
onPopoverShown() {
this.popoverShown = true
},
onPopoverClosed() {
this.popoverShown = false
},
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
this.$emit('sticker-upload-failed', e)
},
onEmoji(emoji) {
const value = emoji.imageUrl
? `:${emoji.displayText}:`
: emoji.replacement
if (!this.keepOpen) {
this.$refs.popover.hidePopover()
}
this.$emit('emoji', {
insertion: value,
insertionUrl: emoji.imageUrl,
keepOpen: this.keepOpen,
})
},
onScroll(startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
const target = this.$refs['emoji-groups'].$el
this.scrolledGroup(target, visibleStartIndex, visibleEndIndex)
},
scrolledGroup(target, start, end) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojiItems.slice(start, end + 1).forEach((group) => {
const headerId = toHeaderId(group.id)
const ref = this.groupRefs['group-' + group.id]
if (!ref) {
return
}
const elem = ref.$el.parentElement
if (!elem) {
return
}
if (elem && getOffset(elem) <= top) {
this.activeGroup = headerId
}
})
this.scrollHeader()
})
},
scrollHeader() {
// Scroll the active tab's header into view
const headerRef = this.groupRefs['group-header-' + this.activeGroup]
const left = headerRef.offsetLeft
const right = left + headerRef.offsetWidth
const headerCont = this.$refs.header
const currentScroll = headerCont.scrollLeft
const currentScrollRight = currentScroll + headerCont.clientWidth
const setScroll = (s) => {
headerCont.scrollLeft = s
}
const margin = 7 // .emoji-tabs-item: padding
if (left - margin < currentScroll) {
setScroll(left - margin)
} else if (right + margin > currentScrollRight) {
setScroll(right + margin - headerCont.clientWidth)
}
},
highlight(groupId) {
this.setShowStickers(false)
const indexInList = this.emojiItems.findIndex((k) => k.id === groupId)
this.$refs['emoji-groups'].scrollToItem(indexInList)
},
updateScrolledClass(target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
this.groupsScrolledClass = 'scrolled-bottom'
} else {
this.groupsScrolledClass = 'scrolled-middle'
}
},
toggleStickers() {
this.showingStickers = !this.showingStickers
},
setShowStickers(value) {
this.showingStickers = value
},
filterByKeyword(list, keyword) {
return filterByKeyword(
list,
keyword,
this.languages,
this.maybeLocalizedEmojiName,
)
},
onShowing() {
const oldContentLoaded = this.contentLoaded
this.updateEmojiSize()
this.recalculateItemPerRow()
this.$nextTick(() => {
this.$refs.search.focus()
})
this.contentLoaded = true
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
if (!oldContentLoaded) {
this.$nextTick(() => {
if (this.defaultGroup) {
this.highlight(this.defaultGroup)
}
})
}
},
getFilteredEmojiGroups() {
return this.allEmojiGroups
.map((group) => ({
...group,
emojis: this.filterByKeyword(group.emojis, trim(this.keyword)),
}))
.filter((group) => group.emojis.length > 0)
},
recalculateItemPerRow() {
this.$nextTick(() => {
if (!this.$refs['emoji-groups']) {
return
}
this.width = this.$refs['emoji-groups'].$el.clientWidth
})
},
},
watch: {
keyword() {
this.onScroll()
this.debouncedHandleKeywordChange()
},
allCustomGroups() {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
},
},
computed: {
minItemSize() {
return this.emojiSize
},
// used to watch it
fontSize() {
this.$nextTick(() => {
this.updateEmojiSize()
})
return useMergedConfigStore().mergedConfig.fontSize
},
emojiHeight() {
return this.emojiSize
},
itemPerRow() {
return this.width ? Math.floor(this.width / this.emojiSize) : 6
},
activeGroupView() {
return this.showingStickers ? '' : this.activeGroup
},
stickersAvailable() {
if (useEmojiStore().stickers) {
return useEmojiStore().stickers.length > 0
}
return 0
},
allCustomGroups() {
if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) {
return {}
}
const emojis = useEmojiStore().groupedCustomEmojis
if (emojis.unpacked) {
emojis.unpacked.text = this.$t('emoji.unpacked')
}
return emojis
},
defaultGroup() {
return Object.keys(this.allCustomGroups)[0]
},
unicodeEmojiGroups() {
return useEmojiStore().standardEmojiGroupList.map((group) => ({
id: `standard-${group.id}`,
text: this.$t(`emoji.unicode_groups.${group.id}`),
icon: UNICODE_EMOJI_GROUP_ICON[group.id],
emojis: group.emojis,
}))
},
allEmojiGroups() {
return Object.entries(this.allCustomGroups)
.map(([, v]) => v)
.concat(this.unicodeEmojiGroups)
},
stickerPickerEnabled() {
return (useEmojiStore().stickers || []).length !== 0
},
debouncedHandleKeywordChange() {
return debounce(() => {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}, 500)
},
emojiItems() {
return this.filteredEmojiGroups
.map((group) =>
chunk(group.emojis, this.itemPerRow).map((items, index) => ({
...group,
id: index === 0 ? group.id : `row-${index}-${group.id}`,
emojis: items,
isFirstRow: index === 0,
})),
)
- .reduce((a, c) => a.concat(c), [])
+ .flat()
},
languages() {
return ensureFinalFallback(
useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiName() {
return (emoji) => {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
}
},
isInModal() {
return this.popoversZLayer === 'modals'
},
},
}
export default EmojiPicker
diff --git a/src/components/font_control/font_control.js b/src/components/font_control/font_control.js
index 697d83ee21..af4d623589 100644
--- a/src/components/font_control/font_control.js
+++ b/src/components/font_control/font_control.js
@@ -1,57 +1,57 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import Select from 'src/components/select/select.vue'
import LocalSettingIndicator from 'src/components/settings_modal/helpers/local_setting_indicator.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faExclamationTriangle,
faFont,
faKeyboard,
} from '@fortawesome/free-solid-svg-icons'
library.add(faExclamationTriangle, faKeyboard, faFont)
export default {
components: {
Select,
Checkbox,
Popover,
LocalSettingIndicator,
},
props: ['name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'],
mounted() {
useInterfaceStore().queryLocalFonts()
},
emits: ['update:modelValue'],
data() {
return {
manualEntry: false,
availableOptions: [
this.noInherit ? '' : 'inherit',
'serif',
'sans-serif',
'monospace',
...(this.options || []),
- ].filter((_) => _),
+ ].filter(Boolean),
}
},
methods: {
toggleManualEntry() {
this.manualEntry = !this.manualEntry
},
},
computed: {
present() {
return this.modelValue != null
},
localFontsList() {
return useInterfaceStore().localFonts
},
localFontsSize() {
return useInterfaceStore().localFonts?.length
},
},
}
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
index 99bdb86032..5a54e83685 100644
--- a/src/components/font_control/font_control.vue
+++ b/src/components/font_control/font_control.vue
@@ -1,146 +1,146 @@
<template>
<div class="font-control">
<div class="setting-item">
<Checkbox
- v-if="typeof fallback !== 'undefined'"
+ v-if="fallback !== undefined"
:id="name + '-o'"
class="font-checkbox setting-control setting-label"
:model-value="present"
@change="$emit('update:modelValue', modelValue == null ? fallback : null)"
>
<LocalSettingIndicator />
{{ ' ' }}
<i18n-t
scope="global"
keypath="settings.style.fonts.override"
tag="span"
>
<span>
{{ label }}
</span>
</i18n-t>
</Checkbox>
</div>
{{ ' ' }}
<div
v-if="modelValue"
class="font-input setting-item"
>
<label
v-if="manualEntry"
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
<i18n-t
keypath="settings.style.themes3.font.entry"
tag="span"
scope="global"
>
<template #fontFamily>
<code>font-family</code>
</template>
</i18n-t>
</label>
<label
v-else
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
{{ $t('settings.style.themes3.font.select') }}
</label>
{{ ' ' }}
<span
v-if="manualEntry"
class="btn-group"
>
<button
class="btn button-default"
:title="$t('settings.style.themes3.font.lookup_local_fonts')"
@click="toggleManualEntry"
>
<FAIcon
fixed-width
icon="font"
/>
</button>
<input
:id="name"
:model-value="modelValue"
class="input custom-font"
type="text"
@update:modelValue="$emit('update:modelValue', $event.target.value)"
>
</span>
<span
v-else
class="font-selector btn-group"
>
<button
class="btn button-default"
:title="$t('settings.style.themes3.font.enter_manually')"
@click="toggleManualEntry"
>
<FAIcon
fixed-width
icon="keyboard"
/>
</button>
<Select
:id="name + '-local-font-switcher'"
:model-value="modelValue"
class="custom-font"
@update:model-value="v => $emit('update:modelValue', v)"
>
<optgroup
:label="$t('settings.style.themes3.font.group-builtin')"
>
<option
v-for="option in availableOptions"
:key="option"
:value="option"
:style="{ fontFamily: option === 'inherit' ? null : option }"
>
{{ $t('settings.style.themes3.font.builtin.' + option) }}
</option>
</optgroup>
<optgroup
v-if="localFontsSize > 0"
:label="$t('settings.style.themes3.font.group-local')"
>
<option
v-for="option in localFontsList"
:key="option"
:value="option"
:style="{ fontFamily: option }"
>
{{ option }}
</option>
</optgroup>
<optgroup
v-else
:label="$t('settings.style.themes3.font.group-local')"
>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable1') }}
</option>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable2') }}
</option>
</optgroup>
</Select>
</span>
</div>
</div>
</template>
<script src="./font_control.js"></script>
<style lang="scss">
.invalid-tooltip {
margin: 0.5em 1em;
min-width: 10em;
text-align: center;
}
</style>
diff --git a/src/components/lists_edit/lists_edit.js b/src/components/lists_edit/lists_edit.js
index d7a8525a82..7eb035091c 100644
--- a/src/components/lists_edit/lists_edit.js
+++ b/src/components/lists_edit/lists_edit.js
@@ -1,151 +1,151 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import ListsUserSearch from 'src/components/lists_user_search/lists_user_search.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useListsStore } from 'src/stores/lists.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronLeft, faSearch } from '@fortawesome/free-solid-svg-icons'
library.add(faSearch, faChevronLeft)
const ListsNew = {
components: {
BasicUserCard,
UserAvatar,
ListsUserSearch,
TabSwitcher,
PanelLoading,
},
data() {
return {
title: '',
titleDraft: '',
membersUserIds: [],
removedUserIds: new Set([]), // users we added for members, to undo
searchUserIds: [],
addedUserIds: new Set([]), // users we added from search, to undo
searchLoading: false,
reallyDelete: false,
}
},
created() {
if (!this.id) return
useListsStore()
.fetchList({ listId: this.id })
.then(() => {
this.title = this.findListTitle(this.id)
this.titleDraft = this.title
})
useListsStore()
.fetchListAccounts({ listId: this.id })
.then(() => {
this.membersUserIds = this.findListAccounts(this.id)
this.membersUserIds.forEach((userId) => {
this.$store.dispatch('fetchUserIfMissing', userId)
})
})
},
computed: {
id() {
return this.$route.params.id
},
membersUsers() {
return [...this.membersUserIds, ...this.addedUserIds]
.map((userId) => this.findUser(userId))
- .filter((user) => user)
+ .filter(Boolean)
},
searchUsers() {
return this.searchUserIds
.map((userId) => this.findUser(userId))
- .filter((user) => user)
+ .filter(Boolean)
},
...mapState({
currentUser: (state) => state.users.currentUser,
}),
...mapPiniaState(useListsStore, ['findListTitle', 'findListAccounts']),
...mapGetters(['findUser']),
},
methods: {
onInput() {
this.search(this.query)
},
toggleRemoveMember(user) {
if (this.removedUserIds.has(user.id)) {
this.id && this.addUser(user)
this.removedUserIds.delete(user.id)
} else {
this.id && this.removeUser(user.id)
this.removedUserIds.add(user.id)
}
},
toggleAddFromSearch(user) {
if (this.addedUserIds.has(user.id)) {
this.id && this.removeUser(user.id)
this.addedUserIds.delete(user.id)
} else {
this.id && this.addUser(user)
this.addedUserIds.add(user.id)
}
},
isRemoved(user) {
return this.removedUserIds.has(user.id)
},
isAdded(user) {
return this.addedUserIds.has(user.id)
},
addUser(user) {
useListsStore().addListAccount({ accountId: user.id, listId: this.id })
},
removeUser(userId) {
useListsStore().removeListAccount({ accountId: userId, listId: this.id })
},
onSearchLoading() {
this.searchLoading = true
},
onSearchLoadingDone() {
this.searchLoading = false
},
onSearchResults(results) {
this.searchLoading = false
this.searchUserIds = results
},
updateListTitle() {
useListsStore().setList({ listId: this.id, title: this.titleDraft })
this.title = this.findListTitle(this.id)
},
createList() {
useListsStore()
.createList({ title: this.titleDraft })
.then((list) => {
useListsStore().setListAccounts({
listId: list.id,
accountIds: [...this.addedUserIds],
})
return list.id
})
.then((listId) => {
this.$router.push({ name: 'lists-timeline', params: { id: listId } })
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
messageKey: 'lists.error',
messageArgs: [e.message],
level: 'error',
})
})
},
deleteList() {
useListsStore().deleteList({ listId: this.id })
this.$router.push({ name: 'lists' })
},
},
}
export default ListsNew
diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue
index e6b736fa33..e6bdd6b8dc 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'"
+ v-if="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 this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/range_input/range_input.vue b/src/components/range_input/range_input.vue
index 40c427784e..9fb7c69fe0 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'"
+ v-if="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'"
+ v-if="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 this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js
index bb8de17b95..ea07c662b7 100644
--- a/src/components/registration/registration.js
+++ b/src/components/registration/registration.js
@@ -1,162 +1,162 @@
import useVuelidate from '@vuelidate/core'
import { required, requiredIf, sameAs } from '@vuelidate/validators'
import { mapState as mapPiniaState } from 'pinia'
import { mapActions, mapState } from 'vuex'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import TermsOfServicePanel from 'src/components/terms_of_service_panel/terms_of_service_panel.vue'
import localeService from '../../services/locale/locale.service.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { DAY } from 'src/services/date_utils/date_utils.js'
const registration = {
setup() {
return { v$: useVuelidate() }
},
data: () => ({
user: {
email: '',
fullname: '',
username: '',
password: '',
confirm: '',
birthday: '',
reason: '',
language: [''],
},
captcha: {},
}),
components: {
InterfaceLanguageSwitcher,
TermsOfServicePanel,
},
validations() {
return {
user: {
email: { required: requiredIf(() => this.accountActivationRequired) },
username: { required },
fullname: { required },
password: { required },
confirm: {
required,
sameAs: sameAs(this.user.password),
},
birthday: {
required: requiredIf(() => this.birthdayRequired),
maxValue: (value) => {
return (
!this.birthdayRequired ||
new Date(value).getTime() <= this.birthdayMin.getTime()
)
},
},
reason: { required: requiredIf(() => this.accountApprovalRequired) },
language: {},
},
}
},
created() {
if ((!this.registrationOpen && !this.token) || this.signedIn) {
this.$router.push({ name: 'root' })
}
this.setCaptcha()
},
computed: {
token() {
return this.$route.params.token
},
bioPlaceholder() {
return this.replaceNewlines(this.$t('registration.bio_placeholder'))
},
reasonPlaceholder() {
return this.replaceNewlines(this.$t('registration.reason_placeholder'))
},
birthdayMin() {
const minAge = this.birthdayMinAge
const today = new Date()
today.setUTCMilliseconds(0)
today.setUTCSeconds(0)
today.setUTCMinutes(0)
today.setUTCHours(0)
const minDate = new Date()
minDate.setTime(today.getTime() - minAge * DAY)
return minDate
},
birthdayMinAttr() {
return this.birthdayMin.toJSON().replace(/T.+$/, '')
},
birthdayMinFormatted() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.birthday &&
new Date(Date.parse(this.birthdayMin)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
...mapPiniaState(useInstanceStore, {
registrationOpen: (store) => store.registrationOpen,
embeddedToS: (store) => store.embeddedToS,
termsOfService: (store) => store.tos,
accountActivationRequired: (store) => store.accountActivationRequired,
accountApprovalRequired: (store) => store.accountApprovalRequired,
birthdayRequired: (store) => store.birthdayRequired,
birthdayMinAge: (store) => store.birthdayMinAge,
}),
...mapState({
signedIn: (state) => !!state.users.currentUser,
isPending: (state) => state.users.signUpPending,
serverValidationErrors: (state) => state.users.signUpErrors,
signUpNotice: (state) => state.users.signUpNotice,
hasSignUpNotice: (state) => !!state.users.signUpNotice.message,
}),
},
methods: {
...mapActions(['signUp', 'getCaptcha']),
async submit() {
this.user.nickname = this.user.username
this.user.token = this.token
this.user.captcha_solution = this.captcha.solution
this.user.captcha_token = this.captcha.token
this.user.captcha_answer_data = this.captcha.answer_data
if (this.user.language) {
this.user.language = localeService.internalToBackendLocaleMulti(
- this.user.language.filter((k) => k),
+ this.user.language.filter(Boolean),
)
}
this.v$.$touch()
if (!this.v$.$invalid) {
try {
const status = await this.signUp(this.user)
if (status === 'ok') {
this.$router.push({ name: 'friends' })
}
// If status is not 'ok' (i.e. it needs further actions to be done
// before you can login), display sign up notice, do not switch anywhere
} catch (error) {
console.warn('Registration failed: ', error)
this.setCaptcha()
}
}
},
setCaptcha() {
this.getCaptcha().then((cpt) => {
this.captcha = cpt
})
},
replaceNewlines(str) {
return str.replaceAll(/\s*\n\s*/g, ' \n')
},
},
}
export default registration
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index a00bf2c471..f80f153b1a 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -1,562 +1,562 @@
import { flattenDeep, unescape as ldUnescape } from 'lodash'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
import { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.js'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import StillImageEmojiPopover from 'src/components/still-image/still-image-emoji-popover.vue'
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
import {
getAttrs,
getTagName,
processTextForEmoji,
} from 'src/services/html_converter/utility.service.js'
import './rich_content.scss'
const MAYBE_LINE_BREAKING_ELEMENTS = [
'blockquote',
'br',
'hr',
'ul',
'ol',
'li',
'p',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'h1',
'h2',
'h3',
'h4',
'h5',
]
/**
* RichContent, The Über-powered component for rendering Post HTML.
*
* This takes post HTML and does multiple things to it:
* - Groups all mentions into <MentionsLine>, this affects all mentions regardles
* of where they are (beginning/middle/end), even single mentions are converted
* to a <MentionsLine> containing single <MentionLink>.
* - Replaces emoji shortcodes with <StillImage>'d images.
*
* There are two problems with this component's architecture:
* 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
* proven to be a massive overcomplication due to amount of things done here.
* 2. We need to output both render and some extra data, which seems to be imp-
* possible in vue. Current solution is to emit 'parseReady' event when parsing
* is done within render() function.
*
* Apart from that one small hiccup with emit in render this _should_ be vue3-ready
*/
export default {
name: 'RichContent',
components: {
MentionsLine,
HashtagLink,
},
props: {
// Original html content
html: {
required: true,
type: String,
},
attentions: {
required: false,
default: () => [],
},
// Emoji object, as in status.emojis, note the "s" at the end...
emoji: {
required: true,
type: Array,
},
// Whether to handle links or not (posts: yes, everything else: no)
handleLinks: {
required: false,
type: Boolean,
default: false,
},
// Meme arrows
greentext: {
required: false,
type: Boolean,
default: false,
},
// Faint style (for notifs)
faint: {
required: false,
type: Boolean,
default: false,
},
// Collapse newlines
collapse: {
required: false,
type: Boolean,
default: false,
},
/* Content comes from current instance
*
* This is used for emoji stealing popover.
* By default we assume it is, so that steal
* emoji button isn't shown where it probably
* should not be.
*/
isLocal: {
required: false,
type: Boolean,
default: true,
},
// Allow wide emoji (max 3:1 ratio)
allowNonSquareEmoji: {
required: false,
type: Boolean,
default: false,
},
pauseMfm: {
required: false,
type: Boolean,
default: false,
},
scaleMfm: {
required: false,
type: Boolean,
default: false,
},
},
// NEVER EVER TOUCH DATA INSIDE RENDER
render() {
// Pre-process HTML
const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
let currentMentions = null // Current chain of mentions, we group all mentions together
// This is used to recover spacing removed when parsing mentions
let lastSpacing = ''
const lastTags = [] // Tags that appear at the end of post body
const writtenMentions = [] // All mentions that appear in post body
const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
// to collapse too many mentions in a row
const writtenTags = [] // All tags that appear in post body
// unique index for vue "tag" property
let mentionIndex = 0
let tagsIndex = 0
const renderImage = (tag) => {
return <StillImage {...getAttrs(tag)} class="img" />
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
if (!encounteredTextReverse) {
lastTags.push(linkData)
}
const { url, tag, content } = linkData
return <HashtagLink url={url} tag={tag} content={content} />
}
const renderMention = (attrs, children) => {
const linkData = getLinkData(attrs, children, mentionIndex++)
linkData.notifying = this.attentions.some(
(a) => a.statusnet_profile_url === linkData.url,
)
writtenMentions.push(linkData)
if (currentMentions === null) {
currentMentions = []
}
currentMentions.push(linkData)
if (currentMentions.length > MENTIONS_LIMIT) {
invisibleMentions.push(linkData)
}
if (currentMentions.length === 1) {
return <MentionsLine mentions={currentMentions} />
} else {
return ''
}
}
// Processor to use with html_tree_converter
const processItem = (item, index, array) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (item.includes('\n')) {
currentMentions = null
}
if (emptyText) {
// don't include spaces when processing mentions - we'll include them
// in MentionsLine
lastSpacing = item
// Don't remove last space in a container (fixes poast mentions)
return index !== array.length - 1 && currentMentions !== null
? item.trim()
: item
}
currentMentions = null
if (item.includes(':')) {
item = [
'',
processTextForEmoji(item, this.emoji, ({ shortcode, url }) => {
return (
<StillImageEmojiPopover
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
shortcode={shortcode}
isLocal={this.isLocal}
/>
)
}),
]
}
return item
}
// Handle tag nodes
if (Array.isArray(item)) {
const [opener, children, closer] = item
let Tag = getTagName(opener)
if (Tag.toLowerCase() === 'script') Tag = 'js-exploit'
if (Tag.toLowerCase() === 'style') Tag = 'css-exploit'
const fullAttrs = getAttrs(opener, () => true)
const attrs = getAttrs(opener)
const previouslyMentions = currentMentions !== null
/* During grouping of mentions we trim all the empty text elements
* This padding is added to recover last space removed in case
* we have a tag right next to mentions
*/
const mentionsLinePadding =
// Padding is only needed if we just finished parsing mentions
previouslyMentions &&
// Don't add padding if content is string and has padding already
!(
children &&
typeof children[0] === 'string' &&
children[0].match(/^\s/)
)
? lastSpacing
: ''
if (MAYBE_LINE_BREAKING_ELEMENTS.includes(Tag)) {
// all the elements that can cause a line change
currentMentions = null
} else if (Tag === 'img') {
// replace images with StillImage
return ['', [mentionsLinePadding, renderImage(opener)], '']
} else if (Tag === 'a' && this.handleLinks) {
// replace mentions with MentionLink
if (fullAttrs.class?.includes('mention')) {
// Handling mentions here
return renderMention(attrs, children)
} else {
currentMentions = null
}
} else if (Tag === 'span') {
if (this.handleLinks && fullAttrs.class?.includes('h-card')) {
return ['', children.map(processItem), '']
}
}
if (children !== undefined) {
return [
'',
[mentionsLinePadding, [opener, children.map(processItem), closer]],
'',
]
} else {
return ['', [mentionsLinePadding, item], '']
}
}
}
// Processor for back direction (for finding "last" stuff, just easier this way)
let encounteredTextReverse = false
const processItemReverse = (item, index, array) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (emptyText) return item
if (!encounteredTextReverse) encounteredTextReverse = true
return ldUnescape(item)
} else if (Array.isArray(item)) {
// Handle tag nodes
const [opener, children] = item
const Tag = opener === '' ? '' : getTagName(opener)
switch (Tag) {
case 'a': {
// replace mentions with MentionLink
if (!this.handleLinks) break
const fullAttrs = getAttrs(opener, () => true)
const attrs = getAttrs(opener, () => true)
// should only be this
if (
fullAttrs.class?.includes('hashtag') || // Pleroma style
fullAttrs.rel === 'tag' // Mastodon style
) {
return renderHashtag(attrs, children, encounteredTextReverse)
} else {
attrs.target = '_blank'
const newChildren = [...children]
.reverse()
.map(processItemReverse)
.reverse()
return <a {...attrs}>{newChildren}</a>
}
}
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
// Render tag as is
if (children !== undefined) {
const newChildren = Array.isArray(children)
? [...children].reverse().map(processItemReverse).reverse()
: children
const attrs = getAttrs(opener)
const newAttrs = { ...attrs }
const fullAttrs = getAttrs(opener, () => true)
const classname = fullAttrs['class']
const isMFM = classname?.startsWith('mfm-')
if (isMFM) {
const mfmOperator = /^mfm-(\w+)$/.exec(classname)?.[1]
newAttrs['class'] = [
'mfm',
this.pauseMfm ? '-pause' : '',
this.scaleMfm ? '-scale' : '',
]
.filter(Boolean)
.join(' ')
newAttrs['data-mfm-operator'] = mfmOperator
switch (mfmOperator) {
case 'position': {
const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 0
const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 0
newAttrs.style = [
'transform:',
`translate(calc(${x} * (var(--emoji-size) / 2)), `,
`calc(${y} * (var(--emoji-size) / 2)))`,
].join(' ')
break
}
case 'scale': {
const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 1
const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 1
newAttrs.style = ['transform:', `scale(${x}, ${y})`].join(' ')
break
}
case 'rotate': {
const deg = Number.parseFloat(fullAttrs['data-mfm-deg']) || 0
newAttrs.style = [
`transform: rotate(${deg}deg)`,
'transform-origin: center',
].join(';')
break
}
case 'bg': {
const color = fullAttrs['data-mfm-color'] || 0
newAttrs.style = [`background-color: #${color}`].join(' ')
break
}
case 'fg': {
const color = fullAttrs['data-mfm-color'] || 0
newAttrs.style = [`color: #${color}`].join(';')
break
}
case 'spin': {
const speed = fullAttrs['data-mfm-speed'] || '1s'
const delay = fullAttrs['data-mfm-delay'] || 0
const left = fullAttrs['data-mfm-left'] != null
const alternate = fullAttrs['data-mfm-alternate'] != null
const y = fullAttrs['data-mfm-y'] != null
const x = fullAttrs['data-mfm-x'] != null
const anim = [
x ? 'mfm-spinX' : null,
y ? 'mfm-spinY' : null,
'mfm-spin',
- ].filter((a) => a)[0]
+ ].filter(Boolean)[0]
const direction = [
alternate ? 'alternate' : null,
left ? 'reverse' : null,
'normal',
- ].filter((a) => a)[0]
+ ].filter(Boolean)[0]
newAttrs.style = [
`animation-name: ${anim}`,
`animation-duration: ${speed}`,
'animation-iteration-count: infinite',
`animation-delay: ${delay}`,
`animation-direction: ${direction}`,
'animation-fill-mode: none',
'animation-timing-function: linear',
].join(';')
break
}
case 'flip': {
newAttrs.style = 'transform: scaleX(-1)'
break
}
case 'border': {
const width = fullAttrs['data-mfm-width'] || '0'
const style = fullAttrs['data-mfm-style'] || 'solid'
const color = fullAttrs['data-mfm-color'] || 'transparent'
const radius = fullAttrs['data-mfm-radius'] || '0'
const noclip = fullAttrs['data-mfm-noclip'] || false
newAttrs.style = [
`border: ${width} ${style} ${color}`,
`border-radius: ${radius}`,
`overflow: ${noclip ? 'visible' : 'clip'}`,
].join(';')
break
}
case 'tada':
case 'jelly':
case 'twitch':
case 'shake':
case 'jump':
case 'bounce':
case 'rainbow': {
const speed = fullAttrs['data-mfm-speed'] || '1s'
const delay = fullAttrs['data-mfm-delay'] || 0
const rules = [
`animation-name: mfm-${mfmOperator}`,
`animation-duration: ${speed}`,
'animation-iteration-count: infinite',
`animation-delay: ${delay}`,
'animation-direction: normal',
'animation-fill-mode: none',
'animation-timing-function: linear',
].join(';')
newAttrs.style = rules
break
}
case 'sparkle':
case 'x2':
case 'x3':
case 'x4':
// handled by css
break
default:
console.warn('Unsupported MFM operator:', mfmOperator, opener)
break
}
}
return <Tag {...newAttrs}>{newChildren}</Tag>
} else {
return <Tag />
}
}
return item
}
const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
// DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3?
const result = (
<span
class={[
'RichContent',
this.faint ? '-faint' : '',
this.allowNonSquareEmoji ? '-allow-non-square-emoji' : '',
]}
>
{this.collapse
? pass2.map((x) => {
if (typeof x === 'string') return x.replaceAll('\n', ' ')
if (!Array.isArray(x)) return x
return x.map((y) => (y.type === 'br' ? ' ' : y))
})
: pass2}
</span>
)
const event = {
lastTags,
writtenMentions,
writtenTags,
invisibleMentions,
}
// DO NOT MOVE TO UPDATE. BAD IDEA.
this.$emit('parseReady', event)
return result
},
}
const getLinkData = (attrs, children, index) => {
const stripTags = (item) => {
if (typeof item === 'string') {
return item
} else {
return item[1].map(stripTags).join('')
}
}
const textContent = children.map(stripTags).join('')
return {
index,
url: attrs.href,
tag: attrs['data-tag'],
content: flattenDeep(children).join(''),
textContent,
}
}
/** Pre-processing HTML
*
* Currently this does one thing:
* - add green/cyantexting
*
* @param {String} html - raw HTML to process
* @param {Boolean} greentext - whether to enable greentexting or not
*/
export const preProcessPerLine = (html, greentext) => {
const greentextHandle = new Set(['p', 'div'])
const lines = convertHtmlToLines(html)
const newHtml = lines
.reverse()
.map((item, index, array) => {
if (!item.text) return item
const string = item.text
// Greentext stuff
if (
// Only if greentext is engaged
greentext &&
// Only handle p's and divs. Don't want to affect blockquotes, code etc
item.level.every((l) => greentextHandle.has(l)) &&
// Only if line begins with '>' or '<'
(string.includes('&gt;') || string.includes('&lt;'))
) {
const cleanedString = string
.replaceAll(/<[^>]+?>/gi, '') // remove all tags
.replaceAll(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
if (cleanedString.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else if (cleanedString.startsWith('&lt;')) {
return `<span class='cyantext'>${string}</span>`
}
}
return string
})
.reverse()
.join('')
return { newHtml }
}
diff --git a/src/components/roundness_input/roundness_input.vue b/src/components/roundness_input/roundness_input.vue
index 36fa8fd474..ad735f2d97 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'"
+ v-if="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 this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/settings_modal/admin_tabs/emoji_tab.js b/src/components/settings_modal/admin_tabs/emoji_tab.js
index 7dc9e91bfc..33ead7c160 100644
--- a/src/components/settings_modal/admin_tabs/emoji_tab.js
+++ b/src/components/settings_modal/admin_tabs/emoji_tab.js
@@ -1,318 +1,318 @@
import Checkbox from 'components/checkbox/checkbox.vue'
import Popover from 'components/popover/popover.vue'
import Select from 'components/select/select.vue'
import StillImage from 'components/still-image/still-image.vue'
import { clone } from 'lodash'
import { defineAsyncComponent } from 'vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue'
import ModifiedIndicator from '../helpers/modified_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowsRotate,
faDownload,
faFolderOpen,
faServer,
} from '@fortawesome/free-solid-svg-icons'
library.add(faArrowsRotate, faFolderOpen, faDownload, faServer)
const EmojiTab = {
components: {
TabSwitcher,
StringSetting,
Checkbox,
StillImage,
Select,
Popover,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
ModifiedIndicator,
EmojiEditingPopover,
},
data() {
return {
knownLocalPacks: {},
knownRemotePacks: {},
editedMetadata: {},
packName: '',
newPackName: '',
deleteModalVisible: false,
remotePackInstance: '',
remotePackDownloadAs: '',
remotePackURL: '',
remotePackFile: null,
}
},
provide() {
return { emojiAddr: this.emojiAddr }
},
computed: {
...SharedComputedObject(),
pack() {
return this.packName !== '' ? this.knownPacks[this.packName] : undefined
},
packMeta() {
if (this.packName === '') return {}
if (this.editedMetadata[this.packName] === undefined) {
this.editedMetadata[this.packName] = clone(this.pack.pack)
}
return this.editedMetadata[this.packName]
},
knownPacks() {
// Copy the object itself but not the children, so they are still passed by reference and modified
const result = clone(this.knownLocalPacks)
for (const instName in this.knownRemotePacks) {
for (const instPack in this.knownRemotePacks[instName]) {
result[`${instPack}@${instName}`] =
this.knownRemotePacks[instName][instPack]
}
}
return result
},
downloadWillReplaceLocal() {
return (
(this.remotePackDownloadAs.trim() === '' &&
this.pack.remote &&
this.pack.remote.baseName in this.knownLocalPacks) ||
this.remotePackDownloadAs in this.knownLocalPacks
)
},
},
methods: {
reloadEmoji() {
useAdminSettingsStore().reloadEmoji()
},
importFromFS() {
useAdminSettingsStore().importEmojiFromFS()
},
emojiAddr(name) {
if (this.pack.remote !== undefined) {
// Remote pack
return `${this.pack.remote.instance}/emoji/${encodeURIComponent(this.pack.remote.baseName)}/${name}`
} else {
return `${useInstanceStore().server}/emoji/${encodeURIComponent(this.packName)}/${name}`
}
},
createEmojiPack() {
useAdminSettingsStore()
.createEmojiPack({ name: this.newPackName })
.then((resp) => resp.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
- return Promise.reject(resp)
+ throw new Error(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
})
},
deleteEmojiPack() {
useAdminSettingsStore()
.deleteEmojiPack({ name: this.packName })
.then((resp) => resp.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
- return Promise.reject(resp)
+ throw new Error(resp)
}
})
.then(() => {
delete this.editedMetadata[this.packName]
this.deleteModalVisible = false
this.packName = ''
})
},
metaEdited(prop) {
if (!this.pack) return
const def = this.pack.pack[prop] || ''
const edited = this.packMeta[prop] || ''
return edited !== def
},
savePackMetadata() {
useAdminSettingsStore()
.saveEmojiPackMetadata({ name: this.packName, newData: this.packMeta })
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.displayError(resp.error)
return
}
// Update actual pack data
this.pack.pack = resp
// Delete edited pack data, should auto-update itself
delete this.editedMetadata[this.packName]
})
},
updatePackFiles(newFiles, packName) {
this.knownPacks[packName].files = newFiles
this.sortPackFiles(packName)
},
refreshPackList() {
useEmojiStore()
.getAdminPacks(
this.remotePackInstance,
useAdminSettingsStore().listEmojiPacks,
)
.then((allPacks) => {
this.knownLocalPacks = allPacks
for (const name of Object.keys(this.knownLocalPacks)) {
this.sortPackFiles(name)
}
})
},
listRemotePacks() {
useEmojiStore()
.getAdminPacks(
this.remotePackInstance,
useAdminSettingsStore().listRemoteEmojiPacks,
)
.then((allPacks) => {
let inst = this.remotePackInstance
if (!inst.startsWith('http')) {
inst = 'https://' + inst
}
const instUrl = new URL(inst)
inst = instUrl.host
for (const packName in allPacks) {
allPacks[packName].remote = {
baseName: packName,
instance: instUrl.origin,
}
}
this.knownRemotePacks[inst] = allPacks
for (const pack in this.knownRemotePacks[inst]) {
this.sortPackFiles(`${pack}@${inst}`)
}
})
.catch((data) => {
this.displayError(data)
})
},
downloadRemotePack() {
if (this.remotePackDownloadAs.trim() === '') {
this.remotePackDownloadAs = this.pack.remote.baseName
}
useAdminSettingsStore()
.downloadRemoteEmojiPack({
instance: this.pack.remote.instance,
packName: this.pack.remote.baseName,
as: this.remotePackDownloadAs,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
- return Promise.reject(resp)
+ throw new Error(resp)
}
})
.then(() => {
this.packName = this.remotePackDownloadAs
this.remotePackDownloadAs = ''
})
},
downloadRemoteURLPack() {
useAdminSettingsStore()
.downloadRemoteEmojiPackZIP({
url: this.remotePackURL,
packName: this.newPackName,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
- return Promise.reject(resp)
+ throw new Error(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
downloadRemoteFilePack() {
useAdminSettingsStore()
.downloadRemoteEmojiPackZIP({
file: this.remotePackFile[0],
packName: this.newPackName,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
- return Promise.reject(resp)
+ throw new Error(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
displayError(msg) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error',
messageArgs: [msg],
level: 'error',
})
},
sortPackFiles(nameOfPack) {
// Sort by key
const sorted = Object.keys(this.knownPacks[nameOfPack].files)
.sort((a, b) => a.localeCompare(b))
.reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = this.knownPacks[nameOfPack].files[key]
return acc
}, {})
this.knownPacks[nameOfPack].files = sorted
},
},
mounted() {
this.refreshPackList()
},
}
export default EmojiTab
diff --git a/src/components/settings_modal/helpers/emoji_editing_popover.vue b/src/components/settings_modal/helpers/emoji_editing_popover.vue
index d3db657d30..0cd04f0538 100644
--- a/src/components/settings_modal/helpers/emoji_editing_popover.vue
+++ b/src/components/settings_modal/helpers/emoji_editing_popover.vue
@@ -1,351 +1,351 @@
<template>
<Popover
ref="emojiPopover"
trigger="click"
:placement="placement"
bound-to-selector=".emoji-list"
popover-class="emoji-tab-edit-popover popover-default"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
:class="{'emoji-unsaved': isEdited}"
>
<template #trigger>
<slot name="trigger" />
</template>
<template #content>
<h3>
{{ title }}
</h3>
<StillImage
v-if="emojiPreview"
class="emoji"
:src="emojiPreview"
/>
<div
v-else
class="emoji"
/>
<div
v-if="newUpload"
class="emoji-tab-popover-new-upload"
>
<h4>{{ $t('admin_dash.emoji.emoji_source') }}</h4>
<div class="emoji-tab-popover-input">
<input
type="file"
accept="image/*"
class="emoji-tab-popover-file input"
@change="uploadFile = $event.target.files"
>
</div>
<div class="emoji-tab-popover-input ">
<input
v-model="uploadURL"
:placeholder="$t('admin_dash.emoji.upload_url')"
class="emoji-data-input input"
>
</div>
</div>
<div>
<div class="emoji-tab-popover-input">
<label>
{{ $t('admin_dash.emoji.shortcode') }}
<input
v-model="editedShortcode"
class="emoji-data-input input"
:placeholder="$t('admin_dash.emoji.new_shortcode')"
>
</label>
</div>
<div class="emoji-tab-popover-input">
<label>
{{ $t('admin_dash.emoji.filename') }}
<input
v-model="editedFile"
class="emoji-data-input input"
:placeholder="$t('admin_dash.emoji.new_filename')"
>
</label>
</div>
<div
v-if="remote !== undefined"
class="emoji-tab-popover-input"
>
<label>
{{ $t('admin_dash.emoji.copy_to') }}
<SelectComponent
v-model="copyToPack"
class="form-control"
>
<option
value=""
disabled
hidden
>
{{ $t('admin_dash.emoji.emoji_pack') }}
</option>
<option
v-for="(pack, listPackName) in knownLocalPacks"
:key="listPackName"
:label="listPackName"
>
{{ listPackName }}
</option>
</SelectComponent>
</label>
</div>
<!--
For local emojis, disable the button if nothing was edited.
For remote emojis, also disable it if a local pack is not selected.
Remote emojis are processed by the same function that uploads new ones, as that is effectively what it does
-->
<button
class="button button-default btn"
type="button"
:disabled="saveButtonDisabled"
@click="(newUpload || remote !== undefined) ? uploadEmoji() : saveEditedEmoji()"
>
{{ $t('admin_dash.emoji.save') }}
</button>
<template v-if="!newUpload && remote === undefined">
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
@click="deleteModalVisible = true"
>
{{ $t('admin_dash.emoji.delete') }}
</button>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
@click="revertEmoji"
>
{{ $t('admin_dash.emoji.revert') }}
</button>
<ConfirmModal
v-if="deleteModalVisible"
:title="$t('admin_dash.emoji.delete_title')"
:cancel-text="$t('status.delete_confirm_cancel_button')"
:confirm-text="$t('status.delete_confirm_accept_button')"
@cancelled="deleteModalVisible = false"
@accepted="deleteEmoji"
>
{{ $t('admin_dash.emoji.delete_confirm', [shortcode]) }}
</ConfirmModal>
</template>
</div>
</template>
</Popover>
</template>
<script>
import Popover from 'components/popover/popover.vue'
import SelectComponent from 'components/select/select.vue'
import { defineAsyncComponent } from 'vue'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
addNewEmojiFile,
deleteEmojiFile,
updateEmojiFile,
} from 'src/api/admin.js'
export default {
components: {
Popover,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
SelectComponent,
},
inject: ['emojiAddr'],
props: {
placement: {
type: String,
required: true,
},
newUpload: Boolean,
title: {
type: String,
required: true,
},
packName: {
type: String,
required: true,
},
shortcode: {
type: String,
// Only exists when this is not a new upload
default: '',
},
file: {
type: String,
// Only exists when this is not a new upload
default: '',
},
// Only exists for emojis from remote packs
remote: {
type: Object,
default: undefined,
},
knownLocalPacks: {
type: Object,
default: undefined,
},
},
emits: ['updatePackFiles', 'displayError'],
data() {
return {
uploadFile: [],
uploadURL: '',
editedShortcode: this.shortcode,
editedFile: this.file,
deleteModalVisible: false,
copyToPack: '',
}
},
computed: {
emojiPreview() {
if (this.newUpload && this.uploadFile.length > 0) {
return URL.createObjectURL(this.uploadFile[0])
} else if (this.newUpload && this.uploadURL !== '') {
return this.uploadURL
} else if (!this.newUpload) {
return this.emojiAddr(this.file)
}
return null
},
isEdited() {
return (
!this.newUpload &&
(this.editedShortcode !== this.shortcode ||
this.editedFile !== this.file)
)
},
saveButtonDisabled() {
if (this.remote === undefined)
return this.newUpload
? this.uploadURL === '' && this.uploadFile.length == 0
: !this.isEdited
else return this.copyToPack === ''
},
},
methods: {
saveEditedEmoji() {
if (!this.isEdited) return
updateEmojiFile({
packName: this.packName,
shortcode: this.shortcode,
newShortcode: this.editedShortcode,
newFilename: this.editedFile,
force: false,
credentials: useOAuthStore().token,
})
.then((resp) => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
- return Promise.reject(resp.error)
+ throw new Error(resp.error)
}
return resp.json()
})
.then((resp) => this.$emit('updatePackFiles', resp))
},
uploadEmoji() {
let packName = this.remote === undefined ? this.packName : this.copyToPack
addNewEmojiFile({
packName: packName,
file:
this.remote === undefined
? this.uploadURL !== ''
? this.uploadURL
: this.uploadFile[0]
: this.emojiAddr(this.file),
shortcode: this.editedShortcode,
filename: this.editedFile,
credentials: useOAuthStore().token,
})
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
return
}
this.$emit('updatePackFiles', resp, packName)
this.$refs.emojiPopover.hidePopover()
this.editedFile = ''
this.editedShortcode = ''
this.uploadFile = []
})
},
revertEmoji() {
this.editedFile = this.file
this.editedShortcode = this.shortcode
},
deleteEmoji() {
this.deleteModalVisible = false
deleteEmojiFile({
packName: this.packName,
shortcode: this.shortcode,
credentials: useOAuthStore().token,
})
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
return
}
this.$emit('updatePackFiles', resp, this.packName)
})
},
},
}
</script>
<style lang="scss">
.emoji-tab-edit-popover {
padding-left: 0.5em;
padding-right: 0.5em;
padding-bottom: 0.5em;
.emoji-tab-popover-new-upload {
margin-bottom: 2em;
}
.emoji {
width: 2.3em;
height: 2.3em;
}
.Select {
display: inline-block;
}
h4 {
margin-bottom: 1em;
margin-top: 1em;
}
}
</style>
diff --git a/src/components/settings_modal/helpers/number_setting.js b/src/components/settings_modal/helpers/number_setting.js
index d0b23c919d..8f8861c25f 100644
--- a/src/components/settings_modal/helpers/number_setting.js
+++ b/src/components/settings_modal/helpers/number_setting.js
@@ -1,39 +1,39 @@
import Setting from './setting.js'
export default {
...Setting,
props: {
...Setting.props,
min: {
type: Number,
required: false,
default: 1,
},
max: {
type: Number,
required: false,
default: 1,
},
step: {
type: Number,
required: false,
default: 1,
},
truncate: {
type: Number,
required: false,
default: 1,
},
},
methods: {
...Setting.methods,
getValue(e) {
- if (!this.truncate === 1) {
+ if (this.truncate === 1) {
return Number.parseInt(e.target.value)
} else if (this.truncate > 1) {
return Math.trunc(e.target.value / this.truncate) * this.truncate
}
- return parseFloat(e.target.value)
+ return Number.parseFloat(e.target.value)
},
},
}
diff --git a/src/components/settings_modal/helpers/unit_setting.js b/src/components/settings_modal/helpers/unit_setting.js
index 99f4ac38ea..3be6b7a104 100644
--- a/src/components/settings_modal/helpers/unit_setting.js
+++ b/src/components/settings_modal/helpers/unit_setting.js
@@ -1,84 +1,87 @@
import Select from 'src/components/select/select.vue'
import Setting from './setting.js'
export const allCssUnits = [
'cm',
'mm',
'in',
'px',
'pt',
'pc',
'em',
'ex',
'ch',
'rem',
'vw',
'vh',
'vmin',
'vmax',
'%',
]
export const defaultHorizontalUnits = ['px', 'rem', 'vw']
export const defaultVerticalUnits = ['px', 'rem', 'vh']
export default {
...Setting,
components: {
...Setting.components,
Select,
},
props: {
...Setting.props,
min: Number,
units: {
type: Array,
default: () => allCssUnits,
},
unitSet: {
type: String,
default: 'none',
},
step: {
type: Number,
default: 1,
},
resetDefault: {
type: Object,
default: null,
},
},
computed: {
...Setting.computed,
stateUnit() {
return typeof this.state === 'string'
? this.state.replace(/[0-9,.]+/, '')
: ''
},
stateValue() {
return typeof this.state === 'string'
? this.state.replace(/[^0-9,.]+/, '')
: ''
},
},
methods: {
...Setting.methods,
getUnitString(value) {
if (this.unitSet === 'none') return value
return this.$t(['settings', 'units', this.unitSet, value].join('.'))
},
updateValue(e) {
- this.configSink(this.path, parseFloat(e.target.value) + this.stateUnit)
+ this.configSink(
+ this.path,
+ Number.parseFloat(e.target.value) + this.stateUnit,
+ )
},
updateUnit(e) {
let value = this.stateValue
const newUnit = e.target.value
if (this.resetDefault) {
const replaceValue = this.resetDefault[newUnit]
if (replaceValue != null) {
value = replaceValue
}
}
this.configSink(this.path, value + newUnit)
},
},
}
diff --git a/src/components/settings_modal/tabs/appearance_tab.js b/src/components/settings_modal/tabs/appearance_tab.js
index d93758f9a9..647c90c47d 100644
--- a/src/components/settings_modal/tabs/appearance_tab.js
+++ b/src/components/settings_modal/tabs/appearance_tab.js
@@ -1,509 +1,509 @@
import { mapActions } from 'pinia'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import Preview from './old_theme_tab/theme_preview.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { normalizeThemeData, useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileImages } from 'src/api/user.js'
import { newImporter } from 'src/services/export_import/export_import.js'
import {
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
import { getCssRules } from 'src/services/theme_data/css_utils.js'
import { deserialize } from 'src/services/theme_data/iss_deserializer.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
const AppearanceTab = {
data() {
return {
availableThemesV3: [],
availableThemesV2: [],
bundledPalettes: [],
compilationCache: {},
fileImporter: newImporter({
accept: '.json, .iss',
validator: this.importValidator,
onImport: this.onImport,
parser: this.importParser,
onImportFailure: this.onImportFailure,
}),
palettesKeys: [
'bg',
'fg',
'link',
'text',
'cRed',
'cGreen',
'cBlue',
'cOrange',
],
userPalette: {},
intersectionObserver: null,
forcedRoundnessOptions: ['disabled', 'sharp', 'nonsharp', 'round'].map(
(mode, i) => ({
key: mode,
value: i - 1,
label: this.$t(
`settings.style.themes3.hacks.forced_roundness_mode_${mode}`,
),
}),
),
underlayOverrideModes: ['none', 'opaque', 'transparent'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(
`settings.style.themes3.hacks.underlay_override_mode_${mode}`,
),
})),
backgroundUploading: false,
background: null,
backgroundError: null,
backgroundPreview: null,
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
Preview,
PaletteEditor,
},
mounted() {
useInterfaceStore().getThemeData()
const updateIndex = (resource) => {
const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
const currentIndex = useInstanceStore()[`${resource}sIndex`]
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = useInterfaceStore()[`fetch${capitalizedResource}sIndex`]()
}
return promise.then((index) => {
return Object.entries(index).map(([k, func]) => [k, func()])
})
}
updateIndex('style').then((styles) => {
styles.forEach(([key, stylePromise]) =>
stylePromise.then((data) => {
const meta = data.find((x) => x.component === '@meta')
this.availableThemesV3.push({
key,
data,
name: meta.directives.name,
version: 'v3',
})
}),
)
})
updateIndex('theme').then((themes) => {
themes.forEach(([key, themePromise]) =>
themePromise.then((data) => {
if (!data) {
console.warn(`Theme with key ${key} is empty or malformed`)
} else if (Array.isArray(data)) {
console.warn(
`Theme with key ${key} is a v1 theme and should be moved to static/palettes/index.json`,
)
} else if (!data.source && !data.theme) {
console.warn(`Theme with key ${key} is malformed`)
} else {
this.availableThemesV2.push({
key,
data,
name: data.name,
version: 'v2',
})
}
}),
)
})
this.userPalette = useInterfaceStore().paletteDataUsed || {}
updateIndex('palette').then((bundledPalettes) => {
bundledPalettes.forEach(([key, palettePromise]) =>
palettePromise.then((v) => {
let palette
if (Array.isArray(v)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = v
palette = {
key,
name,
bg,
fg,
text,
link,
cRed,
cBlue,
cGreen,
cOrange,
}
} else {
palette = { key, ...v }
}
if (!palette.key.startsWith('style.')) {
this.bundledPalettes.push(palette)
}
}),
)
})
this.previewTheme('stock', 'v3')
if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach(({ target, isIntersecting }) => {
if (!isIntersecting) return
const theme = this.availableStyles.find(
(x) => x.key === target.dataset.themeKey,
)
this.$nextTick(() => {
if (theme) this.previewTheme(theme.key, theme.version, theme.data)
})
observer.unobserve(target)
})
},
{
root: this.$refs.themeList,
},
)
} else {
this.availableStyles.forEach((theme) =>
this.previewTheme(theme.key, theme.version, theme.data),
)
}
},
updated() {
this.$nextTick(() => {
this.$refs.themeList
.querySelectorAll('.theme-preview')
.forEach((node) => {
this.intersectionObserver.observe(node)
})
})
},
watch: {
paletteDataUsed() {
this.userPalette = this.paletteDataUsed || {}
},
},
computed: {
isDefaultBackground() {
return !this.$store.state.users.currentUser.background_image
},
switchInProgress() {
return useInterfaceStore().themeChangeInProgress
},
paletteDataUsed() {
return useInterfaceStore().paletteDataUsed
},
availableStyles() {
return [...this.availableThemesV3, ...this.availableThemesV2]
},
availablePalettes() {
return [...this.bundledPalettes, ...this.stylePalettes]
},
stylePalettes() {
const ruleset = useInterfaceStore().styleDataUsed || []
- if (!ruleset?.length === 0) return
+ if (ruleset.length === 0) return
const meta = ruleset.find((x) => x.component === '@meta')
const result = ruleset
.filter((x) => x.component.startsWith('@palette'))
.map((x) => {
const { variant, directives } = x
const {
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper,
} = directives
const result = {
name: `${meta.directives.name || this.$t('settings.style.themes3.palette.imported')}: ${variant}`,
key: `style.${variant.toLowerCase().replaceAll(' ', '_')}`,
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper,
}
return Object.fromEntries(Object.entries(result).filter(([, v]) => v))
})
return result
},
noIntersectionObserver() {
return !window.IntersectionObserver
},
instanceWallpaper() {
- useInstanceStore().instanceIdentity.background
+ return useInstanceStore().instanceIdentity.background
},
instanceWallpaperUsed() {
return (
useInstanceStore().instanceIdentity.background &&
!this.$store.state.users.currentUser.background_image
)
},
customThemeVersion() {
const { themeVersion } = useInterfaceStore()
return themeVersion
},
isCustomThemeUsed() {
const { customTheme, customThemeSource } = this.mergedConfig
return customTheme != null || customThemeSource != null
},
isCustomStyleUsed() {
const { styleCustomData } = this.mergedConfig
return styleCustomData != null
},
...SharedComputedObject(),
},
methods: {
importFile() {
this.fileImporter.importData()
},
importParser(file, filename) {
if (filename.endsWith('.json')) {
return JSON.parse(file)
} else if (filename.endsWith('.iss')) {
return deserialize(file)
}
},
onImport(parsed, filename) {
if (filename.endsWith('.json')) {
useInterfaceStore().setThemeCustom(parsed.source || parsed.theme)
} else if (filename.endsWith('.iss')) {
useInterfaceStore().setStyleCustom(parsed)
}
},
onImportFailure(result) {
console.error('Failure importing theme:', result)
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
importValidator(parsed, filename) {
if (filename.endsWith('.json')) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
} else if (filename.endsWith('.iss')) {
if (!Array.isArray(parsed)) return false
if (parsed.length < 1) return false
if (parsed.find((x) => x.component === '@meta') == null) return false
return true
}
},
isThemeActive(key) {
return (
key ===
(this.mergedConfig.theme || useInstanceStore().instanceIdentity.theme)
)
},
isStyleActive(key) {
return (
key ===
(this.mergedConfig.style || useInstanceStore().instanceIdentity.style)
)
},
isPaletteActive(key) {
return (
key ===
(this.mergedConfig.palette ||
useInstanceStore().instanceIdentity.palette)
)
},
...mapActions(useInterfaceStore, ['setStyle', 'setTheme']),
setPalette(name, data) {
useInterfaceStore().setPalette(name)
this.userPalette = data
},
setPaletteCustom(data) {
useInterfaceStore().setPaletteCustom(data)
this.userPalette = data
},
resetTheming() {
useInterfaceStore().setStyle('stock')
},
previewTheme(key, version, input) {
let theme3
if (this.compilationCache[key]) {
theme3 = this.compilationCache[key]
} else if (input) {
if (version === 'v2') {
const style = normalizeThemeData(input)
const theme2 = convertTheme2To3(style)
theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true,
})
} else if (version === 'v3') {
const palette = input.find((x) => x.component === '@palette')
let paletteRule
if (palette) {
const { directives } = palette
directives.link = directives.link || directives.accent
directives.accent = directives.accent || directives.link
paletteRule = {
component: 'Root',
directives: Object.fromEntries(
Object.entries(directives)
.filter(([k]) => k && k !== 'name')
.map(([k, v]) => ['--' + k, 'color | ' + v]),
),
}
} else {
paletteRule = null
}
theme3 = init({
inputRuleset: [...input, paletteRule].filter(Boolean),
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true,
})
}
} else {
theme3 = init({
inputRuleset: [],
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true,
})
}
if (!this.compilationCache[key]) {
this.compilationCache[key] = theme3
}
const sheet = createStyleSheet('appearance-tab-previews', 90)
sheet.addRule(
[
'#theme-preview-',
key,
' {\n',
getCssRules(theme3.eager).join('\n'),
'\n}',
].join(''),
)
sheet.ready = true
adoptStyleSheets()
},
uploadFile(slot, e) {
const file = e.target.files[0]
if (!file) {
return
}
if (file.size > useInstanceStore()[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(
useInstanceStore()[slot + 'limit'],
)
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [
this.$t('upload.error.file_too_big', {
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit,
}),
],
level: 'error',
})
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
resetBackground() {
const confirmed = window.confirm(
this.$t('settings.reset_background_confirm'),
)
if (confirmed) {
this.submitBackground('')
}
},
resetUploadedBackground() {
this.backgroundPreview = null
},
clearBackgroundError() {
this.backgroundError = null
},
submitBackground(background) {
if (!this.backgroundPreview && background !== '') {
return
}
this.backgroundUploading = true
updateProfileImages({
background,
credentials: useOAuthStore().token,
})
.then(({ data }) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
this.backgroundError = null
})
.catch((e) => {
this.backgroundError = e
})
.finally(() => {
this.backgroundUploading = false
})
},
},
}
export default AppearanceTab
diff --git a/src/components/settings_modal/tabs/clutter_tab.js b/src/components/settings_modal/tabs/clutter_tab.js
index 85749ab3f1..87269eb998 100644
--- a/src/components/settings_modal/tabs/clutter_tab.js
+++ b/src/components/settings_modal/tabs/clutter_tab.js
@@ -1,159 +1,43 @@
-import { mapActions, mapState } from 'pinia'
-import { v4 as uuidv4 } from 'uuid'
+import { mapState } from 'pinia'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
-import { useSyncConfigStore } from 'src/stores/sync_config.js'
const ClutterTab = {
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting,
Checkbox,
Select,
HelpIndicator,
},
computed: {
...SharedComputedObject(),
...mapState(useInstanceCapabilitiesStore, ['shoutAvailable']),
...mapState(useInstanceStore, {
showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel,
instanceSpecificPanelPresent: (store) =>
store.instanceIdentity.showInstanceSpecificPanel &&
store.instanceIdentity.instanceSpecificPanelContent,
}),
- ...mapState(useSyncConfigStore, {
- muteFilters: (store) =>
- Object.entries(store.prefsStorage.simple.muteFilters),
- muteFiltersObject: (store) => store.prefsStorage.simple.muteFilters,
- }),
- },
- methods: {
- ...mapActions(useSyncConfigStore, [
- 'setSimplePrefAndSave',
- 'unsetSimplePrefAndSave',
- 'pushSyncConfig',
- ]),
- getDatetimeLocal(timestamp) {
- const date = new Date(timestamp)
- const fmt = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 2 })
- const datetime = [
- date.getFullYear(),
- '-',
- fmt.format(date.getMonth() + 1),
- '-',
- fmt.format(date.getDate()),
- 'T',
- fmt.format(date.getHours()),
- ':',
- fmt.format(date.getMinutes()),
- ].join('')
- return datetime
- },
- checkRegexValid(id) {
- const filter = this.muteFiltersObject[id]
- if (filter.type !== 'regexp') return true
- if (filter.type !== 'user_regexp') return true
- const { value } = filter
- let valid = true
- try {
- new RegExp(value)
- } catch {
- valid = false
- console.error('Invalid RegExp: ' + value)
- }
- return valid
- },
- createFilter(
- filter = {
- type: 'word',
- value: '',
- name: 'New Filter',
- enabled: true,
- expires: null,
- hide: false,
- },
- ) {
- const newId = uuidv4()
-
- filter.order = this.muteFilters.length + 2
- this.muteFiltersDraftObject[newId] = filter
- this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
- this.pushSyncConfig()
- },
- exportFilter(id) {
- this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
- delete this.exportedFilter.order
- this.filterExporter.exportData()
- },
- importFilter() {
- this.filterImporter.importData()
- },
- copyFilter(id) {
- const filter = { ...this.muteFiltersDraftObject[id] }
- const newId = uuidv4()
-
- this.muteFiltersDraftObject[newId] = filter
- this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
- this.pushSyncConfig()
- },
- deleteFilter(id) {
- delete this.muteFiltersDraftObject[id]
- this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
- this.pushSyncConfig()
- },
- purgeExpiredFilters() {
- this.muteFiltersExpired.forEach(([id]) => {
- delete this.muteFiltersDraftObject[id]
- this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
- })
- this.pushSyncConfig()
- },
- updateFilter(id, field, value) {
- const filter = { ...this.muteFiltersDraftObject[id] }
- if (field === 'expires-never') {
- if (!value) {
- const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
- const date = Date.now() + offset
- filter.expires = date
- } else {
- filter.expires = null
- }
- } else if (field === 'expires') {
- const parsed = Date.parse(value)
- filter.expires = parsed.valueOf()
- } else {
- filter[field] = value
- }
- this.muteFiltersDraftObject[id] = filter
- this.muteFiltersDraftDirty[id] = true
- },
- saveFilter(id) {
- this.setSimplePrefAndSave({
- path: 'muteFilters.' + id,
- value: this.muteFiltersDraftObject[id],
- })
- this.pushSyncConfig()
- this.muteFiltersDraftDirty[id] = false
- },
},
// Updating nested properties
watch: {
replyVisibility() {
this.$store.dispatch('queueFlushAll')
},
},
}
export default ClutterTab
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index e0d0f6879f..3a9cad6d27 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,275 +1,278 @@
import { cloneDeep } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
const SUPPORTED_TYPES = new Set(['word', 'regexp', 'user', 'user_regexp'])
const FilteringTab = {
data() {
return {
replyVisibilityOptions: ['all', 'following', 'self'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.reply_visibility_${mode}`),
})),
muteBlockLv1Options: ['ask', 'forever', 'temporarily'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`user_card.mute_block_${mode}`),
})),
muteFiltersDraftObject: cloneDeep(
useSyncConfigStore().prefsStorage.simple.muteFilters,
),
muteFiltersDraftDirty: Object.fromEntries(
Object.entries(
useSyncConfigStore().prefsStorage.simple.muteFilters,
).map(([k]) => [k, false]),
),
exportedFilter: null,
filterImporter: newImporter({
validator(parsed) {
if (Array.isArray(parsed)) return false
if (!SUPPORTED_TYPES.has(parsed.type)) return false
return true
},
onImport: (data) => {
const {
enabled = true,
expires = null,
hide = false,
name = '',
value = '',
caseSensitive = false,
} = data
this.createFilter({
enabled,
expires,
hide,
name,
value,
caseSensitive,
})
},
onImportFailure(result) {
console.error('Failure importing filter:', result)
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.filter.import_failure',
level: 'error',
})
},
}),
filterExporter: newExporter({
filename: 'pleromafe_mute-filter',
getExportedObject: () => this.exportedFilter,
}),
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting,
Checkbox,
Select,
HelpIndicator,
},
computed: {
...SharedComputedObject(),
...mapState(useSyncConfigStore, {
muteFilters: (store) =>
Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: (store) => store.prefsStorage.simple.muteFilters,
}),
...mapState(useInstanceCapabilitiesStore, ['blockExpiration']),
onMuteDefaultActionLv1: {
get() {
const value = useMergedConfigStore().mergedConfig.onMuteDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set(value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.setPreference({
path: 'simple.onMuteDefaultAction',
value: realValue,
})
},
},
onBlockDefaultActionLv1: {
get() {
const value = useMergedConfigStore().mergedConfig.onBlockDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set(value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.setPreference({
path: 'simple.onBlockDefaultAction',
value: realValue,
})
},
},
muteFiltersDraft() {
return Object.entries(this.muteFiltersDraftObject)
},
muteFiltersExpired() {
const now = Date.now()
return Object.entries(this.muteFiltersDraftObject).filter(
([, { expires }]) => expires != null && expires <= now,
)
},
},
methods: {
...mapActions(useSyncConfigStore, [
'setPreference',
'setSimplePrefAndSave',
'unsetSimplePrefAndSave',
'unsetPreference',
'unsetPrefAndSave',
'pushSyncConfig',
]),
getDatetimeLocal(timestamp) {
const date = new Date(timestamp)
const fmt = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 2 })
const datetime = [
date.getFullYear(),
'-',
fmt.format(date.getMonth() + 1),
'-',
fmt.format(date.getDate()),
'T',
fmt.format(date.getHours()),
':',
fmt.format(date.getMinutes()),
].join('')
return datetime
},
checkRegexValid(id) {
const filter = this.muteFiltersObject[id]
if (filter.type !== 'regexp') return true
if (filter.type !== 'user_regexp') return true
const { value } = filter
let valid = true
try {
new RegExp(value)
} catch {
valid = false
console.error('Invalid RegExp: ' + value)
}
return valid
},
- createFilter(
- filter = {
+ createFilter(filter) {
+ const newId = uuidv4()
+ const newFilter = {
type: 'word',
value: '',
name: 'New Filter',
enabled: true,
expires: null,
hide: false,
- },
- ) {
- const newId = uuidv4()
+ ...filter,
+ }
- filter.order = this.muteFilters.length + 2
- this.muteFiltersDraftObject[newId] = filter
- this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
+ newFilter.order = this.muteFilters.length + 2
+ this.muteFiltersDraftObject[newId] = newFilter
+ this.setSimplePrefAndSave({
+ path: 'muteFilters.' + newId,
+ value: newFilter,
+ })
},
exportFilter(id) {
this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
delete this.exportedFilter.order
this.filterExporter.exportData()
},
importFilter() {
this.filterImporter.importData()
},
copyFilter(id) {
const filter = { ...this.muteFiltersDraftObject[id] }
const newId = uuidv4()
this.muteFiltersDraftObject[newId] = filter
this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
},
deleteFilter(id) {
delete this.muteFiltersDraftObject[id]
this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
},
purgeExpiredFilters() {
this.muteFiltersExpired.forEach(([id]) => {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
})
this.pushSyncConfig()
},
updateFilter(id, field, value) {
const filter = { ...this.muteFiltersDraftObject[id] }
if (field === 'expires-never') {
if (!value) {
const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
const date = Date.now() + offset
filter.expires = date
} else {
filter.expires = null
}
} else if (field === 'expires') {
const parsed = Date.parse(value)
filter.expires = parsed.valueOf()
} else {
filter[field] = value
}
this.muteFiltersDraftObject[id] = filter
this.muteFiltersDraftDirty[id] = true
},
saveFilter(id) {
this.setSimplePrefAndSave({
path: 'muteFilters.' + id,
value: this.muteFiltersDraftObject[id],
})
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility() {
this.$store.dispatch('queueFlushAll')
},
muteFiltersObject() {
this.muteFiltersDraftObject = cloneDeep(
useMergedConfigStore().mergedConfig.muteFilters,
)
},
},
}
export default FilteringTab
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 40df1b98e5..90ddc5ee05 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,828 +1,828 @@
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 (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((a, b) => a.localeCompare(b))
},
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 (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 (colors.text === undefined && colors.fg !== undefined) {
version = 1
}
// New v2 naming: text is text, fg is foreground
if (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 (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/settings_modal/tabs/old_theme_tab/old_theme_tab.vue b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.vue
index 751e113a00..9724162cac 100644
--- a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.vue
+++ b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.vue
@@ -1,1025 +1,1025 @@
<template>
<div class="old-theme-tab">
<div class="alert warning deprecation-warning">
{{ $t("settings.style.themes2_outdated") }}
</div>
<div class="presets-container">
<div class="save-load">
<div
v-if="themeWarning"
class="theme-warning"
>
<div class="alert warning">
{{ themeWarningHelp }}
</div>
<div class="buttons">
<template v-if="themeWarning.type === 'snapshot_source_mismatch'">
<button
class="btn button-default"
@click="forceLoad"
>
{{ $t('settings.style.switcher.use_source') }}
</button>
<button
class="btn button-default"
@click="forceSnapshot"
>
{{ $t('settings.style.switcher.use_snapshot') }}
</button>
</template>
<template v-else-if="themeWarning.noActionsPossible">
<button
class="btn button-default"
@click="dismissWarning"
>
{{ $t('general.dismiss') }}
</button>
</template>
<template v-else>
<button
class="btn button-default"
@click="forceLoad"
>
{{ $t('settings.style.switcher.load_theme') }}
</button>
<button
class="btn button-default"
@click="dismissWarning"
>
{{ $t('settings.style.switcher.keep_as_is') }}
</button>
</template>
</div>
</div>
<div class="top">
<div class="presets">
{{ $t('settings.presets') }}
<label
for="preset-switcher"
class="select"
>
<Select
id="preset-switcher"
v-model="selected"
class="preset-switcher"
>
<option
v-for="style in availableStyles"
:key="style.name"
:value="style.name || style[0]"
:style="{
backgroundColor: style[1] || (style.theme || style.source).colors.bg,
color: style[3] || (style.theme || style.source).colors.text
}"
>
{{ style[0] || style.name }}
</option>
</Select>
</label>
</div>
<div class="export-import">
<button
class="btn button-default"
@click="importTheme"
>
{{ $t(&quot;settings.import_theme&quot;) }}
</button>
<button
class="btn button-default"
@click="exportTheme"
>
{{ $t(&quot;settings.export_theme&quot;) }}
</button>
</div>
</div>
</div>
<div class="save-load-options">
<span class="keep-option">
<Checkbox v-model="keepColor">
{{ $t('settings.style.switcher.keep_color') }}
</Checkbox>
</span>
<span class="keep-option">
<Checkbox v-model="keepShadows">
{{ $t('settings.style.switcher.keep_shadows') }}
</Checkbox>
</span>
<span class="keep-option">
<Checkbox v-model="keepOpacity">
{{ $t('settings.style.switcher.keep_opacity') }}
</Checkbox>
</span>
<span class="keep-option">
<Checkbox v-model="keepRoundness">
{{ $t('settings.style.switcher.keep_roundness') }}
</Checkbox>
</span>
<span class="keep-option">
<Checkbox v-model="keepFonts">
{{ $t('settings.style.switcher.keep_fonts') }}
</Checkbox>
</span>
<p>{{ $t('settings.style.switcher.save_load_hint') }}</p>
</div>
</div>
<preview id="theme-preview" />
<div>
<button
class="btn button-default"
@click="updateTheme3Preview"
>
{{ $t("settings.style.update_preview") }}
</button>
</div>
<keep-alive>
<tab-switcher key="style-tweak">
<div
:label="$t('settings.style.common_colors._tab_label')"
class="color-container"
>
<div class="tab-header">
<p>{{ $t('settings.theme_help') }}</p>
<div class="tab-header-buttons">
<button
class="btn button-default"
@click="clearOpacity"
>
{{ $t('settings.style.switcher.clear_opacity') }}
</button>
<button
class="btn button-default"
@click="clearV1"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
</div>
<p>{{ $t('settings.theme_help_v2_1') }}</p>
<h4>{{ $t('settings.style.common_colors.main') }}</h4>
<div class="color-item">
<ColorInput
v-model="bgColorLocal"
name="bgColor"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="bgOpacityLocal"
name="bgOpacity"
:fallback="previewTheme.opacity?.bg"
/>
<ColorInput
v-model="textColorLocal"
name="textColor"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.bgText" />
<ColorInput
v-model="accentColorLocal"
name="accentColor"
:fallback="previewTheme.colors?.link"
:label="$t('settings.accent')"
- :show-optional-checkbox="typeof linkColorLocal !== 'undefined'"
+ :show-optional-checkbox="linkColorLocal !== undefined"
/>
<ColorInput
v-model="linkColorLocal"
name="linkColor"
:fallback="previewTheme.colors?.accent"
:label="$t('settings.links')"
- :show-optional-checkbox="typeof accentColorLocal !== 'undefined'"
+ :show-optional-checkbox="accentColorLocal !== undefined"
/>
<ContrastRatio :contrast="previewContrast.bgLink" />
</div>
<div class="color-item">
<ColorInput
v-model="fgColorLocal"
name="fgColor"
:label="$t('settings.foreground')"
/>
<ColorInput
v-model="fgTextColorLocal"
name="fgTextColor"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.fgText"
/>
<ColorInput
v-model="fgLinkColorLocal"
name="fgLinkColor"
:label="$t('settings.links')"
:fallback="previewTheme.colors?.fgLink"
/>
<p>{{ $t('settings.style.common_colors.foreground_hint') }}</p>
</div>
<h4>{{ $t('settings.style.common_colors.rgbo') }}</h4>
<div class="color-item">
<ColorInput
v-model="cRedColorLocal"
name="cRedColor"
:label="$t('settings.cRed')"
/>
<ContrastRatio :contrast="previewContrast.bgCRed" />
<ColorInput
v-model="cBlueColorLocal"
name="cBlueColor"
:label="$t('settings.cBlue')"
/>
<ContrastRatio :contrast="previewContrast.bgCBlue" />
</div>
<div class="color-item">
<ColorInput
v-model="cGreenColorLocal"
name="cGreenColor"
:label="$t('settings.cGreen')"
/>
<ContrastRatio :contrast="previewContrast.bgCGreen" />
<ColorInput
v-model="cOrangeColorLocal"
name="cOrangeColor"
:label="$t('settings.cOrange')"
/>
<ContrastRatio :contrast="previewContrast.bgCOrange" />
</div>
<p>{{ $t('settings.theme_help_v2_2') }}</p>
</div>
<div
:label="$t('settings.style.advanced_colors._tab_label')"
class="color-container"
>
<div class="tab-header">
<p>{{ $t('settings.theme_help') }}</p>
<button
class="btn button-default"
@click="clearOpacity"
>
{{ $t('settings.style.switcher.clear_opacity') }}
</button>
<button
class="btn button-default"
@click="clearV1"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.post') }}</h4>
<ColorInput
v-model="postLinkColorLocal"
name="postLinkColor"
:fallback="previewTheme.colors?.accent"
:label="$t('settings.links')"
/>
<ContrastRatio :contrast="previewContrast.postLink" />
<ColorInput
v-model="postGreentextColorLocal"
name="postGreentextColor"
:fallback="previewTheme.colors?.cGreen"
:label="$t('settings.greentext')"
/>
<ContrastRatio :contrast="previewContrast.postGreentext" />
<h4>{{ $t('settings.style.advanced_colors.alert') }}</h4>
<ColorInput
v-model="alertErrorColorLocal"
name="alertError"
:label="$t('settings.style.advanced_colors.alert_error')"
:fallback="previewTheme.colors?.alertError"
/>
<ColorInput
v-model="alertErrorTextColorLocal"
name="alertErrorText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.alertErrorText"
/>
<ContrastRatio
:contrast="previewContrast.alertErrorText"
large
/>
<ColorInput
v-model="alertWarningColorLocal"
name="alertWarning"
:label="$t('settings.style.advanced_colors.alert_warning')"
:fallback="previewTheme.colors?.alertWarning"
/>
<ColorInput
v-model="alertWarningTextColorLocal"
name="alertWarningText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.alertWarningText"
/>
<ContrastRatio
:contrast="previewContrast.alertWarningText"
large
/>
<ColorInput
v-model="alertNeutralColorLocal"
name="alertNeutral"
:label="$t('settings.style.advanced_colors.alert_neutral')"
:fallback="previewTheme.colors?.alertNeutral"
/>
<ColorInput
v-model="alertNeutralTextColorLocal"
name="alertNeutralText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.alertNeutralText"
/>
<ContrastRatio
:contrast="previewContrast.alertNeutralText"
large
/>
<OpacityInput
v-model="alertOpacityLocal"
name="alertOpacity"
:fallback="previewTheme.opacity?.alert"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.badge') }}</h4>
<ColorInput
v-model="badgeNotificationColorLocal"
name="badgeNotification"
:label="$t('settings.style.advanced_colors.badge_notification')"
:fallback="previewTheme.colors?.badgeNotification"
/>
<ColorInput
v-model="badgeNotificationTextColorLocal"
name="badgeNotificationText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.badgeNotificationText"
/>
<ContrastRatio
:contrast="previewContrast.badgeNotificationText"
large
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.panel_header') }}</h4>
<ColorInput
v-model="panelColorLocal"
name="panelColor"
:fallback="previewTheme.colors?.panel"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="panelOpacityLocal"
name="panelOpacity"
:fallback="previewTheme.opacity?.panel"
:disabled="panelColorLocal === 'transparent'"
/>
<ColorInput
v-model="panelTextColorLocal"
name="panelTextColor"
:fallback="previewTheme.colors?.panelText"
:label="$t('settings.text')"
/>
<ContrastRatio
:contrast="previewContrast.panelText"
large
/>
<ColorInput
v-model="panelLinkColorLocal"
name="panelLinkColor"
:fallback="previewTheme.colors?.panelLink"
:label="$t('settings.links')"
/>
<ContrastRatio
:contrast="previewContrast.panelLink"
large
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.top_bar') }}</h4>
<ColorInput
v-model="topBarColorLocal"
name="topBarColor"
:fallback="previewTheme.colors?.topBar"
:label="$t('settings.background')"
/>
<ColorInput
v-model="topBarTextColorLocal"
name="topBarTextColor"
:fallback="previewTheme.colors?.topBarText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.topBarText" />
<ColorInput
v-model="topBarLinkColorLocal"
name="topBarLinkColor"
:fallback="previewTheme.colors?.topBarLink"
:label="$t('settings.links')"
/>
<ContrastRatio :contrast="previewContrast.topBarLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.inputs') }}</h4>
<ColorInput
v-model="inputColorLocal"
name="inputColor"
:fallback="previewTheme.colors?.input"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="inputOpacityLocal"
name="inputOpacity"
:fallback="previewTheme.opacity?.input"
:disabled="inputColorLocal === 'transparent'"
/>
<ColorInput
v-model="inputTextColorLocal"
name="inputTextColor"
:fallback="previewTheme.colors?.inputText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.inputText" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.buttons') }}</h4>
<ColorInput
v-model="btnColorLocal"
name="btnColor"
:fallback="previewTheme.colors?.btn"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="btnOpacityLocal"
name="btnOpacity"
:fallback="previewTheme.opacity?.btn"
:disabled="btnColorLocal === 'transparent'"
/>
<ColorInput
v-model="btnTextColorLocal"
name="btnTextColor"
:fallback="previewTheme.colors?.btnText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnText" />
<ColorInput
v-model="btnPanelTextColorLocal"
name="btnPanelTextColor"
:fallback="previewTheme.colors?.btnPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnPanelText" />
<ColorInput
v-model="btnTopBarTextColorLocal"
name="btnTopBarTextColor"
:fallback="previewTheme.colors?.btnTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnTopBarText" />
<h5>{{ $t('settings.style.advanced_colors.pressed') }}</h5>
<ColorInput
v-model="btnPressedColorLocal"
name="btnPressedColor"
:fallback="previewTheme.colors?.btnPressed"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnPressedTextColorLocal"
name="btnPressedTextColor"
:fallback="previewTheme.colors?.btnPressedText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedText" />
<ColorInput
v-model="btnPressedPanelTextColorLocal"
name="btnPressedPanelTextColor"
:fallback="previewTheme.colors?.btnPressedPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedPanelText" />
<ColorInput
v-model="btnPressedTopBarTextColorLocal"
name="btnPressedTopBarTextColor"
:fallback="previewTheme.colors?.btnPressedTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedTopBarText" />
<h5>{{ $t('settings.style.advanced_colors.disabled') }}</h5>
<ColorInput
v-model="btnDisabledColorLocal"
name="btnDisabledColor"
:fallback="previewTheme.colors?.btnDisabled"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnDisabledTextColorLocal"
name="btnDisabledTextColor"
:fallback="previewTheme.colors?.btnDisabledText"
:label="$t('settings.text')"
/>
<ColorInput
v-model="btnDisabledPanelTextColorLocal"
name="btnDisabledPanelTextColor"
:fallback="previewTheme.colors?.btnDisabledPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ColorInput
v-model="btnDisabledTopBarTextColorLocal"
name="btnDisabledTopBarTextColor"
:fallback="previewTheme.colors?.btnDisabledTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<h5>{{ $t('settings.style.advanced_colors.toggled') }}</h5>
<ColorInput
v-model="btnToggledColorLocal"
name="btnToggledColor"
:fallback="previewTheme.colors?.btnToggled"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnToggledTextColorLocal"
name="btnToggledTextColor"
:fallback="previewTheme.colors?.btnToggledText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledText" />
<ColorInput
v-model="btnToggledPanelTextColorLocal"
name="btnToggledPanelTextColor"
:fallback="previewTheme.colors?.btnToggledPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledPanelText" />
<ColorInput
v-model="btnToggledTopBarTextColorLocal"
name="btnToggledTopBarTextColor"
:fallback="previewTheme.colors?.btnToggledTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledTopBarText" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.tabs') }}</h4>
<ColorInput
v-model="tabColorLocal"
name="tabColor"
:fallback="previewTheme.colors?.tab"
:label="$t('settings.background')"
/>
<ColorInput
v-model="tabTextColorLocal"
name="tabTextColor"
:fallback="previewTheme.colors?.tabText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.tabText" />
<ColorInput
v-model="tabActiveTextColorLocal"
name="tabActiveTextColor"
:fallback="previewTheme.colors?.tabActiveText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.tabActiveText" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.borders') }}</h4>
<ColorInput
v-model="borderColorLocal"
name="borderColor"
:fallback="previewTheme.colors?.border"
:label="$t('settings.style.common.color')"
/>
<OpacityInput
v-model="borderOpacityLocal"
name="borderOpacity"
:fallback="previewTheme.opacity?.border"
:disabled="borderColorLocal === 'transparent'"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.faint_text') }}</h4>
<ColorInput
v-model="faintColorLocal"
name="faintColor"
:fallback="previewTheme.colors?.faint"
:label="$t('settings.text')"
/>
<ColorInput
v-model="faintLinkColorLocal"
name="faintLinkColor"
:fallback="previewTheme.colors?.faintLink"
:label="$t('settings.links')"
/>
<ColorInput
v-model="panelFaintColorLocal"
name="panelFaintColor"
:fallback="previewTheme.colors?.panelFaint"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<OpacityInput
v-model="faintOpacityLocal"
name="faintOpacity"
:fallback="previewTheme.opacity?.faint"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.underlay') }}</h4>
<ColorInput
v-model="underlayColorLocal"
name="underlay"
:label="$t('settings.style.advanced_colors.underlay')"
:fallback="previewTheme.colors?.underlay"
/>
<OpacityInput
v-model="underlayOpacityLocal"
name="underlayOpacity"
:fallback="previewTheme.opacity?.underlay"
:disabled="underlayOpacityLocal === 'transparent'"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.wallpaper') }}</h4>
<ColorInput
v-model="wallpaperColorLocal"
name="wallpaper"
:label="$t('settings.style.advanced_colors.wallpaper')"
:fallback="previewTheme.colors?.wallpaper"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.poll') }}</h4>
<ColorInput
v-model="pollColorLocal"
name="poll"
:label="$t('settings.background')"
:fallback="previewTheme.colors?.poll"
/>
<ColorInput
v-model="pollTextColorLocal"
name="pollText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.pollText"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.icons') }}</h4>
<ColorInput
v-model="iconColorLocal"
name="icon"
:label="$t('settings.style.advanced_colors.icons')"
:fallback="previewTheme.colors?.icon"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.highlight') }}</h4>
<ColorInput
v-model="highlightColorLocal"
name="highlight"
:label="$t('settings.background')"
:fallback="previewTheme.colors?.highlight"
/>
<ColorInput
v-model="highlightTextColorLocal"
name="highlightText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.highlightText"
/>
<ContrastRatio :contrast="previewContrast.highlightText" />
<ColorInput
v-model="highlightLinkColorLocal"
name="highlightLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors?.highlightLink"
/>
<ContrastRatio :contrast="previewContrast.highlightLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.popover') }}</h4>
<ColorInput
v-model="popoverColorLocal"
name="popover"
:label="$t('settings.background')"
:fallback="previewTheme.colors?.popover"
/>
<OpacityInput
v-model="popoverOpacityLocal"
name="popoverOpacity"
:fallback="previewTheme.opacity?.popover"
:disabled="popoverOpacityLocal === 'transparent'"
/>
<ColorInput
v-model="popoverTextColorLocal"
name="popoverText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.popoverText"
/>
<ContrastRatio :contrast="previewContrast.popoverText" />
<ColorInput
v-model="popoverLinkColorLocal"
name="popoverLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors?.popoverLink"
/>
<ContrastRatio :contrast="previewContrast.popoverLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.selectedPost') }}</h4>
<ColorInput
v-model="selectedPostColorLocal"
name="selectedPost"
:label="$t('settings.background')"
:fallback="previewTheme.colors?.selectedPost"
/>
<ColorInput
v-model="selectedPostTextColorLocal"
name="selectedPostText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.selectedPostText"
/>
<ContrastRatio :contrast="previewContrast.selectedPostText" />
<ColorInput
v-model="selectedPostLinkColorLocal"
name="selectedPostLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors?.selectedPostLink"
/>
<ContrastRatio :contrast="previewContrast.selectedPostLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.selectedMenu') }}</h4>
<ColorInput
v-model="selectedMenuColorLocal"
name="selectedMenu"
:label="$t('settings.background')"
:fallback="previewTheme.colors?.selectedMenu"
/>
<ColorInput
v-model="selectedMenuTextColorLocal"
name="selectedMenuText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.selectedMenuText"
/>
<ContrastRatio :contrast="previewContrast.selectedMenuText" />
<ColorInput
v-model="selectedMenuLinkColorLocal"
name="selectedMenuLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors?.selectedMenuLink"
/>
<ContrastRatio :contrast="previewContrast.selectedMenuLink" />
</div>
<div class="color-item">
<h4>{{ $t('chats.chats') }}</h4>
<ColorInput
v-model="chatBgColorLocal"
name="chatBgColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5>
<ColorInput
v-model="chatMessageIncomingBgColorLocal"
name="chatMessageIncomingBgColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<ColorInput
v-model="chatMessageIncomingTextColorLocal"
name="chatMessageIncomingTextColor"
:fallback="previewTheme.colors?.text"
:label="$t('settings.text')"
/>
<ColorInput
v-model="chatMessageIncomingLinkColorLocal"
name="chatMessageIncomingLinkColor"
:fallback="previewTheme.colors?.link"
:label="$t('settings.links')"
/>
<ColorInput
v-model="chatMessageIncomingBorderColorLocal"
name="chatMessageIncomingBorderLinkColor"
:fallback="previewTheme.colors?.fg"
:label="$t('settings.style.advanced_colors.chat.border')"
/>
<h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5>
<ColorInput
v-model="chatMessageOutgoingBgColorLocal"
name="chatMessageOutgoingBgColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<ColorInput
v-model="chatMessageOutgoingTextColorLocal"
name="chatMessageOutgoingTextColor"
:fallback="previewTheme.colors?.text"
:label="$t('settings.text')"
/>
<ColorInput
v-model="chatMessageOutgoingLinkColorLocal"
name="chatMessageOutgoingLinkColor"
:fallback="previewTheme.colors?.link"
:label="$t('settings.links')"
/>
<ColorInput
v-model="chatMessageOutgoingBorderColorLocal"
name="chatMessageOutgoingBorderLinkColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.style.advanced_colors.chat.border')"
/>
</div>
</div>
<div
:label="$t('settings.style.radii._tab_label')"
class="radius-container"
>
<div class="tab-header">
<p>{{ $t('settings.radii_help') }}</p>
<button
class="btn button-default"
@click="clearRoundness"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
<RangeInput
v-model="btnRadiusLocal"
name="btnRadius"
:label="$t('settings.btnRadius')"
:fallback="previewTheme.radii?.btn"
max="16"
hard-min="0"
/>
<RangeInput
v-model="inputRadiusLocal"
name="inputRadius"
:label="$t('settings.inputRadius')"
:fallback="previewTheme.radii?.input"
max="9"
hard-min="0"
/>
<RangeInput
v-model="checkboxRadiusLocal"
name="checkboxRadius"
:label="$t('settings.checkboxRadius')"
:fallback="previewTheme.radii?.checkbox"
max="16"
hard-min="0"
/>
<RangeInput
v-model="panelRadiusLocal"
name="panelRadius"
:label="$t('settings.panelRadius')"
:fallback="previewTheme.radii?.panel"
max="50"
hard-min="0"
/>
<RangeInput
v-model="avatarRadiusLocal"
name="avatarRadius"
:label="$t('settings.avatarRadius')"
:fallback="previewTheme.radii?.avatar"
max="28"
hard-min="0"
/>
<RangeInput
v-model="avatarAltRadiusLocal"
name="avatarAltRadius"
:label="$t('settings.avatarAltRadius')"
:fallback="previewTheme.radii?.avatarAlt"
max="28"
hard-min="0"
/>
<RangeInput
v-model="attachmentRadiusLocal"
name="attachmentRadius"
:label="$t('settings.attachmentRadius')"
:fallback="previewTheme.radii?.attachment"
max="50"
hard-min="0"
/>
<RangeInput
v-model="tooltipRadiusLocal"
name="tooltipRadius"
:label="$t('settings.tooltipRadius')"
:fallback="previewTheme.radii?.tooltip"
max="50"
hard-min="0"
/>
<RangeInput
v-model="chatMessageRadiusLocal"
name="chatMessageRadius"
:label="$t('settings.chatMessageRadius')"
:fallback="previewTheme.radii?.chatMessage || 2"
max="50"
hard-min="0"
/>
</div>
<div
:label="$t('settings.style.shadows._tab_label')"
class="shadow-container"
>
<div class="tab-header shadow-selector">
<div class="select-container">
{{ $t('settings.style.shadows.component') }}
{{ ' ' }}
<Select
id="shadow-switcher"
v-model="shadowSelected"
class="shadow-switcher"
>
<option
v-for="shadow in shadowsAvailable"
:key="shadow"
:value="shadow"
>
{{ $t('settings.style.shadows.components.' + shadow) }}
</option>
</Select>
</div>
<div class="override">
<Checkbox
id="override"
v-model="currentShadowOverriden"
name="override"
class="input-override"
>
{{ $t('settings.style.shadows.override') }}
</Checkbox>
</div>
<button
class="btn button-default"
@click="clearShadows"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
<ShadowControl
v-model="currentShadow"
:separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"
:fallback="currentShadowFallback"
:static-vars="previewTheme.colors"
:compact="true"
/>
</div>
<div
:label="$t('settings.style.fonts._tab_label')"
class="fonts-container"
>
<div class="tab-header">
<p>{{ $t('settings.style.fonts.help') }}</p>
<button
class="btn button-default"
@click="clearFonts"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
<FontControl
v-model="fontsLocal.interface"
name="ui"
:label="$t('settings.style.fonts.components.interface')"
:fallback="previewTheme.fonts?.interface"
no-inherit="1"
/>
<FontControl
v-model="fontsLocal.input"
name="input"
:label="$t('settings.style.fonts.components.input')"
:fallback="previewTheme.fonts?.input"
/>
<FontControl
v-model="fontsLocal.post"
name="post"
:label="$t('settings.style.fonts.components.post')"
:fallback="previewTheme.fonts?.post"
/>
<FontControl
v-model="fontsLocal.postCode"
name="postCode"
:label="$t('settings.style.fonts.components.postCode')"
:fallback="previewTheme.fonts?.postCode"
/>
</div>
</tab-switcher>
</keep-alive>
<teleport
v-if="isActive"
to="#unscrolled-content"
>
<div class="apply-container">
<button
class="btn button-default submit"
:disabled="!themeValid"
@click="setCustomTheme"
>
{{ $t('general.apply') }}
</button>
<button
class="btn button-default"
@click="clearAll"
>
{{ $t('settings.style.switcher.reset') }}
</button>
</div>
</teleport>
</div>
</template>
<script src="./old_theme_tab.js"></script>
<style src="./old_theme_tab.scss" lang="scss"></style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 3e10a24e13..a33d295257 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,589 +1,589 @@
import { uniqBy } from 'lodash'
import { defineAsyncComponent } from 'vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
StatusActionButtons,
},
props: {
statusoid: Object,
replies: Array,
expandable: Boolean,
focused: Boolean,
compact: Boolean,
isPreview: Boolean,
noHeading: Boolean,
inlineExpanded: Boolean,
showPinned: Boolean,
inProfile: Boolean,
inConversation: Boolean,
inQuote: Boolean,
profileUserId: String,
simpleTree: Boolean,
showOtherRepliesAsButton: Boolean,
canDive: Boolean,
ignoreMute: Boolean,
threadDisplayStatus: String,
},
emits: ['goto', 'dive', 'toggleExpanded', 'suspendableStateChange'],
data() {
return {
replying: false,
unmuted: false,
userExpanded: false,
mediaPlaying: new Set(),
error: null,
headTailLinks: null,
}
},
computed: {
showReasonMutedThread() {
return (
(this.status.thread_muted || this.status.reblog?.thread_muted) &&
!this.inConversation
)
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
repeaterClass() {
const user = this.statusoid.user
return highlightClass(user)
},
userClass() {
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
return highlightClass(user)
},
deleted() {
return this.statusoid.deleted
},
repeaterStyle() {
const user = this.statusoid.user
return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userStyle() {
if (this.noHeading) return
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userProfileLink() {
return this.generateUserProfileLink(
this.status.user.id,
this.status.user.screen_name,
)
},
replyProfileLink() {
if (this.isReply) {
const user = this.$store.getters.findUser(
this.status.in_reply_to_user_id,
)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet() {
return !!this.statusoid.retweeted_status
},
retweeterUser() {
return this.statusoid.user
},
retweeter() {
return this.statusoid.user.name || this.statusoid.user.screen_name_ui
},
retweeterHtml() {
return this.statusoid.user.name
},
retweeterProfileLink() {
return this.generateUserProfileLink(
this.statusoid.user.id,
this.statusoid.user.screen_name,
)
},
status() {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
statusFromGlobalRepository() {
// NOTE: Consider to replace status with statusFromGlobalRepository
return this.$store.state.statuses.allStatusesObject[this.status.id]
},
loggedIn() {
return !!this.currentUser
},
muteFilterHits() {
return muteFilterHits(
Object.values(
useSyncConfigStore().prefsStorage.simple.muteFilters || {},
),
this.status,
)
},
botStatus() {
return this.status.user.actor_type === 'Service'
},
showActorTypeIndicator() {
return !this.hideBotIndication
},
sensitiveStatus() {
return this.status.nsfw
},
mentionsLine() {
if (!this.headTailLinks) return []
const writtenSet = new Set(
this.headTailLinks.writtenMentions.map((_) => _.url),
)
return this.status.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.status.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
this.status.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
)
})
.map((attn) => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id,
}))
},
hasMentionsLine() {
return this.mentionsLine.length > 0
},
muteReasons() {
return [
this.userIsMuted ? 'user' : null,
this.status.thread_muted ? 'thread' : null,
this.muteFilterHits.length > 0 ? 'filtered' : null,
this.muteBotStatuses && this.botStatus ? 'bot' : null,
this.muteSensitiveStatuses && this.sensitiveStatus ? 'nsfw' : null,
- ].filter((_) => _)
+ ].filter(Boolean)
},
muteLocalized() {
if (this.muteReasons.length === 0) return null
const mainReason = () => {
switch (this.muteReasons[0]) {
case 'user':
return this.$t('status.muted_user')
case 'thread':
return this.$t('status.thread_muted')
case 'filtered':
return this.$t(
'status.muted_filters',
{
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1,
},
this.muteFilterHits.length,
)
case 'bot':
return this.$t('status.bot_muted')
case 'nsfw':
return this.$t('status.sensitive_muted')
}
}
if (this.muteReasons.length > 1) {
return this.$t(
'status.multi_reason_mute',
{
main: mainReason(),
numReasonsMore: this.muteReasons.length - 1,
},
this.muteReasons.length - 1,
)
} else {
return mainReason()
}
},
muted() {
if (this.ignoreMute) return false
if (this.statusoid.user.id === this.currentUser.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
if (this.statusoid.user.id === this.currentUser.id) return false
const { status } = this
const { reblog } = status
const relationship = this.$store.getters.relationship(status.user.id)
const relationshipReblog =
reblog && this.$store.getters.relationship(reblog.user.id)
return (
(status.muted && !status.thread_muted) ||
// Reprööt of a muted post according to BE
(reblog?.muted && !reblog.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
relationshipReblog?.muting
)
},
shouldNotMute() {
if (this.ignoreMute) return true
if (this.focused) return true
const { status } = this
const { reblog } = status
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && status.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
reblog?.user.id === this.profileUserId)) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && status.thread_muted)) &&
// No excuses if post has muted words
!this.muteFilterHits.length > 0
)
},
hideMutedUsers() {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads() {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses() {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts() {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus() {
return (
!this.shouldNotMute &&
((this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
this.muteFilterHits.some((x) => x.hide))
)
},
isReply() {
return !!(
this.status.in_reply_to_status_id && this.status.in_reply_to_user_id
)
},
replyToName() {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(
this.status.in_reply_to_user_id,
)
return user?.screen_name_ui
}
},
combinedFavsAndRepeatsUsers() {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
this.statusFromGlobalRepository.favoritedBy,
this.statusFromGlobalRepository.rebloggedBy,
)
return uniqBy(combinedUsers, 'id')
},
tags() {
return this.status.tags
.filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name)
.join(' ')
},
hidePostStats() {
return this.mergedConfig.hidePostStats
},
shouldDisplayFavsAndRepeats() {
return (
!this.hidePostStats &&
this.focused &&
(this.combinedFavsAndRepeatsUsers.length > 0 ||
this.statusFromGlobalRepository.quotes_count)
)
},
muteBotStatuses() {
return this.mergedConfig.muteBotStatuses
},
muteSensitiveStatuses() {
return this.mergedConfig.muteSensitiveStatuses
},
hideBotIndication() {
return this.mergedConfig.hideBotIndication
},
currentUser() {
return this.$store.state.users.currentUser
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.size === 0
},
inThreadForest() {
return !!this.threadDisplayStatus
},
threadShowing() {
return this.threadDisplayStatus === 'showing'
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
isEdited() {
return this.status.edited_at !== null
},
editingAvailable() {
return useInstanceCapabilitiesStore().editingAvailable
},
quoteId() {
return this.status.quote_id
},
quoteUrl() {
return this.status.quote_url
},
quoteVisible() {
return this.status.quote_visible
},
quoteExpanded() {
return !this.inQuote
},
scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false
if (!this.status.user?.latestScrobble) return false
const value = this.mergedConfig.hideScrobblesAfter.match(/\d+/gs)[0]
const unit = this.mergedConfig.hideScrobblesAfter.match(/\D+/gs)[0]
let multiplier = 60 * 1000 // minutes is smallest unit
switch (unit) {
case 'm':
break
case 'h':
multiplier *= 60 // hour
break
case 'd':
multiplier *= 60 // hour
multiplier *= 24 // day
break
}
const maxAge = Number(value) * multiplier
const createdAt = Date.parse(this.status.user.latestScrobble.created_at)
const age = Date.now() - createdAt
if (age > maxAge) return false
return this.status.user.latestScrobble.artist
},
scrobble() {
return this.status.user?.latestScrobble
},
},
methods: {
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
showError(error) {
this.error = error
},
clearError() {
this.error = undefined
},
toggleReplyForm() {
if (this.replying) {
// This emits 'close-accepted' if successful
// which in turn callse closeReply()
this.$refs.postStatusForm.requestClose()
} else {
this.replying = true
}
},
closeReplyForm() {
this.replying = false
},
gotoOriginal(id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded() {
this.$emit('toggleExpanded')
},
toggleMute() {
this.unmuted = !this.unmuted
},
toggleUserExpanded() {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink(id, name) {
return generateProfileLink(
id,
name,
useInstanceStore().restrictedNicknames,
)
},
addMediaPlaying(id) {
this.mediaPlaying.add(id)
},
removeMediaPlaying(id) {
this.mediaPlaying.delete(id)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
toggleThreadDisplay() {
this.controlledToggleThreadDisplay()
},
scrollIfFocused(focused) {
if (this.$el.getBoundingClientRect == null) return
if (focused) {
const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= window.innerHeight - 50) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
},
watch: {
focused: function (id) {
this.scrollIfFocused(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (
this.focused &&
this.statusFromGlobalRepository.rebloggedBy &&
this.statusFromGlobalRepository.rebloggedBy.length !== num
) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (
this.focused &&
this.statusFromGlobalRepository.favoritedBy &&
this.statusFromGlobalRepository.favoritedBy.length !== num
) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
isSuspendable: function (suspend) {
this.$emit('suspendableStateChange', { id: this.statusoid.id, suspend })
},
},
}
export default Status
diff --git a/src/i18n/compare b/src/i18n/compare
index 94e5aab555..7afe9355c3 100755
--- a/src/i18n/compare
+++ b/src/i18n/compare
@@ -1,49 +1,49 @@
#!/usr/bin/env node
const arg = process.argv[2]
-if (typeof arg === 'undefined') {
+if (arg === undefined) {
console.info('This is a very simple and tiny tool that checks en.json with any other language and')
console.info('outputs all the things present in english but missing in foreign language.')
console.info('')
console.info('Usage: ./compare.js <lang> ')
console.info(' or')
console.info(' node ./compare.js <lang>')
console.info('')
console.info('Where <lang> is name of .json file containing language. For ./fi.json it should be:')
console.info(' ./compare.js fi ')
console.info('')
console.info('Limitations: ')
console.info('* This program does not work with languages left over in messages.js')
console.info('* This program does not check for extra strings present in foreign language but missing')
console.info(' in english.js (for now)')
console.info('')
console.info('There are no other arguments or options. Make an issue if you encounter a bug or want')
console.info('some feature to be implemented. Merge requests are welcome as well.')
process.exit()
}
const english = require('./en.json')
const foreign = require(`./${arg}.json`)
function walker (a, b, path = []) {
Object.keys(a).forEach(k => {
const aVal = a[k]
const bVal = b[k]
const aType = typeof aVal
const bType = typeof bVal
const currentPath = [...path, k]
const article = aType[0] === 'o' ? 'an' : 'a'
if (bType === 'undefined') {
console.warn(`Foreign language is missing ${article} ${aType} at path ${currentPath.join('.')}`)
} else if (aType === 'object') {
if (bType !== 'object') {
console.warn(`Type mismatch! English has ${aType} while foreign has ${bType} at path ${currentPath.join['.']}`)
} else {
walker(aVal, bVal, currentPath)
}
}
})
}
walker(english, foreign)
diff --git a/src/i18n/messages.js b/src/i18n/messages.js
index 76d6b53860..db80f7ac36 100644
--- a/src/i18n/messages.js
+++ b/src/i18n/messages.js
@@ -1,58 +1,58 @@
// When contributing, please sort JSON before committing so it would be easier to see what's missing and what's being added compared to English and other languages. It's not obligatory, but just an advice.
// To sort json use jq https://stedolan.github.io/jq and invoke it like `jq -S . xx.json > xx.sorted.json`, AFAIK, there's no inplace edit option like in sed
// Also, when adding a new language to "messages" variable, please do it alphabetically by language code so that users can search or check their custom language easily.
// For anyone contributing to old huge messages.js and in need to quickly convert it to JSON
// sed command for converting currently formatted JS to JSON:
// sed -i -e "s/'//gm" -e 's/"/\\"/gm' -re 's/^( +)(.+?): ((.+?))?(,?)(\{?)$/\1"\2": "\4"/gm' -e 's/\"\{\"/{/g' -e 's/,"$/",/g' file.json
// There's only problem that apostrophe character ' gets replaced by \\ so you have to fix it manually, sorry.
import { isEqual } from 'lodash'
import enMessages from './en.json'
import { langCodeToJsonName, languages } from './languages.js'
const ULTIMATE_FALLBACK_LOCALE = 'en'
const hasLanguageFile = (code) => languages.includes(code)
const languageFileMap = import.meta.glob(['./*.json', '!./en.json'])
const loadLanguageFile = (code) => {
const jsonName = langCodeToJsonName(code)
- if (jsonName === 'en') return Promise.resolve({ default: enMessages })
+ if (jsonName === 'en') return { default: enMessages }
return languageFileMap[`./${jsonName}.json`]()
}
const messages = {
languages,
default: {
en: enMessages,
},
setLanguage: async (i18n, language) => {
const languages = (Array.isArray(language) ? language : [language]).filter(
(k) => k,
)
if (!languages.includes(ULTIMATE_FALLBACK_LOCALE)) {
languages.push(ULTIMATE_FALLBACK_LOCALE)
}
const [first, ...rest] = languages
if (first === i18n.locale && isEqual(rest, i18n.fallbackLocale)) {
return
}
for (const lang of languages) {
if (hasLanguageFile(lang)) {
const messages = await loadLanguageFile(lang)
i18n.setLocaleMessage(lang, messages.default)
}
}
i18n.fallbackLocale = rest
i18n.locale = first
},
}
export default messages
diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js
index aef1cc8dc5..5fcf259aa5 100644
--- a/src/lib/persisted_state.js
+++ b/src/lib/persisted_state.js
@@ -1,264 +1,264 @@
import { cloneDeep, each, get, merge, set } from 'lodash'
import { storage } from './storage.js'
import { useInterfaceStore } from 'src/stores/interface'
let loaded = false
const defaultReducer = (state, paths) =>
paths.length === 0
? state
: paths.reduce((substate, path) => {
set(substate, path, get(state, path))
return substate
}, {})
const saveImmedeatelyActions = [
'markNotificationsAsSeen',
'clearCurrentUser',
'setCurrentUser',
'setHighlight',
'setOption',
'setClientData',
'setToken',
'clearToken',
]
const defaultStorage = (() => {
return storage
})()
export default function createPersistedState({
key = 'vuex-lz',
paths = [],
getState = (key, storage) => {
const value = storage.getItem(key)
return value
},
setState = (key, state, storage) => {
if (!loaded) {
console.info('waiting for old state to be loaded...')
return Promise.resolve()
} else {
return storage.setItem(key, state)
}
},
reducer = defaultReducer,
storage = defaultStorage,
subscriber = (store) => (handler) => store.subscribe(handler),
} = {}) {
return getState(key, storage).then((savedState) => {
return (store) => {
try {
if (savedState !== null && typeof savedState === 'object') {
// build user cache
const usersState = savedState.users || {}
usersState.usersObject = {}
const users = usersState.users || []
each(users, (user) => {
usersState.usersObject[user.id] = user
})
savedState.users = usersState
store.replaceState(merge({}, store.state, savedState))
}
loaded = true
} catch (e) {
console.error("Couldn't load state")
console.error(e)
loaded = true
}
subscriber(store)((mutation, state) => {
try {
if (saveImmedeatelyActions.includes(mutation.type)) {
setState(key, reducer(cloneDeep(state), paths), storage).then(
(success) => {
- if (typeof success !== 'undefined') {
+ if (success !== undefined) {
if (
mutation.type === 'setOption' ||
mutation.type === 'setCurrentUser'
) {
useInterfaceStore().settingsSaved({ success })
}
}
},
(error) => {
if (
mutation.type === 'setOption' ||
mutation.type === 'setCurrentUser'
) {
useInterfaceStore().settingsSaved({ error })
}
},
)
}
} catch (e) {
console.error("Couldn't persist state:")
console.error(e)
}
})
}
})
}
/**
* This persists state for pinia, which falls back to read from the vuex state
* if pinia persisted state does not exist.
*
* When you migrate a module from vuex to pinia, you have to keep the original name.
* If the module was called `xxx`, the name of the store has to be `xxx` too.
*
* This adds one property to the store, $persistLoaded, which is a promise
* that resolves when the initial state is loaded. If the plugin is not enabled,
* $persistLoaded is a promise that resolves immediately.
* If we are not able to get the stored state because storage.getItem() throws or
* rejects, $persistLoaded will be a rejected promise with the thrown error.
*
* Call signature:
*
* defineStore(name, {
* ...,
* // setting the `persist` property enables this plugin
* // IMPORTANT: by default it is disabled, you have to set `persist` to at least an empty object
* persist: {
* // set to list of individual paths, or undefined/unset to persist everything
* paths: [],
* // function to call after loading initial state
* // if afterLoad is a function, it must return a state object that will be sent to `store.$patch`, or a promise to the state object
* // by default afterLoad is undefined
* afterLoad: (originalState) => {
* // ...
* return modifiedState
* },
* // if it exists, only persist state after these actions
* // if it doesn't exist or is undefined, persist state after every mutation of the state
* saveImmediatelyActions: [],
* // what to do after successfully saving the state
* onSaveSuccess: () => {},
* // what to do after there is an error saving the state
* onSaveError: () => {}
* }
* })
*
*/
export const piniaPersistPlugin =
({
vuexKey = 'vuex-lz',
keyFunction = (id) => `pinia-local-${id}`,
storage = defaultStorage,
reducer = defaultReducer,
} = {}) =>
({ store, options }) => {
if (!options.persist) {
return {
$persistLoaded: Promise.resolve(),
}
}
let resolveLoaded
let rejectLoaded
const loadedPromise = new Promise((resolve, reject) => {
resolveLoaded = resolve
rejectLoaded = reject
})
const {
afterLoad,
paths = [],
saveImmediatelyActions,
onSaveSuccess = () => {
/* no-op */
},
onSaveError = () => {
/* no-op */
},
} = options.persist || {}
const loadedGuard = { loaded: false }
const key = keyFunction(store.$id)
const getState = async () => {
const id = store.$id
const value = await storage.getItem(key)
if (value) {
return value
}
const fallbackValue = await storage.getItem(vuexKey)
if (fallbackValue?.[id]) {
console.info(`Migrating ${id} store data from vuex to pinia`)
const res = fallbackValue[id]
await storage.setItem(key, res)
return res
}
return {}
}
const setState = (state) => {
if (!loadedGuard.loaded) {
console.info('waiting for old state to be loaded...')
- return Promise.reject()
+ throw new Error('Waiting')
} else {
return storage.setItem(key, state)
}
}
const getMaybeAugmentedState = async () => {
const savedRawState = await getState()
if (typeof afterLoad === 'function') {
try {
return await afterLoad(savedRawState)
} catch (e) {
console.error('Error running afterLoad:', e)
return savedRawState
}
} else {
return savedRawState
}
}
const persistCurrentState = async (state) => {
const stateClone = cloneDeep(state)
const stateToPersist = reducer(stateClone, paths)
try {
const res = await setState(stateToPersist)
onSaveSuccess(res)
} catch (e) {
console.error('Cannot persist state:', e)
onSaveError(e)
}
}
getMaybeAugmentedState().then(
(savedState) => {
if (savedState) {
store.$patch(savedState)
}
loadedGuard.loaded = true
resolveLoaded()
// only subscribe after we have done setting the initial state
if (!saveImmediatelyActions) {
store.$subscribe(async (_mutation, state) => {
await persistCurrentState(state)
})
} else {
store.$onAction(({ name, store, after }) => {
if (saveImmediatelyActions.includes(name)) {
after(() => persistCurrentState(store.$state))
}
})
}
},
(error) => {
console.error('Cannot load storage:', error)
rejectLoaded(error)
},
)
return {
$persistLoaded: loadedPromise,
}
}
diff --git a/src/modules/api.js b/src/modules/api.js
index 92b849a2b1..d2c578c9c2 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -1,345 +1,345 @@
import { Socket } from 'phoenix'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useShoutStore } from 'src/stores/shout.js'
import { fetchTimeline } from 'src/api/timelines.js'
import {
getMastodonSocketURI,
ProcessedWS,
WSConnectionStatus,
} from 'src/api/websocket.js'
import followRequestFetcher from 'src/services/follow_request_fetcher/follow_request_fetcher.service'
import notificationsFetcher from 'src/services/notifications_fetcher/notifications_fetcher.service.js'
import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js'
const retryTimeout = (multiplier) => 1000 * multiplier
const api = {
state: {
retryMultiplier: 1,
fetchers: {},
socket: null,
mastoUserSocket: null,
mastoUserSocketStatus: null,
followRequests: [],
},
getters: {
followRequestCount: (state) => state.followRequests.length,
},
mutations: {
addFetcher(state, { fetcherName, fetcher }) {
state.fetchers[fetcherName] = fetcher
},
removeFetcher(state, { fetcherName }) {
state.fetchers[fetcherName].stop()
delete state.fetchers[fetcherName]
},
setWsToken(state, token) {
state.wsToken = token
},
setSocket(state, socket) {
state.socket = socket
},
setFollowRequests(state, value) {
state.followRequests = value
},
setMastoUserSocketStatus(state, value) {
state.mastoUserSocketStatus = value
},
incrementRetryMultiplier(state) {
state.retryMultiplier = Math.max(++state.retryMultiplier, 3)
},
resetRetryMultiplier(state) {
state.retryMultiplier = 1
},
},
actions: {
/**
* Global MastoAPI socket control, in future should disable ALL sockets/(re)start relevant sockets
*
* @param {Boolean} [initial] - whether this enabling happened at boot time or not
*/
enableMastoSockets(store, initial) {
const { state, dispatch, commit } = store
// Do not initialize unless nonexistent or closed
if (
state.mastoUserSocket &&
![WebSocket.CLOSED, WebSocket.CLOSING].includes(
state.mastoUserSocket.getState(),
)
) {
return
}
if (initial) {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING_INITIAL)
} else {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING)
}
return dispatch('startMastoUserSocket')
},
disableMastoSockets(store) {
const { state, dispatch, commit } = store
if (!state.mastoUserSocket) return
commit('setMastoUserSocketStatus', WSConnectionStatus.DISABLED)
return dispatch('stopMastoUserSocket')
},
// MastoAPI 'User' sockets
startMastoUserSocket(store) {
return new Promise((resolve, reject) => {
try {
const { state, commit, dispatch, rootState } = store
const timelineData = rootState.statuses.timelines.friends
const credentials = useOAuthStore().token
const url = getMastodonSocketURI({ credentials })
state.mastoUserSocket = ProcessedWS({
url,
id: 'Unified',
credentials,
})
state.mastoUserSocket.addEventListener(
'pleroma:authenticated',
() => {
state.mastoUserSocket.subscribe('user')
},
)
state.mastoUserSocket.addEventListener(
'message',
({ detail: message }) => {
if (!message) return // pings
if (message.event === 'notification') {
dispatch('addNewNotifications', {
notifications: [message.notification],
older: false,
})
} else if (message.event === 'update') {
dispatch('addNewStatuses', {
statuses: [message.status],
userId: false,
showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends',
})
} else if (message.event === 'status.update') {
dispatch('addNewStatuses', {
statuses: [message.status],
userId: false,
showImmediately:
message.status.id in timelineData.visibleStatusesObject,
timeline: 'friends',
})
} else if (message.event === 'delete') {
dispatch('deleteStatusById', message.id)
} else if (message.event === 'pleroma:chat_update') {
// The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
// The cause of the duplicates is the WS event arriving earlier than the HTTP response.
// This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
// (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
setTimeout(() => {
dispatch('addChatMessages', {
chatId: message.chatUpdate.id,
messages: [message.chatUpdate.lastMessage],
})
dispatch('updateChat', { chat: message.chatUpdate })
maybeShowChatNotification(store, message.chatUpdate)
}, 100)
}
},
)
state.mastoUserSocket.addEventListener('open', () => {
// Do not show notification when we just opened up the page
if (
state.mastoUserSocketStatus !==
WSConnectionStatus.STARTING_INITIAL
) {
useInterfaceStore().pushGlobalNotice({
level: 'success',
messageKey: 'timeline.socket_reconnected',
timeout: 5000,
})
}
// Stop polling if we were errored or disabled
if (
new Set([
WSConnectionStatus.ERROR,
WSConnectionStatus.DISABLED,
]).has(state.mastoUserSocketStatus)
) {
dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications')
useChatsStore().stopFetchingChats()
}
commit('resetRetryMultiplier')
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
})
state.mastoUserSocket.addEventListener(
'error',
({ detail: error }) => {
console.error('Error in MastoAPI websocket:', error)
// TODO is this needed?
dispatch('clearOpenedChats')
},
)
state.mastoUserSocket.addEventListener(
'close',
({ detail: closeEvent }) => {
const ignoreCodes = new Set([
1000, // Normal (intended) closure
1001, // Going away
])
const { code } = closeEvent
if (ignoreCodes.has(code)) {
console.debug(
`Not restarting socket becasue of closure code ${code} is in ignore list`,
)
commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
} else {
console.warn(
`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`,
)
setTimeout(() => {
dispatch('startMastoUserSocket')
}, retryTimeout(state.retryMultiplier))
commit('incrementRetryMultiplier')
if (state.mastoUserSocketStatus !== WSConnectionStatus.ERROR) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
dispatch('startFetchingChats')
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'timeline.socket_broke',
messageArgs: [code],
timeout: 5000,
})
}
commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
}
dispatch('clearOpenedChats')
},
)
resolve()
} catch (e) {
reject(e)
}
})
},
stopMastoUserSocket({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
dispatch('startFetchingChats')
state.mastoUserSocket.close()
},
// Timelines
startFetchingTimeline(
store,
{
timeline = 'friends',
tag = false,
userId = false,
listId = false,
statusId = false,
bookmarkFolderId = false,
},
) {
if (
timeline === 'favourites' &&
!useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
)
return
if (store.state.fetchers[timeline]) return
const fetcher = timelineFetcher.startFetching({
timeline,
store,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: timeline, fetcher })
},
stopFetchingTimeline(store, timeline) {
const fetcher = store.state.fetchers[timeline]
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: timeline, fetcher })
},
fetchTimeline(store, { timeline, ...rest }) {
fetchTimeline({
store,
timeline,
...rest,
credentials: useOAuthStore().token,
})
},
// Notifications
startFetchingNotifications(store) {
if (store.state.fetchers.notifications) return
const fetcher = notificationsFetcher.startFetching({
store,
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: 'notifications', fetcher })
},
stopFetchingNotifications(store) {
const fetcher = store.state.fetchers.notifications
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
},
// Follow requests
startFetchingFollowRequests(store) {
if (store.state.fetchers.followRequests) return
const fetcher = followRequestFetcher.startFetching({
store,
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: 'followRequests', fetcher })
},
stopFetchingFollowRequests(store) {
const fetcher = store.state.fetchers.followRequests
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher })
},
// Pleroma websocket
setWsToken(store, token) {
store.commit('setWsToken', token)
},
initializeSocket({ commit, state, rootState }) {
// Set up websocket connection
const token = state.wsToken
if (
useInstanceCapabilitiesStore().shoutAvailable &&
- typeof token !== 'undefined' &&
+ token !== undefined &&
state.socket === null
) {
const socket = new Socket('/socket', { params: { token } })
socket.connect()
commit('setSocket', socket)
useShoutStore().initializeShout(socket)
}
},
disconnectFromSocket({ commit, state }) {
state.socket?.disconnect()
commit('setSocket', null)
},
},
}
export default api
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index e3878e85c1..ef68a52372 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -1,901 +1,901 @@
import {
each,
find,
findIndex,
first,
last,
maxBy,
merge,
minBy,
omitBy,
remove,
} from 'lodash'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
fetchEmojiReactions,
fetchFavoritedByUsers,
fetchPinnedStatuses,
fetchRebloggedByUsers,
fetchScrobbles,
fetchStatus,
fetchStatusHistory,
fetchStatusSource,
search2,
} from 'src/api/public.js'
import {
bookmarkStatus,
deleteStatus,
favorite,
muteConversation,
pinOwnStatus,
reactWithEmoji,
retweet,
unbookmarkStatus,
unfavorite,
unmuteConversation,
unpinOwnStatus,
unreactWithEmoji,
unretweet,
} from 'src/api/user.js'
const emptyTl = (userId = 0) => ({
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
- maxId: 0,
- minId: 0,
+ maxId: '0',
+ minId: '0',
minVisibleId: 0,
loading: false,
followers: [],
friends: [],
userId,
flushMarker: 0,
})
export const defaultState = () => ({
allStatuses: [],
scrobblesNextFetch: {},
allStatusesObject: {},
conversationsObject: {},
- maxId: 0,
+ maxId: '0',
favorites: new Set(),
timelines: {
mentions: emptyTl(),
public: emptyTl(),
user: emptyTl(),
favorites: emptyTl(),
media: emptyTl(),
publicAndExternal: emptyTl(),
friends: emptyTl(),
tag: emptyTl(),
dms: emptyTl(),
bookmarks: emptyTl(),
list: emptyTl(),
bubble: emptyTl(),
},
})
export const prepareStatus = (status) => {
// Set deleted flag
status.deleted = false
// To make the array reactive
status.attachments = status.attachments || []
return status
}
const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
// We ignore null values to avoid overwriting existing properties with missing data
// we also skip 'user' because that is handled by users module
merge(
oldItem,
omitBy(item, (v, k) => v === null || k === 'user'),
)
// Reactivity fix.
oldItem.attachments.splice(oldItem.attachments.length)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
prepareStatus(item)
arr.push(item)
obj[item.id] = item
return { item, new: true }
}
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
const sortTimeline = (timeline) => {
timeline.visibleStatuses = timeline.visibleStatuses.sort(sortById)
timeline.statuses = timeline.statuses.sort(sortById)
timeline.minVisibleId = last(timeline.visibleStatuses)?.id
return timeline
}
const getLatestScrobble = (state, user) => {
const scrobblesSupport =
useInstanceCapabilitiesStore().pleromaScrobblesAvailable
if (!scrobblesSupport || !user.name || user.id === 'undefined') {
return
}
if (
state.scrobblesNextFetch[user.id] &&
state.scrobblesNextFetch[user.id] > Date.now()
) {
return
}
state.scrobblesNextFetch[user.id] = Date.now() + 24 * 60 * 60 * 1000
if (!scrobblesSupport) return
fetchScrobbles({ accountId: user.id })
.then(({ data: scrobbles }) => {
if (scrobbles?.error) {
useInstanceCapabilitiesStore().set('pleromaScrobblesAvailable', false)
return
}
if (scrobbles.length > 0) {
user.latestScrobble = scrobbles[0]
state.scrobblesNextFetch[user.id] = Date.now() + 60 * 1000
}
})
.catch((e) => {
console.warn('cannot fetch scrobbles', e)
})
}
// Add status to the global storages (arrays and objects maintaining statuses) except timelines
const addStatusToGlobalStorage = (state, data) => {
getLatestScrobble(state, data.user)
const result = mergeOrAdd(state.allStatuses, state.allStatusesObject, data)
if (result.new) {
// Add to conversation
const status = result.item
const conversationsObject = state.conversationsObject
const conversationId = status.statusnet_conversation_id
if (conversationsObject[conversationId]) {
conversationsObject[conversationId].push(status)
} else {
conversationsObject[conversationId] = [status]
}
}
return result
}
const addNewStatuses = (
state,
{
statuses,
showImmediately = false,
timeline,
user = {},
noIdUpdate = false,
userId,
pagination = {},
},
) => {
// Sanity check
if (!Array.isArray(statuses)) {
return false
}
const allStatuses = state.allStatuses
const timelineObject = state.timelines[timeline]
// Mismatch between API pagination and our internal minId/maxId tracking systems:
// pagination.maxId is the oldest of the returned statuses when fetching older,
// and pagination.minId is the newest when fetching newer. The names come directly
// from the arguments they're supposed to be passed as for the next fetch.
const minNew =
pagination.maxId || (statuses.length > 0 ? minBy(statuses, 'id').id : 0)
const maxNew =
pagination.minId || (statuses.length > 0 ? maxBy(statuses, 'id').id : 0)
const newer =
timeline &&
(maxNew > timelineObject.maxId || timelineObject.maxId === 0) &&
statuses.length > 0
const older =
timeline &&
(minNew < timelineObject.minId || timelineObject.minId === 0) &&
statuses.length > 0
if (!noIdUpdate && newer) {
timelineObject.maxId = maxNew
}
if (!noIdUpdate && older) {
timelineObject.minId = minNew
}
// This makes sure that user timeline won't get data meant for other
// user. I.e. opening different user profiles makes request which could
// return data late after user already viewing different user profile
if (
(timeline === 'user' || timeline === 'media') &&
timelineObject.userId !== userId
) {
return
}
const addStatus = (data, showImmediately, addToTimeline = true) => {
const result = addStatusToGlobalStorage(state, data)
const status = result.item
if (result.new) {
// We are mentioned in a post
if (
status.type === 'status' &&
find(status.attentions, { id: user.id })
) {
const mentions = state.timelines.mentions
// Add the mention to the mentions timeline
if (timelineObject !== mentions) {
mergeOrAdd(mentions.statuses, mentions.statusesObject, status)
mentions.newStatusCount += 1
sortTimeline(mentions)
}
}
if (status.visibility === 'direct') {
const dms = state.timelines.dms
mergeOrAdd(dms.statuses, dms.statusesObject, status)
dms.newStatusCount += 1
sortTimeline(dms)
}
}
// Decide if we should treat the status as new for this timeline.
let resultForCurrentTimeline
// Some statuses should only be added to the global status repository.
if (timeline && addToTimeline) {
resultForCurrentTimeline = mergeOrAdd(
timelineObject.statuses,
timelineObject.statusesObject,
status,
)
}
if (timeline && showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
mergeOrAdd(
timelineObject.visibleStatuses,
timelineObject.visibleStatusesObject,
status,
)
} else if (timeline && addToTimeline && resultForCurrentTimeline.new) {
// Just change newStatuscount
timelineObject.newStatusCount += 1
}
if (status.quote) {
addStatus(
status.quote,
/* showImmediately = */ false,
/* addToTimeline = */ false,
)
}
return status
}
const favoriteStatus = (favorite) => {
const status = find(allStatuses, { id: favorite.in_reply_to_status_id })
if (status) {
// This is our favorite, so the relevant bit.
if (favorite.user.id === user.id) {
status.favorited = true
} else {
status.fave_num += 1
}
}
return status
}
const processors = {
status: (status) => {
addStatus(status, showImmediately)
},
edit: (status) => {
addStatus(status, showImmediately)
},
retweet: (status) => {
// RetweetedStatuses are never shown immediately
const retweetedStatus = addStatus(status.retweeted_status, false, false)
let retweet
// If the retweeted status is already there, don't add the retweet
// to the timeline.
if (
timeline &&
find(timelineObject.statuses, (s) => {
if (s.retweeted_status) {
return (
s.id === retweetedStatus.id ||
s.retweeted_status.id === retweetedStatus.id
)
} else {
return s.id === retweetedStatus.id
}
})
) {
// Already have it visible (either as the original or another RT), don't add to timeline, don't show.
retweet = addStatus(status, false, false)
} else {
retweet = addStatus(status, showImmediately)
}
retweet.retweeted_status = retweetedStatus
},
favorite: (favorite) => {
// Only update if this is a new favorite.
// Ignore our own favorites because we get info about likes as response to like request
if (!state.favorites.has(favorite.id)) {
state.favorites.add(favorite.id)
favoriteStatus(favorite)
}
},
follow: () => {
// NOOP, it is known status but we don't do anything about it for now
},
default: (unknown) => {
console.warn('unknown status type', unknown)
},
}
each(statuses, (status) => {
const type = status.type
const processor = processors[type] || processors.default
processor(status)
})
// Keep the visible statuses sorted
if (timeline && !(timeline === 'bookmarks')) {
sortTimeline(timelineObject)
}
}
const removeStatus = (state, { timeline, userId }) => {
const timelineObject = state.timelines[timeline]
if (userId) {
remove(timelineObject.statuses, { user: { id: userId } })
remove(timelineObject.visibleStatuses, { user: { id: userId } })
timelineObject.minVisibleId =
timelineObject.visibleStatuses.length > 0
? last(timelineObject.visibleStatuses).id
: 0
timelineObject.maxId =
timelineObject.statuses.length > 0 ? first(timelineObject.statuses).id : 0
}
}
export const mutations = {
addNewStatuses,
removeStatus,
showNewStatuses(state, { timeline }) {
const oldTimeline = state.timelines[timeline]
oldTimeline.newStatusCount = 0
oldTimeline.visibleStatuses = oldTimeline.statuses.slice(0, 50)
oldTimeline.minVisibleId = last(oldTimeline.visibleStatuses).id
oldTimeline.minId = oldTimeline.minVisibleId
oldTimeline.visibleStatusesObject = {}
each(oldTimeline.visibleStatuses, (status) => {
oldTimeline.visibleStatusesObject[status.id] = status
})
},
resetStatuses(state) {
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
state[key] = value
})
},
clearTimeline(state, { timeline, excludeUserId = false }) {
const userId = excludeUserId ? state.timelines[timeline].userId : undefined
state.timelines[timeline] = emptyTl(userId)
},
setFavorited(state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus.favorited !== value) {
if (value) {
newStatus.fave_num++
} else {
newStatus.fave_num--
}
}
newStatus.favorited = value
},
setFavoritedConfirm(state, { status, user }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.favorited = status.favorited
newStatus.fave_num = status.fave_num
const index = findIndex(newStatus.favoritedBy, { id: user.id })
if (index !== -1 && !newStatus.favorited) {
newStatus.favoritedBy.splice(index, 1)
} else if (index === -1 && newStatus.favorited) {
newStatus.favoritedBy.push(user)
}
},
setMutedStatus(state, status) {
const newStatus = state.allStatusesObject[status.id]
newStatus.thread_muted = status.thread_muted
if (newStatus.thread_muted !== undefined) {
state.conversationsObject[newStatus.statusnet_conversation_id].forEach(
(status) => {
status.thread_muted = newStatus.thread_muted
},
)
}
},
setRetweeted(state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value
},
setRetweetedConfirm(state, { status, user }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.repeated = status.repeated
newStatus.repeat_num = status.repeat_num
const index = findIndex(newStatus.rebloggedBy, { id: user.id })
if (index !== -1 && !newStatus.repeated) {
newStatus.rebloggedBy.splice(index, 1)
} else if (index === -1 && newStatus.repeated) {
newStatus.rebloggedBy.push(user)
}
},
setBookmarked(state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = value
newStatus.bookmark_folder_id = status.bookmark_folder_id
},
setBookmarkedConfirm(state, { status }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = status.bookmarked
if (status.pleroma)
newStatus.bookmark_folder_id = status.pleroma.bookmark_folder
},
setDeleted(state, { status }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus) newStatus.deleted = true
},
setManyDeleted(state, condition) {
Object.values(state.allStatusesObject).forEach((status) => {
if (condition(status)) {
status.deleted = true
}
})
},
setLoading(state, { timeline, value }) {
state.timelines[timeline].loading = value
},
setNsfw(state, { id, nsfw }) {
const newStatus = state.allStatusesObject[id]
newStatus.nsfw = nsfw
},
queueFlush(state, { timeline, id }) {
state.timelines[timeline].flushMarker = id
},
queueFlushAll(state) {
Object.keys(state.timelines).forEach((timeline) => {
state.timelines[timeline].flushMarker = state.timelines[timeline].maxId
})
},
addRepeats(state, { id, rebloggedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
- newStatus.rebloggedBy = rebloggedByUsers.filter((_) => _)
+ newStatus.rebloggedBy = rebloggedByUsers.filter(Boolean)
// repeats stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.repeat_num = newStatus.rebloggedBy.length
newStatus.repeated = !!newStatus.rebloggedBy.find(
({ id }) => currentUser.id === id,
)
},
addFavs(state, { id, favoritedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
- newStatus.favoritedBy = favoritedByUsers.filter((_) => _)
+ newStatus.favoritedBy = favoritedByUsers.filter(Boolean)
// favorites stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.fave_num = newStatus.favoritedBy.length
newStatus.favorited = !!newStatus.favoritedBy.find(
({ id }) => currentUser.id === id,
)
},
addEmojiReactionsBy(state, { id, emojiReactions }) {
const status = state.allStatusesObject[id]
status.emoji_reactions = emojiReactions
},
addOwnReaction(state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
const reaction = status.emoji_reactions[reactionIndex] || {
name: emoji,
count: 0,
accounts: [],
}
const newReaction = {
...reaction,
count: reaction.count + 1,
me: true,
accounts: [...reaction.accounts, currentUser],
}
// Update count of existing reaction if it exists, otherwise append at the end
if (reactionIndex >= 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = [...status.emoji_reactions, newReaction]
}
},
removeOwnReaction(state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
if (reactionIndex < 0) return
const reaction = status.emoji_reactions[reactionIndex]
const accounts = reaction.accounts || []
const newReaction = {
...reaction,
count: reaction.count - 1,
me: false,
accounts: accounts.filter((acc) => acc.id !== currentUser.id),
}
if (newReaction.count > 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = status.emoji_reactions.filter(
(r) => r.name !== emoji,
)
}
},
updateStatusWithPoll(state, { id, poll }) {
const status = state.allStatusesObject[id]
status.poll = poll
},
setVirtualHeight(state, { statusId, height }) {
state.allStatusesObject[statusId].virtualHeight = height
},
}
const statuses = {
state: defaultState(),
actions: {
addNewStatuses(
{ rootState, commit },
{
statuses,
showImmediately = false,
timeline = false,
noIdUpdate = false,
userId,
pagination,
},
) {
return commit('addNewStatuses', {
statuses,
showImmediately,
timeline,
noIdUpdate,
user: rootState.users.currentUser,
userId,
pagination,
})
},
fetchStatus({ rootState, dispatch }, id) {
return fetchStatus({ id }).then(({ data: status }) =>
dispatch('addNewStatuses', { statuses: [status] }),
)
},
fetchStatusSource({ rootState }, status) {
return fetchStatusSource({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
fetchStatusHistory(_, status) {
return fetchStatusHistory({ status }).then(({ data }) => data)
},
deleteStatus({ rootState, commit }, status) {
deleteStatus({
id: status.id,
credentials: useOAuthStore().token,
})
.then(() => {
commit('setDeleted', { status })
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'status.delete_error',
messageArgs: [e.message],
timeout: 5000,
})
})
},
deleteStatusById({ rootState, commit }, id) {
const status = rootState.statuses.allStatusesObject[id]
commit('setDeleted', { status })
},
markStatusesAsDeleted({ commit }, condition) {
commit('setManyDeleted', condition)
},
favorite({ rootState, commit }, status) {
// Optimistic favoriting...
commit('setFavorited', { status, value: true })
favorite({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setFavoritedConfirm', {
status,
user: rootState.users.currentUser,
}),
)
},
unfavorite({ rootState, commit }, status) {
// Optimistic unfavoriting...
commit('setFavorited', { status, value: false })
unfavorite({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setFavoritedConfirm', {
status,
user: rootState.users.currentUser,
}),
)
},
fetchPinnedStatuses({ rootState, dispatch }, userId) {
fetchPinnedStatuses({
id: userId,
credentials: useOAuthStore().token,
}).then(({ data: statuses }) =>
dispatch('addNewStatuses', {
statuses,
timeline: 'user',
userId,
showImmediately: true,
noIdUpdate: true,
}),
)
},
pinStatus({ rootState, dispatch }, statusId) {
return pinOwnStatus({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
dispatch('addNewStatuses', { statuses: [status] }),
)
},
unpinStatus({ rootState, dispatch }, statusId) {
return unpinOwnStatus({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
dispatch('addNewStatuses', { statuses: [status] }),
)
},
muteConversation({ rootState, commit }, { id: statusId }) {
return muteConversation({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) => commit('setMutedStatus', status))
},
unmuteConversation({ rootState, commit }, { id: statusId }) {
return unmuteConversation({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) => commit('setMutedStatus', status))
},
retweet({ rootState, commit }, status) {
// Optimistic retweeting...
commit('setRetweeted', { status, value: true })
retweet({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setRetweetedConfirm', {
status: status.retweeted_status,
user: rootState.users.currentUser,
}),
)
},
unretweet({ rootState, commit }, status) {
// Optimistic unretweeting...
commit('setRetweeted', { status, value: false })
unretweet({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setRetweetedConfirm', {
status,
user: rootState.users.currentUser,
}),
)
},
bookmark({ rootState, commit }, status) {
commit('setBookmarked', { status, value: true })
bookmarkStatus({
id: status.id,
folder_id: status.bookmark_folder_id,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
commit('setBookmarkedConfirm', { status })
})
},
unbookmark({ rootState, commit }, status) {
commit('setBookmarked', { status, value: false })
unbookmarkStatus({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
commit('setBookmarkedConfirm', { status })
})
},
queueFlush({ commit }, { timeline, id }) {
commit('queueFlush', { timeline, id })
},
queueFlushAll({ commit }) {
commit('queueFlushAll')
},
fetchFavsAndRepeats({ rootState, commit }, id) {
Promise.all([
fetchFavoritedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data }) => data),
fetchRebloggedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data }) => data),
]).then(([favoritedByUsers, rebloggedByUsers]) => {
commit('addFavs', {
id,
favoritedByUsers,
currentUser: rootState.users.currentUser,
})
commit('addRepeats', {
id,
rebloggedByUsers,
currentUser: rootState.users.currentUser,
})
})
},
reactWithEmoji({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('addOwnReaction', { id, emoji, currentUser })
reactWithEmoji({
id,
emoji,
credentials: useOAuthStore().token,
}).then(() => {
dispatch('fetchEmojiReactionsBy', id)
})
},
unreactWithEmoji({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('removeOwnReaction', { id, emoji, currentUser })
unreactWithEmoji({
id,
emoji,
currentUser: rootState.users.currentUser,
}).then(() => {
dispatch('fetchEmojiReactionsBy', id)
})
},
fetchEmojiReactionsBy({ rootState, commit }, id) {
return fetchEmojiReactions({
id,
credentials: useOAuthStore().token,
}).then(({ data: emojiReactions }) => {
commit('addEmojiReactionsBy', {
id,
emojiReactions,
currentUser: rootState.users.currentUser,
})
})
},
fetchFavs({ rootState, commit }, id) {
fetchFavoritedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: favoritedByUsers }) =>
commit('addFavs', {
id,
favoritedByUsers,
currentUser: rootState.users.currentUser,
}),
)
},
fetchRepeats({ rootState, commit }, id) {
fetchRebloggedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: rebloggedByUsers }) =>
commit('addRepeats', {
id,
rebloggedByUsers,
currentUser: rootState.users.currentUser,
}),
)
},
search(store, { q, resolve, limit, offset, following, type }) {
return search2({
q,
resolve,
limit,
offset,
following,
type,
credentials: useOAuthStore().token,
}).then(({ data }) => {
store.commit('addNewUsers', data.accounts)
store.commit(
'addNewUsers',
- data.statuses.map((s) => s.user).filter((u) => u),
+ data.statuses.map((s) => s.user).filter(Boolean),
)
store.commit('addNewStatuses', {
statuses: data.statuses,
})
data.statuses = data.statuses.map(
(s) => store.state.allStatusesObject[s.id],
)
return data
})
},
setVirtualHeight({ commit }, { statusId, height }) {
commit('setVirtualHeight', { statusId, height })
},
},
mutations,
}
export default statuses
diff --git a/src/modules/users.js b/src/modules/users.js
index b585668591..c4af0fb36a 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,870 +1,870 @@
import Cookies from 'js-cookie'
import { compact, each, last, map, mergeWith } from 'lodash'
import {
registerPushNotifications,
unregisterPushNotifications,
} from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useListsStore } from 'src/stores/lists.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 { revokeToken } from 'src/api/oauth.js'
import {
fetchFollowers,
fetchFriends,
fetchUser,
fetchUserByName,
getCaptcha,
register,
searchUsers,
verifyCredentials,
} from 'src/api/public.js'
import {
blockUser as apiBlockUser,
editUserNote as apiEditUserNote,
muteUser as apiMuteUser,
unblockUser as apiUnblockUser,
unmuteUser as apiUnmuteUser,
fetchBlocks,
fetchDomainMutes,
fetchMutes,
fetchUserInLists,
fetchUserRelationship,
followUser,
} from 'src/api/user.js'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => {
if (!item) {
return false
}
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
mergeWith(oldItem, item, mergeArrayLength)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
arr.push(item)
obj[item.id] = item
return { item, new: true }
}
}
const mergeArrayLength = (oldValue, newValue) => {
if (Array.isArray(oldValue) && Array.isArray(newValue)) {
oldValue.length = newValue.length
return mergeWith(oldValue, newValue, mergeArrayLength)
}
}
const getNotificationPermission = () => {
const Notification = window.Notification
- if (!Notification) return Promise.resolve(null)
+ if (!Notification) return null
if (Notification.permission === 'default')
return Notification.requestPermission()
- return Promise.resolve(Notification.permission)
+ return Notification.permission
}
const blockUser = (store, args) => {
const id = args.id
const expiresIn = typeof args === 'object' ? args.expiresIn : 0
const predictedRelationship = store.state.relationships[id] || { id }
store.commit('updateUserRelationship', [predictedRelationship])
store.commit('addBlockId', id)
return apiBlockUser({ id, expiresIn }).then(({ data: relationship }) => {
store.commit('updateUserRelationship', [relationship])
store.commit('addBlockId', id)
store.commit('removeStatus', { timeline: 'friends', userId: id })
store.commit('removeStatus', { timeline: 'public', userId: id })
store.commit('removeStatus', {
timeline: 'publicAndExternal',
userId: id,
})
})
}
const unblockUser = (store, id) => {
return apiUnblockUser({ id }).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const removeUserFromFollowers = (store, id) => {
return removeUserFromFollowers({ id }).then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const editUserNote = (store, { id, comment }) => {
return apiEditUserNote({ id, comment }).then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const muteUser = (store, args) => {
const id = typeof args === 'object' ? args.id : args
const expiresIn = typeof args === 'object' ? args.expiresIn : 0
const predictedRelationship = store.state.relationships[id] || { id }
store.commit('updateUserRelationship', [predictedRelationship])
store.commit('addMuteId', id)
return apiMuteUser({
id,
expiresIn,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) => {
store.commit('updateUserRelationship', [relationship])
store.commit('addMuteId', id)
})
}
const unmuteUser = (store, id) => {
const predictedRelationship = store.state.relationships[id] || { id }
predictedRelationship.muting = false
store.commit('updateUserRelationship', [predictedRelationship])
return apiUnmuteUser({ id }).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const hideReblogs = (store, userId) => {
return followUser({
id: userId,
reblogs: false,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const showReblogs = (store, userId) => {
return followUser({
id: userId,
reblogs: true,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const muteDomain = (store, domain) => {
return muteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => store.commit('addDomainMute', domain))
}
const unmuteDomain = (store, domain) => {
return unmuteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => store.commit('removeDomainMute', domain))
}
export const mutations = {
tagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
user.tags.add(tag)
},
untagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
user.tags.delete(tag)
},
updateRight(state, { user: { id }, right, value }) {
const user = state.usersObject[id]
const newRights = user.rights
newRights[right] = value
user.rights = newRights
},
updateUserAdminData(state, { user }) {
const { id } = user
const localUser = state.usersObject[id]
localUser.adminData = user
localUser.deactivated = !user.is_active
localUser.tags = new Set(user.tags)
},
setCurrentUser(state, user) {
state.lastLoginName = user.screen_name
state.currentUser = mergeWith(
state.currentUser || {},
user,
mergeArrayLength,
)
},
clearCurrentUser(state) {
state.currentUser = false
state.lastLoginName = false
},
beginLogin(state) {
state.loggingIn = true
},
endLogin(state) {
state.loggingIn = false
},
saveFriendIds(state, { id, friendIds }) {
const user = state.usersObject[id]
user.friendIds = [...new Set([...(user.friendIds || []), ...friendIds])]
},
saveFollowerIds(state, { id, followerIds }) {
const user = state.usersObject[id]
user.followerIds = [...new Set([user.followerIds || [], ...followerIds])]
},
// Because frontend doesn't have a reason to keep these stuff in memory
// outside of viewing someones user profile.
clearFriends(state, userId) {
const user = state.usersObject[userId]
if (user) {
user.friendIds = []
}
},
clearFollowers(state, userId) {
const user = state.usersObject[userId]
if (user) {
user.followerIds = []
}
},
addNewUsers(state, users) {
each(users, (user) => {
if (user.relationship) {
state.relationships[user.relationship.id] = user.relationship
}
const res = mergeOrAdd(state.users, state.usersObject, user)
const item = res.item
if (res.new && item.screen_name && !item.screen_name.includes('@')) {
state.usersByNameObject[item.screen_name.toLowerCase()] = item
}
})
},
updateUserRelationship(state, relationships) {
relationships.forEach((relationship) => {
state.relationships[relationship.id] = relationship
})
},
updateUserInLists(state, { id, inLists }) {
state.usersObject[id].inLists = inLists
},
saveBlockIds(state, blockIds) {
state.currentUser.blockIds = blockIds
},
addBlockId(state, blockId) {
- if (state.currentUser.blockIds.indexOf(blockId) === -1) {
+ if (state.currentUser.blockIds.includes(blockId)) {
state.currentUser.blockIds.push(blockId)
}
},
setBlockIdsMaxId(state, blockIdsMaxId) {
state.currentUser.blockIdsMaxId = blockIdsMaxId
},
saveMuteIds(state, muteIds) {
state.currentUser.muteIds = muteIds
},
setMuteIdsMaxId(state, muteIdsMaxId) {
state.currentUser.muteIdsMaxId = muteIdsMaxId
},
addMuteId(state, muteId) {
- if (state.currentUser.muteIds.indexOf(muteId) === -1) {
+ if (state.currentUser.muteIds.includes(muteId)) {
state.currentUser.muteIds.push(muteId)
}
},
saveDomainMutes(state, domainMutes) {
state.currentUser.domainMutes = domainMutes
},
addDomainMute(state, domain) {
- if (state.currentUser.domainMutes.indexOf(domain) === -1) {
+ if (state.currentUser.domainMutes.includes(domain)) {
state.currentUser.domainMutes.push(domain)
}
},
removeDomainMute(state, domain) {
const index = state.currentUser.domainMutes.indexOf(domain)
if (index !== -1) {
state.currentUser.domainMutes.splice(index, 1)
}
},
setPinnedToUser(state, status) {
const user = state.usersObject[status.user.id]
user.pinnedStatusIds = user.pinnedStatusIds || []
const index = user.pinnedStatusIds.indexOf(status.id)
if (status.pinned && index === -1) {
user.pinnedStatusIds.push(status.id)
} else if (!status.pinned && index !== -1) {
user.pinnedStatusIds.splice(index, 1)
}
},
setUserForStatus(state, status) {
status.user = state.usersObject[status.user.id]
},
setUserForNotification(state, notification) {
if (notification.type !== 'follow') {
notification.action.user = state.usersObject[notification.action.user.id]
}
notification.from_profile = state.usersObject[notification.from_profile.id]
},
setColor(state, { user: { id }, highlighted }) {
const user = state.usersObject[id]
user.highlight = highlighted
},
signUpPending(state) {
state.signUpPending = true
state.signUpErrors = []
state.signUpNotice = {}
},
signUpSuccess(state) {
state.signUpPending = false
},
signUpFailure(state, errors) {
state.signUpPending = false
state.signUpErrors = errors
state.signUpNotice = {}
},
signUpNotice(state, notice) {
state.signUpPending = false
state.signUpErrors = []
state.signUpNotice = notice
},
}
export const getters = {
findUser: (state) => (query) => {
return state.usersObject[query]
},
findUserByName: (state) => (query) => {
return state.usersByNameObject[query.toLowerCase()]
},
findUserByUrl: (state) => (query) => {
return state.users.find(
(u) =>
u.statusnet_profile_url &&
u.statusnet_profile_url.toLowerCase() === query.toLowerCase(),
)
},
relationship: (state) => (id) => {
const rel = id && state.relationships[id]
return rel || { id, loading: true }
},
}
export const defaultState = {
loggingIn: false,
lastLoginName: false,
currentUser: false,
users: [],
usersObject: {},
usersByNameObject: {},
signUpPending: false,
signUpErrors: [],
signUpNotice: {},
relationships: {},
}
const users = {
state: defaultState,
mutations,
getters,
actions: {
fetchUserIfMissing(store, id) {
const user = store.getters.findUser(id)
if (!user) {
return store.dispatch('fetchUser', id)
} else {
- return Promise.resolve(user)
+ return user
}
},
updateUserAdminData(store, { userAdminData }) {
return store
.dispatch('fetchUserIfMissing', userAdminData.id)
.then((user) => {
user.adminData = userAdminData
store.commit('addNewUsers', [user])
return user
})
},
fetchUser(store, id) {
return fetchUser({
id,
credentials: useOAuthStore().token,
})
.then(({ data: user }) => {
store.commit('addNewUsers', [user])
return user
})
.catch((error) => {
if (error.statusCode === 404) {
console.warn(`User ${id} not found`)
} else {
throw error
}
})
},
fetchUserByName(store, name) {
return fetchUserByName({
name,
credentials: useOAuthStore().token,
}).then(({ data: user }) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserRelationship(store, id) {
if (store.state.currentUser) {
fetchUserRelationship({
id,
credentials: useOAuthStore().token,
}).then(({ data: relationships }) =>
store.commit('updateUserRelationship', relationships),
)
}
},
fetchUserInLists(store, id) {
if (store.state.currentUser) {
fetchUserInLists({
id,
credentials: useOAuthStore().token,
}).then(({ data: inLists }) =>
store.commit('updateUserInLists', { id, inLists }),
)
}
},
fetchBlocks(store, args) {
const { reset } = args || {}
const maxId = store.state.currentUser.blockIdsMaxId
return fetchBlocks({
maxId,
credentials: useOAuthStore().token,
}).then(({ data: blocks }) => {
if (reset) {
store.commit('saveBlockIds', map(blocks, 'id'))
} else {
map(blocks, 'id').map((id) => store.commit('addBlockId', id))
}
if (blocks.length) {
store.commit('setBlockIdsMaxId', last(blocks).id)
}
store.commit('addNewUsers', blocks)
return blocks
})
},
blockUser(store, data) {
return blockUser(store, data)
},
unblockUser(store, data) {
return unblockUser(store, data)
},
removeUserFromFollowers(store, id) {
return removeUserFromFollowers(store, id)
},
blockUsers(store, data = []) {
return Promise.all(data.map((d) => blockUser(store, d)))
},
unblockUsers(store, data = []) {
return Promise.all(data.map((d) => unblockUser(store, d)))
},
editUserNote(store, args) {
return editUserNote(store, args)
},
fetchMutes(store, args) {
const { reset } = args || {}
const maxId = store.state.currentUser.muteIdsMaxId
return fetchMutes({
maxId,
credentials: useOAuthStore().token,
}).then(({ data: mutes }) => {
if (reset) {
store.commit('saveMuteIds', map(mutes, 'id'))
} else {
map(mutes, 'id').map((id) => store.commit('addMuteId', id))
}
if (mutes.length) {
store.commit('setMuteIdsMaxId', last(mutes).id)
}
store.commit('addNewUsers', mutes)
return mutes
})
},
muteUser(store, data) {
return muteUser(store, data)
},
unmuteUser(store, id) {
return unmuteUser(store, id)
},
hideReblogs(store, id) {
return hideReblogs(store, id)
},
showReblogs(store, id) {
return showReblogs(store, id)
},
muteUsers(store, data = []) {
return Promise.all(data.map((d) => muteUser(store, d)))
},
unmuteUsers(store, ids = []) {
return Promise.all(ids.map((d) => unmuteUser(store, d)))
},
fetchDomainMutes(store) {
return fetchDomainMutes({
credentials: useOAuthStore().token,
}).then(({ data: domainMutes }) => {
store.commit('saveDomainMutes', domainMutes)
return domainMutes
})
},
muteDomain(store, domain) {
return muteDomain(store, domain)
},
unmuteDomain(store, domain) {
return unmuteDomain(store, domain)
},
muteDomains(store, domains = []) {
return Promise.all(domains.map((domain) => muteDomain(store, domain)))
},
unmuteDomains(store, domain = []) {
return Promise.all(domain.map((domain) => unmuteDomain(store, domain)))
},
fetchFriends({ rootState, commit }, id) {
const user = rootState.users.usersObject[id]
const maxId = last(user.friendIds)
return fetchFriends({
id,
maxId,
credentials: useOAuthStore().token,
}).then(({ data: friends }) => {
commit('addNewUsers', friends)
commit('saveFriendIds', { id, friendIds: map(friends, 'id') })
return friends
})
},
fetchFollowers({ rootState, commit }, id) {
const user = rootState.users.usersObject[id]
const maxId = last(user.followerIds)
return fetchFollowers({
id,
maxId,
credentials: useOAuthStore().token,
}).then(({ data: followers }) => {
commit('addNewUsers', followers)
commit('saveFollowerIds', { id, followerIds: map(followers, 'id') })
return followers
})
},
clearFriends({ commit }, userId) {
commit('clearFriends', userId)
},
clearFollowers({ commit }, userId) {
commit('clearFollowers', userId)
},
subscribeUser({ rootState, commit }, id) {
return followUser({
id,
notify: true,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
commit('updateUserRelationship', [relationship]),
)
},
unsubscribeUser({ rootState, commit }, id) {
return followUser({
id,
notify: false,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
commit('updateUserRelationship', [relationship]),
)
},
registerPushNotifications(store) {
const token = store.state.currentUser.credentials
const vapidPublicKey = useInstanceStore().vapidPublicKey
const isEnabled = useMergedConfigStore().mergedConfig.webPushNotifications
const notificationVisibility =
useMergedConfigStore().mergedConfig.notificationVisibility
registerPushNotifications(
isEnabled,
vapidPublicKey,
token,
notificationVisibility,
)
},
unregisterPushNotifications(store) {
const token = store.state.currentUser.credentials
unregisterPushNotifications(token)
},
addNewUsers({ commit }, users) {
commit('addNewUsers', users)
},
addNewStatuses(store, { statuses }) {
const users = map(statuses, 'user')
const retweetedUsers = compact(map(statuses, 'retweeted_status.user'))
store.commit('addNewUsers', users)
store.commit('addNewUsers', retweetedUsers)
each(statuses, (status) => {
// Reconnect users to statuses
store.commit('setUserForStatus', status)
// Set pinned statuses to user
store.commit('setPinnedToUser', status)
})
each(compact(map(statuses, 'retweeted_status')), (status) => {
// Reconnect users to retweets
store.commit('setUserForStatus', status)
// Set pinned retweets to user
store.commit('setPinnedToUser', status)
})
},
addNewNotifications(store, { notifications }) {
const users = map(notifications, 'from_profile')
- const targetUsers = map(notifications, 'target').filter((_) => _)
+ const targetUsers = map(notifications, 'target').filter(Boolean)
const notificationIds = notifications.map((_) => _.id)
store.commit('addNewUsers', users)
store.commit('addNewUsers', targetUsers)
const notificationsObject = store.rootState.notifications.idStore
const relevantNotifications = Object.entries(notificationsObject)
.filter(([k]) => notificationIds.includes(k))
.map(([, val]) => val)
// Reconnect users to notifications
each(relevantNotifications, (notification) => {
store.commit('setUserForNotification', notification)
})
},
searchUsers({ rootState, commit }, { query }) {
return searchUsers({
query,
credentials: useOAuthStore().token,
}).then(({ data: users }) => {
commit('addNewUsers', users)
return users
})
},
async signUp(store, userInfo) {
const oauthStore = useOAuthStore()
store.commit('signUpPending')
try {
const token = await oauthStore.ensureAppToken()
const { data } = await register({
credentials: token,
params: { ...userInfo },
})
if (data.access_token) {
store.commit('signUpSuccess')
oauthStore.setToken(data.access_token)
await store.dispatch('loginUser', data.access_token)
return 'ok'
} else {
// Request succeeded, but user cannot login yet.
store.commit('signUpNotice', data)
return 'request_sent'
}
} catch (e) {
const errors = e.message
store.commit('signUpFailure', errors)
throw e
}
},
getCaptcha(store) {
return getCaptcha({
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
logout(store) {
const oauth = useOAuthStore()
// NOTE: No need to verify the app still exists, because if it doesn't,
// the token will be invalid too
return oauth
.ensureApp()
.then((app) => {
const params = {
app,
instance: useInstanceStore().server,
token: oauth.userToken,
}
return revokeToken(params)
})
.then(() => {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
store.dispatch('stopFetchingTimeline', 'friends')
store.dispatch('stopFetchingNotifications')
useListsStore().stopFetching()
useBookmarkFoldersStore().stopFetching()
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
store.commit('resetStatuses')
useChatsStore().resetChats()
oauth.clearToken()
Cookies.remove('__Host-pleroma_key', { path: '/' })
useInterfaceStore().setLastTimeline('public-timeline')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
})
},
loginUser(store, accessToken) {
return new Promise((resolve, reject) => {
const commit = store.commit
const dispatch = store.dispatch
commit('beginLogin')
verifyCredentials({
credentials: useOAuthStore().token,
})
.then(({ data: user }) => {
// user.credentials = userCredentials
user.credentials = accessToken
user.blockIds = []
user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', user)
useSyncConfigStore()
.initSyncConfig(user)
.then(() => {
useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
})
useUserHighlightStore().initUserHighlight(user)
commit('addNewUsers', [user])
useEmojiStore().fetchEmoji()
getNotificationPermission().then((permission) =>
useInterfaceStore().setNotificationPermission(permission),
)
// Do server-side storage migrations
// Debug snippet to clean up storage and reset migrations
/*
// Reset wordfilter
Object.keys(
useSyncConfigStore().prefsStorage.simple.muteFilters
).forEach(key => {
useSyncConfigStore().unsetSimplePrefAndSave({ path: 'muteFilters.' + key, value: null })
})
// Reset flag to 0 to re-run migrations
useSyncConfigStore().setFlag({ flag: 'configMigration', value: 0 })
/**/
if (user.token) {
dispatch('setWsToken', user.token)
// Initialize the shout socket.
dispatch('initializeSocket')
}
const startPolling = () => {
// Start getting fresh posts.
dispatch('startFetchingTimeline', { timeline: 'friends' })
// Start fetching notifications
dispatch('startFetchingNotifications')
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
// Start fetching chats
dispatch('startFetchingChats')
}
}
useListsStore().startFetching()
useBookmarkFoldersStore().startFetching()
if (user.locked) {
dispatch('startFetchingFollowRequests')
}
if (useMergedConfigStore().mergedConfig.useStreamingApi) {
dispatch('fetchTimeline', {
timeline: 'friends',
sinceId: null,
})
dispatch('fetchNotifications', { sinceId: null })
dispatch('enableMastoSockets', true)
.catch((error) => {
console.error(
'Failed initializing MastoAPI Streaming socket',
error,
)
})
.then(() => {
dispatch('fetchChats', { latest: true })
setTimeout(
() => dispatch('setNotificationsSilence', false),
10000,
)
})
} else {
startPolling()
}
// Start fetching things that don't need to block the UI
useAnnouncementsStore().startFetchingAnnouncements()
dispatch('fetchMutes')
dispatch('loadDrafts')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
// Fetch our friends
fetchFriends({ id: user.id }).then(({ data: friends }) =>
commit('addNewUsers', friends),
)
commit('endLogin')
resolve()
})
.catch((error) => {
console.error(error)
// Authentication failed
commit('endLogin')
// remove authentication token on client/authentication errors
if ([400, 401, 403, 422].includes(error.statusCode)) {
useOAuthStore().clearToken()
}
commit('endLogin')
if (error.tatusCode === 401) {
throw new Error('Wrong username or password', error)
} else {
throw new Error('An error occurred, please try again', error)
}
})
})
},
},
}
export default users
diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js
index dded73da3e..925969531b 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') {
+ if (r === null || 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') {
+ if (fga === 1 || 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: Number.parseInt(result[1], 16),
g: Number.parseInt(result[2], 16),
b: Number.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)) {
+ if (rgba.r !== undefined && !Number.isNaN(rgba.r)) {
base.r = rgba.r
}
- if (rgba.g !== undefined && !isNaN(rgba.g)) {
+ if (rgba.g !== undefined && !Number.isNaN(rgba.g)) {
base.g = rgba.g
}
- if (rgba.b !== undefined && !isNaN(rgba.b)) {
+ if (rgba.b !== undefined && !Number.isNaN(rgba.b)) {
base.b = rgba.b
}
- if (rgba.a !== undefined && !isNaN(rgba.a)) {
+ if (rgba.a !== undefined && !Number.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 = 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/file_type/file_type.service.js b/src/services/file_type/file_type.service.js
index 02f659eec1..b6179cd81c 100644
--- a/src/services/file_type/file_type.service.js
+++ b/src/services/file_type/file_type.service.js
@@ -1,54 +1,54 @@
// TODO this func might as well take the entire file and use its mimetype
// or the entire service could be just mimetype service that only operates
// on mimetypes and not files. Currently the naming is confusing.
export const fileType = (mimetype) => {
if (mimetype.match(/flash/)) {
return 'flash'
}
if (mimetype.match(/text\/html/)) {
return 'html'
}
if (mimetype.match(/image/)) {
return 'image'
}
if (mimetype.match(/video/)) {
return 'video'
}
if (mimetype.match(/audio/)) {
return 'audio'
}
return 'unknown'
}
export const fileTypeExt = (url) => {
if (url.match(/\.(a?png|jpe?g|gif|webp|avif)$/)) {
return 'image'
}
if (url.match(/\.(ogv|mp4|webm|mov)$/)) {
return 'video'
}
if (
url.match(
/\.(it|s3m|mod|umx|mp3|aac|m4a|flac|alac|ogg|oga|opus|wav|ape|midi?)$/,
)
) {
return 'audio'
}
return 'unknown'
}
export const fileMatchesSomeType = (types, file) =>
- types.some((type) => fileType(file.mimetype) === type)
+ types.includes(fileType(file.mimetype))
const fileTypeService = {
fileType,
fileTypeExt,
fileMatchesSomeType,
}
export default fileTypeService
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 46b81cc66b..9e443bf8bd 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -1,206 +1,204 @@
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
import { muteFilterHits } from '../status_parser/status_parser.js'
import FaviconService from 'src/services/favicon_service/favicon_service.js'
export const ACTIONABLE_NOTIFICATION_TYPES = new Set([
'mention',
'pleroma:report',
'follow_request',
])
let cachedBadgeUrl = null
export const notificationsFromStore = (store) => store.state.notifications.data
const visibleTypes = (notificationVisibility) => {
return [
notificationVisibility.likes && 'like',
notificationVisibility.mentions && 'mention',
notificationVisibility.statuses && 'status',
notificationVisibility.repeats && 'repeat',
notificationVisibility.follows && 'follow',
notificationVisibility.followRequest && 'follow_request',
notificationVisibility.moves && 'move',
notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
notificationVisibility.reports && 'pleroma:report',
notificationVisibility.polls && 'poll',
- ].filter((_) => _)
+ ].filter(Boolean)
}
const statusNotifications = new Set([
'like',
'mention',
'status',
'repeat',
'pleroma:emoji_reaction',
'poll',
])
export const isStatusNotification = (type) => statusNotifications.has(type)
export const isValidNotification = (notification) => {
if (isStatusNotification(notification.type) && !notification.status) {
return false
}
return true
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
const isMutedNotification = (muteFilters, notification) => {
if (!notification.status) return false
if (notification.status.muted) return true
return muteFilterHits(muteFilters, notification.status).length > 0
}
export const maybeShowNotification = (
store,
notificationVisibility,
muteFilters,
notification,
i18n,
) => {
const rootState = store.rootState || store.state
if (notification.seen) return
if (!visibleTypes(notificationVisibility).includes(notification.type)) return
if (
notification.type === 'mention' &&
isMutedNotification(muteFilters, notification)
)
return
const notificationObject = prepareNotificationObject(notification, i18n)
showDesktopNotification(rootState, notificationObject)
}
export const filteredNotificationsFromStore = (
store,
notificationVisibility,
types,
) => {
// map is just to clone the array since sort mutates it and it causes some issues
- const sortedNotifications = notificationsFromStore(store)
- .map((_) => _)
- .sort(sortById)
+ const sortedNotifications = notificationsFromStore(store).sort(sortById)
// TODO implement sorting elsewhere and make it optional
return sortedNotifications.filter((notification) =>
(types || visibleTypes(notificationVisibility)).includes(notification.type),
)
}
export const unseenNotificationsFromStore = (
store,
notificationVisibility,
ignoreInactionableSeen,
) => {
return filteredNotificationsFromStore(store, notificationVisibility).filter(
({ seen, type }) => {
if (!ignoreInactionableSeen) return !seen
if (seen) return false
return ACTIONABLE_NOTIFICATION_TYPES.has(type)
},
)
}
export const prepareNotificationObject = (notification, i18n) => {
if (cachedBadgeUrl === null) {
const favicons = FaviconService.getOriginalFavicons()
const favicon = favicons[favicons.length - 1]
if (!favicon) {
cachedBadgeUrl = 'about:blank'
} else {
cachedBadgeUrl = favicon.favimg.src
}
}
const notifObj = {
tag: notification.id,
type: notification.type,
badge: cachedBadgeUrl,
}
const status = notification.status
const title = notification.from_profile.name
notifObj.title = title
notifObj.icon = notification.from_profile.profile_image_url
let i18nString
switch (notification.type) {
case 'like':
i18nString = 'favorited_you'
break
case 'status':
i18nString = 'subscribed_status'
break
case 'repeat':
i18nString = 'repeated_you'
break
case 'follow':
i18nString = 'followed_you'
break
case 'move':
i18nString = 'migrated_to'
break
case 'follow_request':
i18nString = 'follow_request'
break
case 'pleroma:report':
i18nString = 'submitted_report'
break
case 'poll':
i18nString = 'poll_ended'
break
}
if (notification.type === 'pleroma:emoji_reaction') {
notifObj.body = i18n.t('notifications.reacted_with', [notification.emoji])
} else if (i18nString) {
notifObj.body = i18n.t('notifications.' + i18nString)
} else if (isStatusNotification(notification.type)) {
notifObj.body = notification.status.text
}
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
if (!status.nsfw && status?.attachments?.[0]?.mimetype.startsWith('image/')) {
notifObj.image = status.attachments[0].url
}
return notifObj
}
export const countExtraNotifications = (
store,
mergedConfig,
unreadChatsCount,
unreadAnnouncementCount,
) => {
const rootGetters = store.rootGetters || store.getters
if (!mergedConfig.showExtraNotifications) {
return 0
}
return [
mergedConfig.showChatsInExtraNotifications ? unreadChatsCount : 0,
mergedConfig.showAnnouncementsInExtraNotifications
? unreadAnnouncementCount
: 0,
mergedConfig.showFollowRequestsInExtraNotifications
? rootGetters.followRequestCount
: 0,
].reduce((a, c) => a + c, 0)
}
diff --git a/src/services/ruffle_service/ruffle_service.js b/src/services/ruffle_service/ruffle_service.js
index c029330aaa..298f32df3f 100644
--- a/src/services/ruffle_service/ruffle_service.js
+++ b/src/services/ruffle_service/ruffle_service.js
@@ -1,48 +1,48 @@
const createRuffleService = () => {
let ruffleInstance = null
const getRuffle = async () =>
new Promise((resolve, reject) => {
if (ruffleInstance) {
resolve(ruffleInstance)
return
}
// Ruffle needs these to be set before it's loaded
// https://github.com/ruffle-rs/ruffle/issues/3952
window.RufflePlayer = {}
window.RufflePlayer.config = {
polyfills: false,
publicPath: '/static/ruffle',
}
// Currently it's seems like a better way of loading ruffle
// because it needs the wasm publically accessible, but it needs path to it
// and filename of wasm seems to be pseudo-randomly generated (is it a hash?)
const script = document.createElement('script')
// see webpack config, using CopyPlugin to copy it from node_modules
// provided via ruffle-mirror
script.src = '/static/ruffle/ruffle.js'
script.type = 'text/javascript'
script.onerror = (e) => {
- reject(e)
+ reject(new Error('Ruffle script errorred', e))
}
script.onabort = (e) => {
- reject(e)
+ reject(new Error('Ruffle script aborted', e))
}
script.oncancel = (e) => {
- reject(e)
+ reject(new Error('Ruffle script cancelled', e))
}
script.onload = () => {
ruffleInstance = window.RufflePlayer
resolve(ruffleInstance)
}
document.body.appendChild(script)
})
return { getRuffle }
}
const RuffleService = createRuffleService()
export default RuffleService
diff --git a/src/services/status_parser/status_parser.js b/src/services/status_parser/status_parser.js
index a011fe2654..fc498f540e 100644
--- a/src/services/status_parser/status_parser.js
+++ b/src/services/status_parser/status_parser.js
@@ -1,92 +1,92 @@
export const muteFilterHits = (muteFilters, status) => {
const statusText = status.text.toLowerCase()
const statusSummary = status.summary.toLowerCase()
const replyToUser = status.in_reply_to_screen_name?.toLowerCase()
const poster = status.user.screen_name?.toLowerCase()
const mentions = (status.attentions || []).map((att) =>
att.screen_name.toLowerCase(),
)
return muteFilters
.toSorted((a, b) => b.order - a.order)
.map((filter) => {
const {
hide,
expires,
name,
value,
type,
enabled,
caseSensitive = false,
} = filter
if (!enabled) return false
if (value === '') return false
if (expires !== null && expires < Date.now()) return false
switch (type) {
case 'word': {
let match = false
if (caseSensitive) {
match = statusText.includes(value) || statusSummary.includes(value)
} else {
const lowercaseValue = value.toLowerCase()
match =
statusText.toLowerCase().includes(lowercaseValue) ||
statusSummary.toLowerCase().includes(lowercaseValue)
}
if (match) {
return { hide, name }
}
break
}
case 'regexp': {
try {
const re = new RegExp(value, caseSensitive ? '' : 'i')
if (re.test(statusText) || re.test(statusSummary)) {
return { hide, name }
}
return false
} catch {
return false
}
}
case 'user': {
let match = false
if (caseSensitive) {
match =
poster.includes(value) ||
replyToUser.includes(value) ||
mentions.some((mention) => mention.includes(value))
} else {
const lowercaseValue = value.toLowerCase()
match =
poster.toLowerCase().includes(lowercaseValue) ||
replyToUser.toLowerCase().includes(lowercaseValue) ||
mentions.some((mention) =>
mention.toLowerCase().includes(lowercaseValue),
)
}
if (match) {
return { hide, name }
}
break
}
case 'user_regexp': {
try {
const re = new RegExp(value, caseSensitive ? '' : 'i')
if (
re.test(poster) ||
re.test(replyToUser) ||
mentions.some((mention) => re.test(mention))
) {
return { hide, name }
}
return false
} catch {
return false
}
}
}
})
- .filter((_) => _)
+ .filter(Boolean)
}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index 4eed080c1f..318978264e 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,369 +1,367 @@
import sum from 'hash-sum'
import localforage from 'localforage'
import { chunk, throttle } from 'lodash'
import { getCssRules } from '../theme_data/css_utils.js'
import { getEngineChecksum, init } from '../theme_data/theme_data_3.service.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { promisedRequest } from 'src/api/helpers.js'
import { ROOT_CONFIG } from 'src/modules/default_config_state.js'
// On platforms where this is not supported, it will return undefined
// Otherwise it will return an array
const supportsAdoptedStyleSheets = !!document.adoptedStyleSheets
const stylesheets = {}
export const createStyleSheet = (id, priority = 1000) => {
if (stylesheets[id]) return stylesheets[id]
const newStyleSheet = {
rules: [],
ready: false,
priority,
clear() {
this.rules = []
},
addRule(rule) {
let newRule = rule
if (!CSS.supports?.('backdrop-filter', 'blur()')) {
newRule = newRule.replaceAll(/backdrop-filter:[^;]+;/g, '') // Remove backdrop-filter
}
if (newRule.startsWith('::-webkit')) {
if (typeof CSS.supports !== 'function') return
// firefox doesn't like invalid selectors
const fullWebkitScrollbarSupport =
CSS.supports('selector(::-webkit-scrollbar)') &&
CSS.supports('selector(::-webkit-scrollbar-button)') &&
CSS.supports('selector(::-webkit-resizer)') &&
CSS.supports('selector(::-webkit-scrollbar-thumb)')
if (!fullWebkitScrollbarSupport) return
}
this.rules.push(
newRule.replaceAll(/var\(--shadowFilter\)[^;]*;/g, ''), // Remove shadowFilter references
)
},
}
stylesheets[id] = newStyleSheet
return newStyleSheet
}
export const adoptStyleSheets = throttle(() => {
if (supportsAdoptedStyleSheets) {
document.adoptedStyleSheets = Object.values(stylesheets)
.filter((x) => x.ready)
.sort((a, b) => a.priority - b.priority)
.map((sheet) => {
const css = new CSSStyleSheet()
sheet.rules.forEach((r) => {
try {
css.insertRule(r)
} catch (e) {
console.warn('Error inserting rule:', e, r)
}
})
return css
})
} else {
const holder = document.getElementById('custom-styles-holder')
for (let i = holder.sheet.cssRules.length - 1; i >= 0; --i) {
holder.sheet.deleteRule(i)
}
Object.values(stylesheets)
.filter((x) => x.ready)
.sort((a, b) => a.priority - b.priority)
.forEach((sheet) => {
sheet.rules.forEach((r) => holder.sheet.insertRule(r))
})
}
// Some older browsers do not support document.adoptedStyleSheets.
// In this case, we use the <style> elements.
// Since the <style> elements we need are already in the DOM, there
// is nothing to do here.
}, 500)
const EAGER_STYLE_ID = 'pleroma-eager-styles'
const LAZY_STYLE_ID = 'pleroma-lazy-styles'
const generateTheme = (inputRuleset, callbacks, debug) => {
const {
onNewRule = () => {
/* no-op */
},
onLazyFinished = () => {
/* no-op */
},
onEagerFinished = () => {
/* no-op */
},
} = callbacks
const themes3 = init({
inputRuleset,
debug,
})
getCssRules(themes3.eager, debug).forEach((rule) => {
// Hacks to support multiple selectors on same component
onNewRule(rule, false)
})
onEagerFinished()
// Optimization - instead of processing all lazy rules in one go, process them in small chunks
// so that UI can do other things and be somewhat responsive while less important rules are being
// processed
let counter = 0
const chunks = chunk(themes3.lazy, 200)
// let t0 = performance.now()
const processChunk = () => {
const chunk = chunks[counter]
Promise.all(chunk.map((x) => x())).then((result) => {
getCssRules(result.filter(Boolean), debug).forEach((rule) => {
onNewRule(rule, true)
})
// const t1 = performance.now()
// console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms')
// t0 = t1
counter += 1
if (counter < chunks.length) {
setTimeout(processChunk, 0)
} else {
onLazyFinished()
}
})
}
return { lazyProcessFunc: processChunk }
}
export const tryLoadCache = async () => {
console.info('Trying to load compiled theme data from cache')
const cache = await localforage.getItem('pleromafe-theme-cache')
if (!cache) return null
try {
if (
cache.engineChecksum === getEngineChecksum() &&
cache.checksum !== undefined &&
cache.checksum === useMergedConfigStore().mergedConfig.themeChecksum
) {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
cache.data[0].forEach((rule) => eagerStyles.addRule(rule))
cache.data[1].forEach((rule) => lazyStyles.addRule(rule))
eagerStyles.ready = true
lazyStyles.ready = true
console.info(`Loaded theme from cache`)
return true
} else {
console.warn("Checksums don't match, cache not usable, clearing")
localStorage.removeItem('pleroma-fe-theme-cache')
}
} catch (e) {
console.error('Failed to load theme cache:', e)
return false
}
}
export const applyTheme = (
input,
onEagerFinish = () => {
/* no-op */
},
onFinish = () => {
/* no-op */
},
debug,
) => {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
eagerStyles.clear()
lazyStyles.clear()
const { lazyProcessFunc } = generateTheme(
input,
{
onNewRule(rule, isLazy) {
if (isLazy) {
lazyStyles.addRule(rule)
} else {
eagerStyles.addRule(rule)
}
},
onEagerFinished() {
eagerStyles.ready = true
adoptStyleSheets()
onEagerFinish()
console.info(
'Eager part of theme finished, waiting for lazy part to finish to store cache',
)
},
onLazyFinished() {
lazyStyles.ready = true
adoptStyleSheets()
const data = [eagerStyles.rules, lazyStyles.rules]
const checksum = sum(data)
const cache = {
checksum,
engineChecksum: getEngineChecksum(),
data,
}
useSyncConfigStore().setSimplePrefAndSave({
path: 'themeChecksum',
value: checksum,
})
onFinish(cache)
localforage.setItem('pleromafe-theme-cache', cache)
console.info('Theme cache stored')
},
},
debug,
)
setTimeout(lazyProcessFunc, 0)
}
const extractStyleConfig = ({
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
themeEditorMinWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
forcedRoundness,
}) => {
const result = {
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
themeEditorMinWidth:
Number.parseInt(themeEditorMinWidth) === 0
? 'fit-content'
: themeEditorMinWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
}
switch (forcedRoundness) {
case 'disable':
break
case '0':
result.forcedRoundness = '0'
break
case '1':
result.forcedRoundness = '1px'
break
case '2':
result.forcedRoundness = '0.4rem'
break
default:
}
return result
}
const defaultStyleConfig = extractStyleConfig(ROOT_CONFIG)
export const applyStyleConfig = (input) => {
const config = extractStyleConfig(input)
if (config === defaultStyleConfig) {
return
}
const rules = Object.entries(config)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}: ${v}`)
.join(';')
const styleSheet = createStyleSheet('theme-holder', 30)
styleSheet.clear()
styleSheet.addRule(`:root { ${rules} }`)
// TODO find a way to make this not apply to theme previews
if (Object.hasOwn(config, 'forcedRoundness')) {
styleSheet.addRule(` *:not(.preview-block) {
--roundness: var(--forcedRoundness) !important;
}`)
}
styleSheet.ready = true
adoptStyleSheets()
}
export const getResourcesIndex = async (url, parser = (x) => x) => {
const cache = 'no-store'
const customUrl = url.replace(/\.(\w+)$/, '.custom.$1')
let builtin
let custom
const resourceTransform = (resources) => {
return Object.entries(resources).map(([k, v]) => {
if (typeof v === 'object') {
- return [k, () => Promise.resolve(v)]
+ return [k, () => v]
} else if (typeof v === 'string') {
return [
k,
() =>
promisedRequest({
url: v,
cache,
})
.then(({ data: text }) => parser(text))
.catch((e) => {
console.error(e)
return null
}),
]
} else {
console.error(`Unknown resource format - ${k} is a ${typeof v}`)
return [k, null]
}
})
}
try {
const { data: builtinData } = await promisedRequest({ url, cache })
builtin = resourceTransform(builtinData)
} catch {
builtin = []
console.warn(`Builtin resources at ${url} unavailable`)
}
try {
const { data: customData } = await promisedRequest({
url: customUrl,
cache,
})
custom = resourceTransform(customData)
} catch {
custom = []
console.warn(`Custom resources at ${customUrl} unavailable`)
}
const total = [...custom, ...builtin]
if (total.length === 0) {
- return Promise.reject(
- new Error(
- `Resource at ${url} and ${customUrl} completely unavailable. Panicking`,
- ),
+ throw new Error(
+ `Resource at ${url} and ${customUrl} completely unavailable. Panicking`,
)
}
- return Promise.resolve(Object.fromEntries(total))
+ return Object.fromEntries(total)
}
diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js
index 2b7dabd41c..ef8200a4d4 100644
--- a/src/services/sw/sw.js
+++ b/src/services/sw/sw.js
@@ -1,189 +1,187 @@
/* global process */
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding)
.replaceAll('-', '+')
- .replace(/_/g, '/')
+ .replaceAll('_', '/')
const rawData = window.atob(base64)
return Uint8Array.from([...rawData].map((char) => char.codePointAt(0)))
}
export function isSWSupported() {
return 'serviceWorker' in navigator
}
function isPushSupported() {
return 'PushManager' in window
}
function getOrCreateServiceWorker() {
if (!isSWSupported()) return
const swType = process.env.HAS_MODULE_SERVICE_WORKER ? 'module' : 'classic'
return navigator.serviceWorker
.register('/sw-pleroma.js', { type: swType })
.catch((err) =>
console.error('Unable to get or create a service worker.', err),
)
}
function subscribePush(registration, isEnabled, vapidPublicKey) {
- if (!isEnabled)
- return Promise.reject(new Error('Web Push is disabled in config'))
- if (!vapidPublicKey)
- return Promise.reject(new Error('VAPID public key is not found'))
+ if (!isEnabled) throw new Error('Web Push is disabled in config')
+ if (!vapidPublicKey) throw new Error('VAPID public key is not found')
const subscribeOptions = {
userVisibleOnly: false,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
}
return registration.pushManager.subscribe(subscribeOptions)
}
-function unsubscribePush(registration) {
+async function unsubscribePush(registration) {
return registration.pushManager.getSubscription().then((subscription) => {
if (subscription === null) {
- return Promise.resolve('No subscription')
+ return 'No subscription'
}
return subscription.unsubscribe()
})
}
function deleteSubscriptionFromBackEnd(token) {
return fetch('/api/v1/push/subscription/', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}).then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response
})
}
function sendSubscriptionToBackEnd(
subscription,
token,
notificationVisibility,
) {
return window
.fetch('/api/v1/push/subscription/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
subscription,
data: {
alerts: {
follow: notificationVisibility.follows,
favourite: notificationVisibility.likes,
mention: notificationVisibility.mentions,
reblog: notificationVisibility.repeats,
move: notificationVisibility.moves,
},
},
}),
})
.then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response.json()
})
.then((responseData) => {
if (!responseData.id) throw new Error('Bad response from server.')
return responseData
})
}
export async function initServiceWorker(store) {
if (!isSWSupported()) return
await getOrCreateServiceWorker()
navigator.serviceWorker.addEventListener('message', (event) => {
const { dispatch } = store
const { type, ...rest } = event.data
switch (type) {
case 'notificationClicked':
dispatch('notificationClicked', { id: rest.id })
}
})
}
export async function showDesktopNotification(content) {
if (!isSWSupported) return
const { active: sw } =
(await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
sw.postMessage({ type: 'desktopNotification', content })
}
export async function closeDesktopNotification({ id }) {
if (!isSWSupported) return
const { active: sw } =
(await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
if (id >= 0) {
sw.postMessage({ type: 'desktopNotificationClose', content: { id } })
} else {
sw.postMessage({ type: 'desktopNotificationClose', content: { all: true } })
}
}
export async function updateFocus() {
if (!isSWSupported) return
const { active: sw } =
(await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
sw.postMessage({ type: 'updateFocus' })
}
export function registerPushNotifications(
isEnabled,
vapidPublicKey,
token,
notificationVisibility,
) {
if (isPushSupported()) {
getOrCreateServiceWorker()
.then((registration) =>
subscribePush(registration, isEnabled, vapidPublicKey),
)
.then((subscription) =>
sendSubscriptionToBackEnd(subscription, token, notificationVisibility),
)
.catch((e) =>
console.warn(`Failed to setup Web Push Notifications: ${e.message}`),
)
}
}
export function unregisterPushNotifications(token) {
if (isPushSupported()) {
getOrCreateServiceWorker()
.then((registration) => {
return unsubscribePush(registration).then((result) => [
registration,
result,
])
})
.then(([, unsubResult]) => {
if (unsubResult === 'No subscription') return
if (!unsubResult) {
console.warn("Push subscription cancellation wasn't successful")
}
return deleteSubscriptionFromBackEnd(token)
})
.catch((e) => {
console.warn(`Failed to disable Web Push Notifications: ${e.message}`)
})
}
}
export const shouldCache = process.env.NODE_ENV === 'production'
export const cacheKey = 'pleroma-fe'
export const emojiCacheKey = 'pleroma-fe-emoji'
export const clearCache = (key) => caches.delete(key)
export { getOrCreateServiceWorker }
diff --git a/src/services/theme_data/css_utils.js b/src/services/theme_data/css_utils.js
index 990db652c5..b728f68d2b 100644
--- a/src/services/theme_data/css_utils.js
+++ b/src/services/theme_data/css_utils.js
@@ -1,190 +1,190 @@
import { convert } from 'chromatism'
import { hex2rgb, rgba2css } from '../color_convert/color_convert.js'
export const getCssColorString = (color, alpha = 1) =>
rgba2css({ ...convert(color).rgb, a: alpha })
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([
getCssColorString(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([getCssColorString(shad.color, shad.alpha)])
.join(' '),
)
.map((_) => `drop-shadow(${_})`)
.join(' ')
)
}
// `debug` changes what backgrounds are used to "stacked" solid colors so you can see
// what theme engine "thinks" is actual background color is for purposes of text color
// generation and for when --stacked variable is used
export const getCssRules = (rules, debug) =>
rules
.map((rule) => {
let selector = rule.selector
if (!selector) {
selector = 'html'
}
const header = selector + ' {'
const footer = '}'
const virtualDirectives = Object.entries(rule.virtualDirectives || {})
.map(([k, v]) => {
return ' ' + k + ': ' + v
})
.join(';\n')
const directives = Object.entries(rule.directives)
.map(([k, v]) => {
switch (k) {
case 'roundness': {
return ' ' + ['--roundness: ' + v + 'px'].join(';\n ')
}
case 'shadow': {
if (!rule.dynamicVars.shadow) {
return ''
}
return (
' ' +
[
'--shadow: ' + getCssShadow(rule.dynamicVars.shadow),
'--shadowFilter: ' +
getCssShadowFilter(rule.dynamicVars.shadow),
'--shadowInset: ' +
getCssShadow(rule.dynamicVars.shadow, true),
].join(';\n ')
)
}
case 'background': {
if (debug) {
return `
--background: ${getCssColorString(rule.dynamicVars.stacked)};
background-color: ${getCssColorString(rule.dynamicVars.stacked)};
`
}
if (v === 'transparent') {
if (rule.component === 'Root') return null
return [
rule.directives.backgroundNoCssColor !== 'yes'
? 'background-color: ' + v
: '',
' --background: ' + v,
]
.filter(Boolean)
.join(';\n')
}
const color = getCssColorString(
rule.dynamicVars.background,
rule.directives.opacity,
)
const cssDirectives = ['--background: ' + color]
if (rule.directives.backgroundNoCssColor !== 'yes') {
cssDirectives.push('background-color: ' + color)
}
return cssDirectives.filter(Boolean).join(';\n')
}
case 'blur': {
const cssDirectives = []
if (rule.directives.opacity < 1) {
cssDirectives.push(`--backdrop-filter: blur(${v}) `)
if (rule.directives.backgroundNoCssColor !== 'yes') {
cssDirectives.push(`backdrop-filter: blur(${v}) `)
}
}
return cssDirectives.join(';\n')
}
case 'font': {
return 'font-family: ' + v
}
case 'textColor': {
if (rule.directives.textNoCssColor === 'yes') {
return ''
}
return 'color: ' + v
}
default:
if (k.startsWith('--')) {
const [type, value] = v.split('|').map((x) => x.trim())
switch (type) {
case 'color': {
const color = rule.dynamicVars[k]
if (typeof color === 'string') {
return k + ': ' + rgba2css(hex2rgb(color))
} else {
return k + ': ' + rgba2css(color)
}
}
case 'generic':
return k + ': ' + value
default:
return null
}
}
return null
}
})
.filter(Boolean)
.map((x) => ' ' + x + ';')
.join('\n')
return [
header,
directives,
rule.component === 'Text' &&
- rule.state.indexOf('faint') < 0 &&
+ !rule.state.includes('faint') &&
rule.directives.textNoCssColor !== 'yes'
? ' color: var(--text);'
: '',
virtualDirectives,
footer,
]
.filter(Boolean)
.join('\n')
})
.filter(Boolean)
export const getScopedVersion = (rules, newScope) => {
return rules.map((x) => {
if (x.startsWith('html')) {
return x.replace('html', newScope)
} else if (x.startsWith('#content')) {
return x.replace('#content', newScope)
} else {
return newScope + ' > ' + x
}
})
}
diff --git a/src/services/theme_data/iss_utils.js b/src/services/theme_data/iss_utils.js
index 67579a1187..d42da37800 100644
--- a/src/services/theme_data/iss_utils.js
+++ b/src/services/theme_data/iss_utils.js
@@ -1,216 +1,216 @@
import { sortBy } from 'lodash'
// "Unrolls" a tree structure of item: { parent: { ...item2, parent: { ...item3, parent: {...} } }}
// into an array [item2, item3] for iterating
export const unroll = (item) => {
const out = []
let currentParent = item
while (currentParent) {
out.push(currentParent)
currentParent = currentParent.parent
}
return out
}
// This gives you an array of arrays of all possible unique (i.e. order-insensitive) combinations
// Can only accept primitives. Duplicates are not supported and can cause unexpected behavior
export const getAllPossibleCombinations = (array) => {
const combos = [array.map((x) => [x])]
for (let comboSize = 2; comboSize <= array.length; comboSize++) {
const previous = combos[combos.length - 1]
const newCombos = previous.map((self) => {
const selfSet = new Set()
self.forEach((x) => selfSet.add(x))
const nonSelf = array.filter((x) => !selfSet.has(x))
return nonSelf.map((x) => [...self, x])
})
- const flatCombos = newCombos.reduce((acc, x) => [...acc, ...x], [])
+ const flatCombos = newCombos.flat()
const uniqueComboStrings = new Set()
const uniqueCombos = flatCombos.map(sortBy).filter((x) => {
if (uniqueComboStrings.has(x.join())) {
return false
} else {
uniqueComboStrings.add(x.join())
return true
}
})
combos.push(uniqueCombos)
}
- return combos.reduce((acc, x) => [...acc, ...x], [])
+ return combos.flat()
}
/**
* Converts rule, parents and their criteria into a CSS (or path if ignoreOutOfTreeSelector == true)
* selector.
*
* "path" here refers to "fake" selector that cannot be actually used in UI but is used for internal
* purposes
*
* @param {Object} components - object containing all components definitions
*
* @returns {Function}
* @param {Object} rule - rule in question to convert to CSS selector
* @param {boolean} ignoreOutOfTreeSelector - wthether to ignore aformentioned field in
* component definition and use selector
* @param {boolean} isParent - (mostly) internal argument used when recursing
*
* @returns {String} CSS selector (or path)
*/
export const genericRuleToSelector =
(components) => (rule, ignoreOutOfTreeSelector, liteMode, children) => {
const isParent = !!children
if (!rule && !isParent) return null
const component = components[rule.component]
const { states = {}, variants = {}, outOfTreeSelector } = component
const expand = (array = [], subArray = []) => {
if (array.length === 0) return subArray.map((x) => [x])
if (subArray.length === 0) return array.map((x) => [x])
return array
.map((a) => {
return subArray.map((b) => [a, b])
})
.flat()
}
let componentSelectors = Array.isArray(component.selector)
? component.selector
: [component.selector]
if (ignoreOutOfTreeSelector || liteMode)
componentSelectors = [componentSelectors[0]]
componentSelectors = componentSelectors.map((selector) => {
if (selector === ':root') {
return ''
} else if (isParent) {
return selector
} else {
if (outOfTreeSelector && !ignoreOutOfTreeSelector)
return outOfTreeSelector
return selector
}
})
const applicableVariantName = rule.variant || 'normal'
let variantSelectors = null
if (applicableVariantName !== 'normal') {
variantSelectors = variants[applicableVariantName]
} else {
variantSelectors = variants?.normal ?? ''
}
variantSelectors = Array.isArray(variantSelectors)
? variantSelectors
: [variantSelectors]
if (ignoreOutOfTreeSelector || liteMode)
variantSelectors = [variantSelectors[0]]
const applicableStates = (rule.state || []).filter((x) => x !== 'normal')
// const applicableStates = (rule.state || [])
const statesSelectors = applicableStates.map((state) => {
const selector = states[state] || ''
let arraySelector = Array.isArray(selector) ? selector : [selector]
if (ignoreOutOfTreeSelector || liteMode)
arraySelector = [arraySelector[0]]
return arraySelector
})
const statesSelectorsFlat = statesSelectors.reduce((acc, s) => {
return expand(acc, s).map((st) => st.join(''))
}, [])
const componentVariant = expand(componentSelectors, variantSelectors).map(
(cv) => cv.join(''),
)
const componentVariantStates = expand(
componentVariant,
statesSelectorsFlat,
).map((cvs) => cvs.join(''))
const selectors = expand(componentVariantStates, children).map((cvsc) =>
cvsc.join(' '),
)
/*
*/
if (rule.parent) {
return genericRuleToSelector(components)(
rule.parent,
ignoreOutOfTreeSelector,
liteMode,
selectors,
)
}
return selectors.join(', ').trim()
}
/**
* Check if combination matches
*
* @param {Object} criteria - criteria to match against
* @param {Object} subject - rule/combination to check match
* @param {boolean} strict - strict checking:
* By default every variant and state inherits from "normal" state/variant
* so when checking if combination matches, it WILL match against "normal"
* state/variant. In strict mode inheritance is ignored an "normal" does
* not match
*/
export const combinationsMatch = (criteria, subject, strict) => {
if (criteria.component !== subject.component) return false
// All variants inherit from normal
if (subject.variant !== 'normal' || strict) {
if (criteria.variant !== subject.variant) return false
}
// Subject states > 1 essentially means state is "normal" and therefore matches
if (subject.state.length > 1 || strict) {
const subjectStatesSet = new Set(subject.state)
const criteriaStatesSet = new Set(criteria.state)
const setsAreEqual =
[...criteriaStatesSet].every((state) => subjectStatesSet.has(state)) &&
[...subjectStatesSet].every((state) => criteriaStatesSet.has(state))
if (!setsAreEqual) return false
}
return true
}
/**
* Search for rule that matches `criteria` in set of rules
* meant to be used in a ruleset.filter() function
*
* @param {Object} criteria - criteria to search for
* @param {boolean} strict - whether search strictly or not (see combinationsMatch)
*
* @return function that returns true/false if subject matches
*/
export const findRules = (criteria, strict) => (subject) => {
// If we searching for "general" rules - ignore "specific" ones
if (criteria.parent === null && !!subject.parent) return false
if (!combinationsMatch(criteria, subject, strict)) return false
if (criteria.parent !== undefined && criteria.parent !== null) {
if (!subject.parent && !strict) return true
const pathCriteria = unroll(criteria)
const pathSubject = unroll(subject)
if (pathCriteria.length < pathSubject.length) return false
// Search: .a .b .c
// Matches: .a .b .c; .b .c; .c; .z .a .b .c
// Does not match .a .b .c .d, .a .b .e
for (let i = 0; i < pathCriteria.length; i++) {
const criteriaParent = pathCriteria[i]
const subjectParent = pathSubject[i]
if (!subjectParent) return true
if (!combinationsMatch(criteriaParent, subjectParent, strict))
return false
}
}
return true
}
// Pre-fills 'normal' state/variant if missing
export const normalizeCombination = (rule) => {
rule.variant = rule.variant ?? 'normal'
rule.state = [...new Set(['normal', ...(rule.state || [])])]
}
diff --git a/src/services/theme_data/theme2_to_theme3.js b/src/services/theme_data/theme2_to_theme3.js
index 5bab4818cc..8c3ac6ad2a 100644
--- a/src/services/theme_data/theme2_to_theme3.js
+++ b/src/services/theme_data/theme2_to_theme3.js
@@ -1,574 +1,584 @@
import { convert } from 'chromatism'
import allKeys from './theme2_keys'
// keys that are meant to be used globally, i.e. what's the rest of the theme is based upon.
export const basePaletteKeys = new Set([
'bg',
'fg',
'text',
'link',
'accent',
'cBlue',
'cRed',
'cGreen',
'cOrange',
'wallpaper',
])
export const fontsKeys = new Set(['interface', 'input', 'post', 'postCode'])
export const opacityKeys = new Set([
'alert',
'alertPopup',
'bg',
'border',
'btn',
'faint',
'input',
'panel',
'popover',
'profileTint',
'underlay',
])
export const shadowsKeys = new Set([
'panel',
'topBar',
'popup',
'avatar',
'avatarStatus',
'panelHeader',
'button',
'buttonHover',
'buttonPressed',
'input',
])
export const radiiKeys = new Set([
'btn',
'input',
'checkbox',
'panel',
'avatar',
'avatarAlt',
'tooltip',
'attachment',
'chatMessage',
])
// Keys that are not available in editor and never meant to be edited
export const hiddenKeys = new Set(['profileBg', 'profileTint'])
export const extendedBasePrefixes = [
'border',
'icon',
'highlight',
'lightText',
'popover',
'panel',
'topBar',
'tab',
'btn',
'input',
'selectedMenu',
'alert',
'alertPopup',
'badge',
'post',
'selectedPost', // wrong nomenclature
'poll',
'chatBg',
'chatMessage',
]
export const nonComponentPrefixes = new Set([
'border',
'icon',
'highlight',
'lightText',
'chatBg',
])
export const extendedBaseKeys = Object.fromEntries(
extendedBasePrefixes.map((prefix) => [
prefix,
allKeys.filter((k) => {
if (prefix === 'alert') {
return k.startsWith(prefix) && !k.startsWith('alertPopup')
}
return k.startsWith(prefix)
}),
]),
)
// Keysets that are only really used intermideately, i.e. to generate other colors
export const temporary = new Set(['', 'highlight'])
export const temporaryColors = {}
export const convertTheme2To3 = (data) => {
data.colors.accent = data.colors.accent || data.colors.link
data.colors.link = data.colors.link || data.colors.accent
const generateRoot = () => {
const directives = {}
basePaletteKeys.forEach((key) => {
directives['--' + key] = 'color | ' + convert(data.colors[key]).hex
})
return {
component: 'Root',
directives,
}
}
const convertOpacity = () => {
const newRules = []
Object.keys(data.opacity || {}).forEach((key) => {
if (!opacityKeys.has(key) || data.opacity[key] === undefined) return null
const originalOpacity = data.opacity[key]
const rule = { source: '2to3' }
switch (key) {
case 'alert':
rule.component = 'Alert'
break
case 'alertPopup':
rule.component = 'Alert'
rule.parent = { component: 'Popover' }
break
case 'bg':
rule.component = 'Panel'
break
case 'border':
rule.component = 'Border'
break
case 'btn':
rule.component = 'Button'
break
case 'faint':
rule.component = 'Text'
rule.state = ['faint']
break
case 'input':
rule.component = 'Input'
break
case 'panel':
rule.component = 'PanelHeader'
break
case 'popover':
rule.component = 'Popover'
break
case 'profileTint':
return null
case 'underlay':
rule.component = 'Underlay'
break
}
switch (key) {
case 'alert':
case 'alertPopup':
case 'bg':
case 'btn':
case 'input':
case 'panel':
case 'popover':
case 'underlay':
rule.directives = { opacity: originalOpacity }
break
case 'faint':
case 'border':
rule.directives = { textOpacity: originalOpacity }
break
}
newRules.push(rule)
if (rule.component === 'Button') {
- newRules.push({ ...rule, component: 'ScrollbarElement' })
- newRules.push({ ...rule, component: 'Tab' })
- newRules.push({
- ...rule,
- component: 'Tab',
- state: ['active'],
- directives: { opacity: 0 },
- })
+ newRules.push(
+ { ...rule, component: 'ScrollbarElement' },
+ { ...rule, component: 'Tab' },
+ {
+ ...rule,
+ component: 'Tab',
+ state: ['active'],
+ directives: { opacity: 0 },
+ },
+ )
}
if (rule.component === 'Panel') {
newRules.push({ ...rule, component: 'Post' })
}
})
return newRules
}
const convertRadii = () => {
const newRules = []
Object.keys(data.radii || {}).forEach((key) => {
if (!radiiKeys.has(key) || data.radii[key] === undefined) return null
const originalRadius = data.radii[key]
const rule = { source: '2to3' }
switch (key) {
case 'btn':
rule.component = 'Button'
break
case 'tab':
rule.component = 'Tab'
break
case 'input':
rule.component = 'Input'
break
case 'checkbox':
rule.component = 'Input'
rule.variant = 'checkbox'
break
case 'panel':
rule.component = 'Panel'
break
case 'avatar':
rule.component = 'Avatar'
break
case 'avatarAlt':
rule.component = 'Avatar'
rule.variant = 'compact'
break
case 'tooltip':
rule.component = 'Popover'
break
case 'ChatMessage':
rule.component = 'Button'
break
}
rule.directives = {
roundness: originalRadius,
}
newRules.push(rule)
if (rule.component === 'Button') {
- newRules.push({ ...rule, component: 'ScrollbarElement' })
- newRules.push({ ...rule, component: 'Tab' })
+ newRules.push(
+ { ...rule, component: 'ScrollbarElement' },
+ { ...rule, component: 'Tab' },
+ )
}
})
return newRules
}
const convertFonts = () => {
const newRules = []
Object.keys(data.fonts || {}).forEach((key) => {
if (!fontsKeys.has(key)) return
if (!data.fonts[key]) return
const originalFont = data.fonts[key]
const rule = { source: '2to3' }
switch (key) {
case 'interface':
case 'postCode':
rule.component = 'Root'
break
case 'input':
rule.component = 'Input'
break
case 'post':
rule.component = 'RichContent'
break
}
switch (key) {
case 'interface':
case 'input':
case 'post':
rule.directives = { '--font': 'generic | ' + originalFont }
break
case 'postCode':
rule.directives = { '--monoFont': 'generic | ' + originalFont }
newRules.push({ ...rule, component: 'RichContent' })
break
}
newRules.push(rule)
})
return newRules
}
const convertShadows = () => {
const newRules = []
Object.keys(data.shadows || {}).forEach((key) => {
if (!shadowsKeys.has(key)) return
const originalShadow = data.shadows[key]
const rule = { source: '2to3' }
switch (key) {
case 'panel':
rule.component = 'Panel'
break
case 'topBar':
rule.component = 'TopBar'
break
case 'popup':
rule.component = 'Popover'
break
case 'avatar':
rule.component = 'Avatar'
break
case 'avatarStatus':
rule.component = 'Avatar'
rule.parent = { component: 'Post' }
break
case 'panelHeader':
rule.component = 'PanelHeader'
break
case 'button':
rule.component = 'Button'
break
case 'buttonHover':
rule.component = 'Button'
rule.state = ['hover']
break
case 'buttonPressed':
rule.component = 'Button'
rule.state = ['pressed']
break
case 'input':
rule.component = 'Input'
break
}
rule.directives = {
shadow: originalShadow,
}
newRules.push(rule)
if (key === 'topBar') {
newRules.push({
...rule,
component: 'PanelHeader',
parent: { component: 'MobileDrawer' },
})
}
if (key === 'avatarStatus') {
newRules.push({ ...rule, parent: { component: 'Notification' } })
}
if (key === 'buttonPressed') {
- newRules.push({ ...rule, state: ['toggled'] })
- newRules.push({ ...rule, state: ['toggled', 'focus'] })
- newRules.push({ ...rule, state: ['pressed', 'focus'] })
- newRules.push({ ...rule, state: ['toggled', 'focus', 'hover'] })
- newRules.push({ ...rule, state: ['pressed', 'focus', 'hover'] })
+ newRules.push(
+ { ...rule, state: ['toggled'] },
+ { ...rule, state: ['toggled', 'focus'] },
+ { ...rule, state: ['pressed', 'focus'] },
+ { ...rule, state: ['toggled', 'focus', 'hover'] },
+ { ...rule, state: ['pressed', 'focus', 'hover'] },
+ )
}
if (rule.component === 'Button') {
- newRules.push({ ...rule, component: 'ScrollbarElement' })
- newRules.push({ ...rule, component: 'Tab' })
+ newRules.push(
+ { ...rule, component: 'ScrollbarElement' },
+ { ...rule, component: 'Tab' },
+ )
}
})
return newRules
}
const extendedRules = Object.entries(extendedBaseKeys).map(
([prefix, keys]) => {
if (nonComponentPrefixes.has(prefix)) return null
const rule = { source: '2to3' }
if (prefix === 'alertPopup') {
rule.component = 'Alert'
rule.parent = { component: 'Popover' }
} else if (prefix === 'selectedPost') {
rule.component = 'Post'
rule.state = ['selected']
} else if (prefix === 'selectedMenu') {
rule.component = 'MenuItem'
rule.state = ['hover']
} else if (prefix === 'chatMessageIncoming') {
rule.component = 'ChatMessage'
} else if (prefix === 'chatMessageOutgoing') {
rule.component = 'ChatMessage'
rule.variant = 'outgoing'
} else if (prefix === 'panel') {
rule.component = 'PanelHeader'
} else if (prefix === 'topBar') {
rule.component = 'TopBar'
} else if (prefix === 'chatMessage') {
rule.component = 'ChatMessage'
} else if (prefix === 'poll') {
rule.component = 'PollGraph'
} else if (prefix === 'btn') {
rule.component = 'Button'
} else {
rule.component = prefix[0].toUpperCase() + prefix.slice(1).toLowerCase()
}
return keys.map((key) => {
if (!data.colors[key]) return null
const leftoverKey = key.replace(prefix, '')
const parts = (leftoverKey || 'Bg').match(/[A-Z][a-z]*/g)
const last = parts.slice(-1)[0]
let newRule = { source: '2to3', directives: {} }
let variantArray = []
switch (last) {
case 'Text':
case 'Faint': // typo
case 'Link':
case 'Icon':
case 'Greentext':
case 'Cyantext':
case 'Border':
newRule.parent = rule
newRule.directives.textColor = data.colors[key]
variantArray = parts.slice(0, -1)
break
default:
newRule = { ...rule, directives: {} }
newRule.directives.background = data.colors[key]
variantArray = parts
break
}
if (last === 'Text' || last === 'Link') {
const secondLast = parts.slice(-2)[0]
if (secondLast === 'Light') {
return null // unsupported
} else if (secondLast === 'Faint') {
newRule.state = ['faint']
variantArray = parts.slice(0, -2)
}
}
switch (last) {
case 'Text':
case 'Link':
case 'Icon':
case 'Border':
newRule.component = last
break
case 'Greentext':
case 'Cyantext':
newRule.component = 'FunText'
newRule.variant = last.toLowerCase()
break
case 'Faint':
newRule.component = 'Text'
newRule.state = ['faint']
break
}
variantArray = variantArray.filter((x) => x !== 'Bg')
if (last === 'Link' && prefix === 'selectedPost') {
// selectedPost has typo - duplicate 'Post'
variantArray = variantArray.filter((x) => x !== 'Post')
}
if (prefix === 'popover' && variantArray[0] === 'Post') {
newRule.component = 'Post'
newRule.parent = { source: '2to3hack', component: 'Popover' }
variantArray = variantArray.filter((x) => x !== 'Post')
}
if (prefix === 'selectedMenu' && variantArray[0] === 'Popover') {
newRule.parent = { source: '2to3hack', component: 'Popover' }
variantArray = variantArray.filter((x) => x !== 'Popover')
}
switch (prefix) {
case 'btn':
case 'input':
case 'alert': {
const hasPanel = variantArray.find((x) => x === 'Panel')
if (hasPanel) {
newRule.parent = {
source: '2to3hack',
component: 'PanelHeader',
parent: newRule.parent,
}
variantArray = variantArray.filter((x) => x !== 'Panel')
}
const hasTop = variantArray.find((x) => x === 'Top') // TopBar
if (hasTop) {
newRule.parent = {
source: '2to3hack',
component: 'TopBar',
parent: newRule.parent,
}
variantArray = variantArray.filter(
(x) => x !== 'Top' && x !== 'Bar',
)
}
break
}
}
if (variantArray.length > 0) {
if (prefix === 'btn') {
newRule.state = variantArray.map((x) => x.toLowerCase())
} else {
newRule.variant = variantArray[0].toLowerCase()
}
}
if (newRule.component === 'Panel') {
return [newRule, { ...newRule, component: 'MobileDrawer' }]
} else if (newRule.component === 'Button') {
const rules = [
newRule,
{ ...newRule, component: 'Tab' },
{ ...newRule, component: 'ScrollbarElement' },
]
- if (newRule.state?.indexOf('toggled') >= 0) {
- rules.push({ ...newRule, state: [...newRule.state, 'focused'] })
- rules.push({ ...newRule, state: [...newRule.state, 'hover'] })
- rules.push({
- ...newRule,
- state: [...newRule.state, 'hover', 'focused'],
- })
+ if (newRule.state?.includes('toggled')) {
+ rules.push(
+ { ...newRule, state: [...newRule.state, 'focused'] },
+ { ...newRule, state: [...newRule.state, 'hover'] },
+ {
+ ...newRule,
+ state: [...newRule.state, 'hover', 'focused'],
+ },
+ )
}
- if (newRule.state?.indexOf('hover') >= 0) {
+ if (newRule.state?.includes('hover')) {
rules.push({ ...newRule, state: [...newRule.state, 'focused'] })
}
return rules
} else if (newRule.component === 'Badge') {
if (newRule.variant === 'notification') {
return [
newRule,
{
component: 'Root',
directives: {
'--badgeNotification':
'color | ' + newRule.directives.background,
},
},
]
} else if (newRule.variant === 'neutral') {
return [{ ...newRule, variant: 'normal' }]
} else {
return [newRule]
}
} else if (newRule.component === 'TopBar') {
return [
newRule,
{
...newRule,
parent: { component: 'MobileDrawer' },
component: 'PanelHeader',
},
]
} else {
return [newRule]
}
})
},
)
const flatExtRules = extendedRules
.filter(Boolean)
- .reduce((acc, x) => [...acc, ...x], [])
+ .flat()
.filter(Boolean)
- .reduce((acc, x) => [...acc, ...x], [])
+ .flat()
return [
generateRoot(),
...convertShadows(),
...convertRadii(),
...convertOpacity(),
...convertFonts(),
...flatExtRules,
]
}
diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js
index 626d7daeb6..694e41e12f 100644
--- a/src/services/theme_data/theme_data_3.service.js
+++ b/src/services/theme_data/theme_data_3.service.js
@@ -1,780 +1,780 @@
import { brightness, convert } from 'chromatism'
import sum from 'hash-sum'
import { flattenDeep, sortBy } from 'lodash'
import {
alphaBlend,
getTextColor,
mixrgb,
relativeLuminance,
rgba2css,
} from '../color_convert/color_convert.js'
import { deserializeShadow } from './iss_deserializer.js'
import {
findRules,
genericRuleToSelector,
getAllPossibleCombinations,
normalizeCombination,
unroll,
} from './iss_utils.js'
import {
colorFunctions,
process,
shadowFunctions,
} from './theme3_slot_functions.js'
// Ensuring the order of components
const components = {
Root: null,
Text: null,
FunText: null,
Link: null,
Icon: null,
Border: null,
PanelHeader: null,
Panel: null,
Chat: null,
ChatMessage: null,
Button: null,
}
export const findShadow = (shadows, { dynamicVars, staticVars }) => {
return (shadows || []).map((shadow) => {
let targetShadow
if (typeof shadow === 'string') {
if (shadow.startsWith('$')) {
targetShadow = process(
shadow,
shadowFunctions,
{ findColor, findShadow },
{ dynamicVars, staticVars },
)
} else if (shadow.startsWith('--')) {
// modifiers are completely unsupported here
const variableSlot = shadow.substring(2)
return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
} else {
targetShadow = deserializeShadow(shadow)
}
} else {
targetShadow = shadow
}
const shadowArray = Array.isArray(targetShadow)
? targetShadow
: [targetShadow]
return shadowArray.map((s) => ({
...s,
color: findColor(s.color, { dynamicVars, staticVars }),
}))
})
}
export const findColor = (color, { dynamicVars, staticVars }) => {
try {
if (
typeof color !== 'string' ||
(!color.startsWith('--') && !color.startsWith('$'))
)
return color
let targetColor = null
if (color.startsWith('--')) {
// Modifier support is pretty much for v2 themes only
const [variable, modifier] = color.split(/,/g).map((str) => str.trim())
const variableSlot = variable.substring(2)
if (variableSlot === 'stack') {
const { r, g, b } = dynamicVars.stacked
targetColor = { r, g, b }
} else if (variableSlot.startsWith('parent')) {
if (variableSlot === 'parent') {
const { r, g, b } = dynamicVars.lowerLevelBackground ?? {}
targetColor = { r, g, b }
} else {
const virtualSlot = variableSlot.replace(/^parent/, '')
targetColor = convert(
dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot],
).rgb
}
} else {
const staticVar = staticVars[variableSlot]
const dynamicVar = dynamicVars[variableSlot]
if (!staticVar && !dynamicVar) {
console.warn(
`Couldn't find variable "${variableSlot}", falling back to magenta. Variables are:
Static:
${JSON.stringify(staticVars, null, 2)}
Dynamic:
${JSON.stringify(dynamicVars, null, 2)}`,
dynamicVars,
variableSlot,
dynamicVars[variableSlot],
)
}
targetColor = convert(staticVar ?? dynamicVar ?? '#FF00FF').rgb
}
if (modifier) {
const effectiveBackground =
dynamicVars.lowerLevelBackground ?? targetColor
const isLightOnDark =
relativeLuminance(convert(effectiveBackground).rgb) < 0.5
const mod = isLightOnDark ? 1 : -1
targetColor = brightness(
Number.parseFloat(modifier) * mod,
targetColor,
).rgb
}
}
if (color.startsWith('$')) {
try {
targetColor = process(
color,
colorFunctions,
{ findColor },
{ dynamicVars, staticVars },
)
} catch (e) {
console.error(
'Failure executing color function',
e,
'\n Function: ' + color,
)
targetColor = '#FF00FF'
}
}
// Color references other color
return targetColor
} catch (e) {
throw new Error(`Couldn't find color "${color}", variables are:
Static:
${JSON.stringify(staticVars, null, 2)}
Dynamic:
${JSON.stringify(dynamicVars, null, 2)}\nError: ${e}`)
}
}
const getTextColorAlpha = (
directives,
intendedTextColor,
dynamicVars,
staticVars,
) => {
const opacity = directives.textOpacity
const backgroundColor = convert(dynamicVars.lowerLevelBackground).rgb
const textColor = convert(
findColor(intendedTextColor, { dynamicVars, staticVars }),
).rgb
if (opacity === null || opacity === undefined || opacity >= 1) {
return convert(textColor).hex
}
if (opacity === 0) {
return convert(backgroundColor).hex
}
const opacityMode = directives.textOpacityMode
switch (opacityMode) {
case 'fake':
return convert(alphaBlend(textColor, opacity, backgroundColor)).hex
case 'mixrgb':
return convert(mixrgb(backgroundColor, textColor)).hex
default:
return rgba2css({ a: opacity, ...textColor })
}
}
// Loading all style.js[on] files dynamically
const componentsContext = import.meta.glob(
['/src/**/*.style.js', '/src/**/*.style.json'],
{ eager: true },
)
Object.keys(componentsContext).forEach((key) => {
const component = componentsContext[key].default
if (components[component.name] != null) {
console.warn(
`Component in file ${key} is trying to override existing component ${component.name}! You have collisions/duplicates!`,
)
}
components[component.name] = component
})
Object.keys(components).forEach((key) => {
if (key === 'Root') return
components.Root.validInnerComponents =
components.Root.validInnerComponents || []
components.Root.validInnerComponents.push(key)
})
Object.keys(components).forEach((key) => {
const component = components[key]
const { validInnerComponents = [] } = component
validInnerComponents.forEach((inner) => {
const child = components[inner]
component.possibleChildren = component.possibleChildren || []
component.possibleChildren.push(child)
child.possibleParents = child.possibleParents || []
child.possibleParents.push(component)
})
})
const engineChecksum = sum(components)
const ruleToSelector = genericRuleToSelector(components)
export const getEngineChecksum = () => engineChecksum
/**
* Initializes and compiles the theme according to the ruleset
*
* @param {Object[]} inputRuleset - set of rules to compile theme against. Acts as an override to
* component default rulesets
* @param {string} ultimateBackgroundColor - Color that will be the "final" background for
* calculating contrast ratios and making text automatically accessible. Really used for cases when
* stuff is transparent.
* @param {boolean} debug - print out debug information in console, mostly just performance stuff
* @param {boolean} liteMode - use validInnerComponentsLite instead of validInnerComponents, meant to
* generatate theme previews and such that need to be compiled faster and don't require a lot of other
* components present in "normal" mode
* @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme
* previews since states are the biggest factor for compilation time and are completely unnecessary
* when previewing multiple themes at same time
*/
export const init = ({
inputRuleset,
ultimateBackgroundColor,
debug = false,
liteMode = false,
editMode = false,
onlyNormalState = false,
initialStaticVars = {},
}) => {
const rootComponentName = 'Root'
if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
const staticVars = { ...initialStaticVars }
const stacked = {}
const computed = {}
const rulesetUnsorted = [
...Object.values(components)
.map((c) =>
(c.defaultRules || []).map((r) => ({
source: 'Built-in',
component: c.name,
...r,
})),
)
- .reduce((acc, arr) => [...acc, ...arr], []),
+ .flat(),
...inputRuleset,
].map((rule) => {
normalizeCombination(rule)
let currentParent = rule.parent
while (currentParent) {
normalizeCombination(currentParent)
currentParent = currentParent.parent
}
return rule
})
const ruleset = rulesetUnsorted
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
const parentsA = unroll(a).length
const parentsB = unroll(b).length
let aScore = 0
let bScore = 0
aScore += parentsA * 1000
bScore += parentsB * 1000
aScore += a.variant !== 'normal' ? 100 : 0
bScore += b.variant !== 'normal' ? 100 : 0
aScore += a.state.filter((x) => x !== 'normal').length * 1000
bScore += b.state.filter((x) => x !== 'normal').length * 1000
aScore += a.component === 'Text' ? 1 : 0
bScore += b.component === 'Text' ? 1 : 0
// Debug
a._specificityScore = aScore
b._specificityScore = bScore
if (aScore === bScore) {
return ai - bi
}
return aScore - bScore
})
.map(({ data }) => data)
if (!ultimateBackgroundColor) {
console.warn(
'No ultimate background color provided, falling back to panel color',
)
const rootRule = ruleset.findLast(
(x) => x.component === 'Root' && x.directives?.['--bg'],
)
ultimateBackgroundColor = rootRule.directives['--bg'].split('|')[1].trim()
}
const virtualComponents = new Set(
Object.values(components)
.filter((c) => c.virtual)
.map((c) => c.name),
)
const transparentComponents = new Set(
Object.values(components)
.filter((c) => c.transparent)
.map((c) => c.name),
)
const nonEditableComponents = new Set(
Object.values(components)
.filter((c) => c.notEditable)
.map((c) => c.name),
)
const extraCompileComponents = new Set([])
Object.values(components).forEach((component) => {
const relevantRules = ruleset.filter((r) => r.component === component.name)
const backgrounds = relevantRules
.map((r) => r.directives.background)
.filter(Boolean)
const opacities = relevantRules
.map((r) => r.directives.opacity)
.filter(Boolean)
if (
backgrounds.some((x) => x.match(/--parent/)) ||
opacities.some((x) => x != null && x < 1)
) {
extraCompileComponents.add(component.name)
}
})
const processCombination = (combination) => {
try {
const selector = ruleToSelector(combination, true)
const cssSelector = ruleToSelector(combination)
const parentSelector = selector.split(/ /g).slice(0, -1).join(' ')
const soloSelector = selector.split(/ /g).slice(-1)[0]
const lowerLevelSelector = parentSelector
let lowerLevelBackground = computed[lowerLevelSelector]?.background
if (editMode && !lowerLevelBackground) {
// FIXME hack for editor until it supports handling component backgrounds
lowerLevelBackground = '#00FFFF'
}
const lowerLevelVirtualDirectives =
computed[lowerLevelSelector]?.virtualDirectives
const lowerLevelVirtualDirectivesRaw =
computed[lowerLevelSelector]?.virtualDirectivesRaw
const dynamicVars = computed[selector] || {
lowerLevelSelector,
lowerLevelBackground,
lowerLevelVirtualDirectives,
lowerLevelVirtualDirectivesRaw,
}
// Inheriting all of the applicable rules
const existingRules = ruleset.filter(findRules(combination))
const computedDirectives = existingRules
.map((r) => r.directives)
.reduce((acc, directives) => ({ ...acc, ...directives }), {})
const computedRule = {
...combination,
directives: computedDirectives,
}
computed[selector] = computed[selector] || {}
computed[selector].computedRule = computedRule
computed[selector].dynamicVars = dynamicVars
// avoid putting more stuff into actual CSS
computed[selector].virtualDirectives = {}
// but still be able to access it i.e. from --parent
computed[selector].virtualDirectivesRaw =
computed[lowerLevelSelector]?.virtualDirectivesRaw || {}
if (virtualComponents.has(combination.component)) {
const virtualName = [
'--',
combination.component.toLowerCase(),
combination.variant === 'normal'
? ''
: combination.variant[0].toUpperCase() +
combination.variant.slice(1).toLowerCase(),
...sortBy(combination.state.filter((x) => x !== 'normal')).map(
(state) => state[0].toUpperCase() + state.slice(1).toLowerCase(),
),
].join('')
let inheritedTextColor = computedDirectives.textColor
let inheritedTextAuto = computedDirectives.textAuto
let inheritedTextOpacity = computedDirectives.textOpacity
let inheritedTextOpacityMode = computedDirectives.textOpacityMode
const lowerLevelTextSelector = [
...selector.split(/ /g).slice(0, -1),
soloSelector,
].join(' ')
const lowerLevelTextRule = computed[lowerLevelTextSelector]
if (
inheritedTextColor == null ||
inheritedTextOpacity == null ||
inheritedTextOpacityMode == null
) {
inheritedTextColor =
computedDirectives.textColor ?? lowerLevelTextRule.textColor
inheritedTextAuto =
computedDirectives.textAuto ?? lowerLevelTextRule.textAuto
inheritedTextOpacity =
computedDirectives.textOpacity ?? lowerLevelTextRule.textOpacity
inheritedTextOpacityMode =
computedDirectives.textOpacityMode ??
lowerLevelTextRule.textOpacityMode
}
const newTextRule = {
...computedRule,
directives: {
...computedRule.directives,
textColor: inheritedTextColor,
textAuto: inheritedTextAuto ?? 'preserve',
textOpacity: inheritedTextOpacity,
textOpacityMode: inheritedTextOpacityMode,
},
}
dynamicVars.inheritedBackground = lowerLevelBackground
dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb
const intendedTextColor = convert(
findColor(inheritedTextColor, { dynamicVars, staticVars }),
).rgb
const textColor =
newTextRule.directives.textAuto === 'no-auto'
? intendedTextColor
: getTextColor(
convert(stacked[lowerLevelSelector]).rgb,
intendedTextColor,
newTextRule.directives.textAuto === 'preserve',
)
const virtualDirectives = {
...(computed[lowerLevelSelector].virtualDirectives || {}),
}
const virtualDirectivesRaw = {
...(computed[lowerLevelSelector].virtualDirectivesRaw || {}),
}
// Storing color data in lower layer to use as custom css properties
virtualDirectives[virtualName] = getTextColorAlpha(
newTextRule.directives,
textColor,
dynamicVars,
)
virtualDirectivesRaw[virtualName] = textColor
computed[lowerLevelSelector].virtualDirectives = virtualDirectives
computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw
return {
dynamicVars,
selector: cssSelector.split(/ /g).slice(0, -1).join(' '),
...combination,
directives: {},
virtualDirectives,
virtualDirectivesRaw,
}
} else {
computed[selector] = computed[selector] || {}
// TODO: DEFAULT TEXT COLOR
const lowerLevelStackedBackground =
stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb
if (computedDirectives.background) {
let inheritRule = null
const variantRules = ruleset.filter(
findRules({
component: combination.component,
variant: combination.variant,
parent: combination.parent,
}),
)
const lastVariantRule = variantRules[variantRules.length - 1]
const lastVariantSelector = ruleToSelector(lastVariantRule, true)
if (lastVariantRule && lastVariantSelector !== selector) {
inheritRule = lastVariantRule
} else {
const normalRules = ruleset.filter(
findRules({
component: combination.component,
parent: combination.parent,
}),
)
const lastNormalRule = normalRules[normalRules.length - 1]
inheritRule = lastNormalRule
}
const inheritSelector = ruleToSelector(
{ ...inheritRule, parent: combination.parent },
true,
)
const inheritedBackground = computed[inheritSelector].background
dynamicVars.inheritedBackground = inheritedBackground
const rgb = convert(
findColor(computedDirectives.background, {
dynamicVars,
staticVars,
}),
).rgb
if (!stacked[selector]) {
let blend
const alpha = computedDirectives.opacity ?? 1
if (alpha >= 1) {
blend = rgb
} else if (alpha <= 0) {
blend = lowerLevelStackedBackground
} else {
blend = alphaBlend(
rgb,
computedDirectives.opacity,
lowerLevelStackedBackground,
)
}
stacked[selector] = blend
computed[selector].background = {
...rgb,
a: computedDirectives.opacity ?? 1,
}
}
}
if (computedDirectives.shadow) {
dynamicVars.shadow = flattenDeep(
findShadow(flattenDeep(computedDirectives.shadow), {
dynamicVars,
staticVars,
}),
)
}
if (!stacked[selector]) {
computedDirectives.background = 'transparent'
computedDirectives.opacity = 0
stacked[selector] = lowerLevelStackedBackground
computed[selector].background = {
...lowerLevelStackedBackground,
a: 0,
}
}
dynamicVars.stacked = stacked[selector]
dynamicVars.background = computed[selector].background
const dynamicSlots = Object.entries(computedDirectives).filter(([k]) =>
k.startsWith('--'),
)
dynamicSlots.forEach(([k, v]) => {
const [type, value] = v.split('|').map((x) => x.trim()) // woah, Extreme!
switch (type) {
case 'color': {
const color = findColor(value, { dynamicVars, staticVars })
dynamicVars[k] = color
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = color
}
break
}
case 'shadow': {
const shadow = value
.split(/,/g)
.map((s) => s.trim())
.filter(Boolean)
dynamicVars[k] = shadow
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = shadow
}
break
}
case 'generic': {
dynamicVars[k] = value
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = value
}
break
}
}
})
const rule = {
dynamicVars,
selector: cssSelector,
...combination,
directives: computedDirectives,
}
return rule
}
} catch (e) {
const { component, variant, state } = combination
throw new Error(
`Error processing combination ${component}.${variant}:${state.join(':')}: ${e}`,
)
}
}
const processInnerComponent = (component, parent) => {
const combinations = []
const { states: originalStates = {}, variants: originalVariants = {} } =
component
let validInnerComponents
if (editMode) {
const temp =
component.validInnerComponentsLite ||
component.validInnerComponents ||
[]
validInnerComponents = temp.filter(
(c) => virtualComponents.has(c) && !nonEditableComponents.has(c),
)
} else if (liteMode) {
validInnerComponents =
component.validInnerComponentsLite ||
component.validInnerComponents ||
[]
} else if (
component.name === 'Root' ||
component.states != null ||
component.background?.includes('--parent')
) {
validInnerComponents = component.validInnerComponents || []
} else {
validInnerComponents =
component.validInnerComponents?.filter(
(c) =>
virtualComponents.has(c) ||
transparentComponents.has(c) ||
extraCompileComponents.has(c),
) || []
}
// Normalizing states and variants to always include "normal"
const states = { normal: '', ...originalStates }
const variants = { normal: '', ...originalVariants }
const innerComponents = validInnerComponents.map((name) => {
const result = components[name]
if (result === undefined)
console.error(
`Component ${component.name} references a component ${name} which does not exist!`,
)
return result
})
// Optimization: we only really need combinations without "normal" because all states implicitly have it
const permutationStateKeys = Object.keys(states).filter(
(s) => s !== 'normal',
)
const stateCombinations =
onlyNormalState && !virtualComponents.has(component.name)
? [['normal']]
: [
['normal'],
...getAllPossibleCombinations(permutationStateKeys)
.map((combination) => ['normal', ...combination])
.filter((combo) => {
// Optimization: filter out some hard-coded combinations that don't make sense
- if (combo.indexOf('disabled') >= 0) {
+ if (combo.includes('disabled')) {
return !(
- combo.indexOf('hover') >= 0 ||
- combo.indexOf('focused') >= 0 ||
- combo.indexOf('pressed') >= 0
+ combo.includes('hover') ||
+ combo.includes('focused') ||
+ combo.includes('pressed')
)
}
return true
}),
]
const stateVariantCombination = Object.keys(variants)
.map((variant) => {
return stateCombinations.map((state) => ({ variant, state }))
})
- .reduce((acc, x) => [...acc, ...x], [])
+ .flat()
stateVariantCombination.forEach((combination) => {
combination.component = component.name
combination.lazy = component.lazy || parent?.lazy
combination.parent = parent
- if (!liteMode && combination.state.indexOf('hover') >= 0) {
+ if (!liteMode && combination.state.includes('hover')) {
combination.lazy = true
}
if (
!liteMode &&
parent?.component !== 'Root' &&
!virtualComponents.has(component.name) &&
!transparentComponents.has(component.name) &&
extraCompileComponents.has(component.name)
) {
combination.lazy = true
}
combinations.push(combination)
innerComponents.forEach((innerComponent) => {
combinations.push(...processInnerComponent(innerComponent, combination))
})
})
return combinations
}
const t0 = performance.now()
const combinations = processInnerComponent(
components[rootComponentName] ?? components.Root,
)
const t1 = performance.now()
if (debug) {
console.debug('Tree traveral took ' + (t1 - t0) + ' ms')
}
const result = combinations
.map((combination) => {
if (combination.lazy) {
return async () => processCombination(combination)
} else {
return processCombination(combination)
}
})
.filter(Boolean)
const t2 = performance.now()
if (debug) {
console.debug('Eager processing took ' + (t2 - t1) + ' ms')
}
// optimization to traverse big-ass array only once instead of twice
const eager = []
const lazy = []
result.forEach((x) => {
if (typeof x === 'function') {
lazy.push(x)
} else {
eager.push(x)
}
})
return {
lazy,
eager,
staticVars,
engineChecksum,
themeChecksum: sum([lazy, eager]),
}
}
diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js
index e3f94ea1aa..abd0d96332 100644
--- a/src/services/user_highlighter/user_highlighter.js
+++ b/src/services/user_highlighter/user_highlighter.js
@@ -1,54 +1,54 @@
import { hex2rgb } from '../color_convert/color_convert.js'
const highlightStyle = (prefs) => {
if (prefs === undefined) return
const { color = '#FFFFFF', type } = prefs
if (typeof color !== 'string') return
const rgb = hex2rgb(color)
if (rgb == null) return
const solidColor = `rgb(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)})`
const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .1)`
const tintColor2 = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .2)`
const customProps = {
'--____highlight-solidColor': solidColor,
'--____highlight-tintColor': tintColor,
'--____highlight-tintColor2': tintColor2,
}
if (type === 'striped') {
return {
backgroundImage: [
'repeating-linear-gradient(135deg,',
`${tintColor} ,`,
`${tintColor} 20px,`,
`${tintColor2} 20px,`,
`${tintColor2} 40px`,
].join(' '),
backgroundPosition: '0 0',
...customProps,
}
} else if (type === 'solid') {
return {
backgroundColor: tintColor2,
...customProps,
}
} else if (type === 'side') {
return {
backgroundImage: [
'linear-gradient(to right,',
`${solidColor} ,`,
`${solidColor} 2px,`,
'transparent 6px',
].join(' '),
backgroundPosition: '0 0',
...customProps,
}
}
}
const highlightClass = (user) => {
return (
- 'USER____' + user.screen_name?.replaceAll('.', '_').replace(/@/g, '_AT_')
+ 'USER____' + user.screen_name?.replaceAll('.', '_').replaceAll('@', '_AT_')
)
}
export { highlightClass, highlightStyle }
diff --git a/src/stores/chats.js b/src/stores/chats.js
index 7908da9c8f..5ade0c126c 100644
--- a/src/stores/chats.js
+++ b/src/stores/chats.js
@@ -1,113 +1,113 @@
import { find, omitBy, orderBy, sumBy } from 'lodash'
import { defineStore } from 'pinia'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import { promiseInterval } from '../services/promise_interval/promise_interval.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { chats } from 'src/api/chats.js'
const emptyChatList = () => ({
data: [],
idStore: {},
})
const defaultState = {
chatList: emptyChatList(),
chatListFetcher: null,
}
const getChatById = (state, id) => {
return find(state.chatList.data, { id })
}
export const useChatsStore = defineStore('chats', {
state: () => ({ ...defaultState }),
getters: {
sortedChatList(state) {
return orderBy(state.chatList.data, ['updated_at'], ['desc'])
},
unreadChatsCount(state) {
return sumBy(state.chatList.data, 'unread')
},
},
actions: {
startFetchingChats() {
const fetcher = () => this.fetchChats()
this.setChatListFetcher(() => promiseInterval(fetcher, 5000))
},
stopFetchingChats() {
this.setChatListFetcher(null)
},
async fetchChats() {
const { data } = await chats({
credentials: useOAuthStore().token,
})
this.addNewChats(data)
},
setChatListFetcher(fetcher) {
const prevFetcher = this.chatListFetcher
if (prevFetcher) {
prevFetcher.stop()
}
this.chatListFetcher = fetcher?.()
},
resetChats() {
this.chatList = emptyChatList()
this.setChatListFetcher(null)
},
addNewChats(chats) {
window.vuex.commit(
'addNewUsers',
- chats.map((k) => k.account).filter((k) => k),
+ chats.map((k) => k.account).filter(Boolean),
)
chats.forEach((updatedChat) => {
const chat = getChatById(this, updatedChat.id)
if (chat) {
const isNewMessage =
chat.lastMessage?.id !== updatedChat.lastMessage?.id
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
chat.updated_at = updatedChat.updated_at
if (isNewMessage && chat.unread) {
maybeShowChatNotification(chat)
}
} else {
this.chatList.data.push(updatedChat)
this.chatList.idStore[updatedChat.id] = updatedChat
}
})
},
readChat(id) {
const chat = getChatById(this, id)
if (chat) {
chat.unread = 0
}
},
updateChat({ chat: updatedChat }) {
const chat = getChatById(this, updatedChat.id)
if (chat) {
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
chat.updated_at = updatedChat.updated_at
}
if (!chat) {
this.chatList.data.unshift(updatedChat)
}
this.chatList.idStore[updatedChat.id] = updatedChat
},
deleteChat(id) {
this.chats.data = this.chats.data.filter(
(conversation) => conversation.last_status.id !== id,
)
this.chats.idStore = omitBy(
this.chats.idStore,
(conversation) => conversation.last_status.id === id,
)
},
},
})
diff --git a/src/stores/emoji.js b/src/stores/emoji.js
index 2d77954f13..8135a483e5 100644
--- a/src/stores/emoji.js
+++ b/src/stores/emoji.js
@@ -1,321 +1,317 @@
import { merge } from 'lodash'
import { defineStore } from 'pinia'
import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { listEmojiPacks } from 'src/api/public.js'
import { ensureFinalFallback } from 'src/i18n/languages.js'
import { annotationsLoader } from 'virtual:pleroma-fe/emoji-annotations'
const defaultState = {
// Custom emoji from server
customEmoji: [],
customEmojiFetched: false,
adminPacksLocal: null,
adminPacksLocalLoading: true,
// Unicode emoji from bundle
emoji: {},
emojiFetched: false,
unicodeEmojiAnnotations: {},
// Stickers
stickers: null,
}
const SORTED_EMOJI_GROUP_IDS = [
'smileys-and-emotion',
'people-and-body',
'animals-and-nature',
'food-and-drink',
'travel-and-places',
'activities',
'objects',
'symbols',
'flags',
]
const REGIONAL_INDICATORS = (() => {
const start = 0x1f1e6
const end = 0x1f1ff
const A = 'A'.codePointAt(0)
const res = new Array(end - start + 1)
for (let i = start; i <= end; ++i) {
const letter = String.fromCodePoint(A + i - start)
res[i - start] = {
replacement: String.fromCodePoint(i),
imageUrl: false,
displayText: 'regional_indicator_' + letter,
displayTextI18n: {
key: 'emoji.regional_indicator',
args: { letter },
},
}
}
return res
})()
const loadAnnotations = (lang) => {
return annotationsLoader[lang]().then((k) => k.default)
}
const injectAnnotations = (emoji, annotations) => {
const availableLangs = Object.keys(annotations)
return {
...emoji,
annotations: availableLangs.reduce((acc, cur) => {
acc[cur] = annotations[cur][emoji.replacement]
return acc
}, {}),
}
}
const injectRegionalIndicators = (groups) => {
groups.symbols.push(...REGIONAL_INDICATORS)
return groups
}
export const useEmojiStore = defineStore('emoji', {
state: () => ({ ...defaultState }),
getters: {
groupedCustomEmojis(state) {
const packsOf = (emoji) => {
const packs = emoji.tags
.filter((k) => k.startsWith('pack:'))
.map((k) => {
const packName = k.slice(5) // remove 'pack:' prefix
return {
id: `custom-${packName}`,
text: packName,
}
})
if (!packs.length) {
return [
{
id: 'unpacked',
},
]
} else {
return packs
}
}
return this.customEmoji.reduce((res, emoji) => {
packsOf(emoji).forEach(({ id: packId, text: packName }) => {
if (!res[packId]) {
res[packId] = {
id: packId,
text: packName,
image: emoji.imageUrl,
emojis: [],
}
}
res[packId].emojis.push(emoji)
})
return res
}, {})
},
standardEmojiList(state) {
- return (
- SORTED_EMOJI_GROUP_IDS.map((groupId) =>
- (this.emoji[groupId] || []).map((k) =>
- injectAnnotations(k, this.unicodeEmojiAnnotations),
- ),
- ).reduce((a, b) => a.concat(b), []) ?? []
- )
+ return SORTED_EMOJI_GROUP_IDS.map((groupId) =>
+ (this.emoji[groupId] || []).map((k) =>
+ injectAnnotations(k, this.unicodeEmojiAnnotations),
+ ),
+ ).flat()
},
standardEmojiGroupList(state) {
- return (
- SORTED_EMOJI_GROUP_IDS.map((groupId) => ({
- id: groupId,
- emojis: (this.emoji[groupId] || []).map((k) =>
- injectAnnotations(k, this.unicodeEmojiAnnotations),
- ),
- })) ?? []
- )
+ return SORTED_EMOJI_GROUP_IDS.map((groupId) => ({
+ id: groupId,
+ emojis: (this.emoji[groupId] || []).map((k) =>
+ injectAnnotations(k, this.unicodeEmojiAnnotations),
+ ),
+ }))
},
},
actions: {
setStickers(stickers) {
this.stickers = stickers
},
async getStaticEmoji() {
try {
// See build/emojis_plugin for more details
const values = (await import('src/assets/emoji.json')).default
const emoji = Object.keys(values).reduce((res, groupId) => {
res[groupId] = values[groupId].map((e) => ({
displayText: e.slug,
imageUrl: false,
replacement: e.emoji,
}))
return res
}, {})
this.emoji = injectRegionalIndicators(emoji)
} catch (e) {
console.warn("Can't load static emoji\n", e)
}
},
loadUnicodeEmojiData(language) {
const langList = ensureFinalFallback(language)
return Promise.all(
langList.map(async (lang) => {
if (!this.unicodeEmojiAnnotations[lang]) {
try {
const annotations = await loadAnnotations(lang)
this.unicodeEmojiAnnotations[lang] = annotations
} catch (e) {
console.warn(
`Error loading unicode emoji annotations for ${lang}: `,
e,
)
// ignore
}
}
}),
)
},
async getAdminPacksLocal(refresh) {
if (!refresh && this.adminPacksLocal) return this.adminPacksLocal
this.adminPacksLocalLoading = true
this.adminPacksLocal = await this.getAdminPacks(
useInstanceStore().server,
(params) =>
listEmojiPacks({
...params,
credentials: useOAuthStore().token,
}).then(({ data }) => data),
)
this.adminPacksLocalLoading = false
},
async getAdminPacks(instance, listFunction) {
const currentUser = window.vuex.state.users.currentUser
if (!currentUser.rights.admin) return
const pageSize = 25
return await listFunction({
instance,
page: 1,
pageSize: 0,
})
.then((data) => {
const promises = []
for (let i = 0; i < Math.ceil(data.count / pageSize); i++) {
promises.push(
listFunction({
instance,
page: i,
pageSize,
}).then((pageData) => {
return pageData.packs
}),
)
}
return Promise.all(promises).then((results) => {
return merge({}, ...results)
})
})
.then((allPacks) => {
// Sort by key
return Object.keys(allPacks)
.sort((a, b) => a.localeCompare(b))
.reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = allPacks[key]
return acc
}, {})
})
.catch((data) => {
console.error(data)
})
},
async getCustomEmoji() {
try {
let res = await window.fetch('/api/v1/pleroma/emoji')
if (!res.ok) {
res = await window.fetch('/api/pleroma/emoji.json')
}
if (res.ok) {
const result = await res.json()
const values = Array.isArray(result)
? Object.assign({}, ...result)
: result
const caseInsensitiveStrCmp = (a, b) => {
const la = a.toLowerCase()
const lb = b.toLowerCase()
return la > lb ? 1 : la < lb ? -1 : 0
}
const noPackLast = (a, b) => {
const aNull = a === ''
const bNull = b === ''
if (aNull === bNull) {
return 0
} else if (aNull && !bNull) {
return 1
} else {
return -1
}
}
const byPackThenByName = (a, b) => {
const packOf = (emoji) =>
(emoji.tags.filter((k) => k.startsWith('pack:'))[0] || '').slice(
5,
)
const packOfA = packOf(a)
const packOfB = packOf(b)
return (
noPackLast(packOfA, packOfB) ||
caseInsensitiveStrCmp(packOfA, packOfB) ||
caseInsensitiveStrCmp(a.displayText, b.displayText)
)
}
const emoji = Object.entries(values)
.map(([key, value]) => {
const imageUrl = value.image_url
return {
displayText: key,
imageUrl: imageUrl
? useInstanceStore().server + imageUrl
: value,
tags: imageUrl
? value.tags.sort((a, b) => (a > b ? 1 : 0))
: ['utf'],
replacement: `:${key}: `,
}
// Technically could use tags but those are kinda useless right now,
// should have been "pack" field, that would be more useful
})
.sort(byPackThenByName)
this.customEmoji = emoji
} else {
throw res
}
} catch (e) {
console.warn("Can't load custom emojis\n", e)
}
},
fetchEmoji() {
if (!this.customEmojiFetched) {
this.getCustomEmoji().then(() => (this.customEmojiFetched = true))
}
if (!this.emojiFetched) {
this.getStaticEmoji().then(() => (this.emojiFetched = true))
}
},
},
})
diff --git a/src/stores/interface.js b/src/stores/interface.js
index edc8796142..7be72a8d2a 100644
--- a/src/stores/interface.js
+++ b/src/stores/interface.js
@@ -1,803 +1,803 @@
import { defineStore } from 'pinia'
import {
applyTheme,
getResourcesIndex,
tryLoadCache,
} from '../services/style_setter/style_setter.js'
import { deserialize } from '../services/theme_data/iss_deserializer.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import {
CURRENT_VERSION,
generatePreset,
} from 'src/services/theme_data/theme_data.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
const GENERIC_FONT_NAMES = new Set([
'serif',
'sans-serif',
'system-ui',
'cursive',
'fantasy',
'math',
'monospace',
])
export const useInterfaceStore = defineStore('interface', {
state: () => ({
localFonts: null,
themeApplied: false,
themeChangeInProgress: false,
themeVersion: 'v3',
styleNameUsed: null,
styleDataUsed: null,
useStylePalette: false, // hack for applying styles from appearance tab
paletteNameUsed: null,
paletteDataUsed: null,
themeNameUsed: null,
themeDataUsed: null,
temporaryChangesTimeoutId: null,
temporaryChangesCountdown: -1, // used for temporary options that revert after a timeout
temporaryChangesConfirm: () => {
/* no-op */
}, // used for applying temporary options
temporaryChangesRevert: () => {
/* no-op */
}, // used for reverting temporary options
settingsModalState: 'hidden',
settingsModalLoadedUser: false,
settingsModalLoadedAdmin: false,
settingsModalTargetTab: null,
settingsModalMode: 'user',
settings: {
currentSaveStateNotice: null,
noticeClearTimeout: null,
notificationPermission: null,
},
browserSupport: {
cssFilter:
window.CSS?.supports &&
(window.CSS.supports('filter', 'drop-shadow(0 0)') ||
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')),
localFonts: typeof window.queryLocalFonts === 'function',
},
layoutType: 'normal',
globalNotices: [],
globalError: null,
layoutHeight: 0,
lastTimeline: null,
foreignProfileBackground: null,
}),
actions: {
setTemporaryChanges({ confirm, revert }) {
this.temporaryChangesCountdown = 10
this.temporaryChangesConfirm = confirm
this.temporaryChangesRevert = revert
const countdownFunc = () => {
if (this.temporaryChangesCountdown <= 1) {
this.temporaryChangesRevert()
this.clearTemporaryChanges()
} else {
this.temporaryChangesCountdown--
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
}
}
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
},
clearTemporaryChanges() {
this.temporaryChangesTimeoutId ??
clearTimeout(this.temporaryChangesTimeoutId)
this.temporaryChangesTimeoutId = null
this.temporaryChangesCountdown = -1
this.temporaryChangesConfirm = () => {
/* no-op */
}
this.temporaryChangesRevert = () => {
/* no-op */
}
},
setPageTitle(option = '') {
try {
document.title = `${option} ${useInstanceStore().instanceIdentity.name}`
} catch (error) {
console.error(`${error}`)
}
},
setForeignProfileBackground(url) {
this.foreignProfileBackground = url
},
settingsSaved({ success, error }) {
if (success) {
if (this.noticeClearTimeout) {
clearTimeout(this.noticeClearTimeout)
}
this.settings.currentSaveStateNotice = { error: false, data: success }
this.settings.noticeClearTimeout = setTimeout(
() => delete this.settings.currentSaveStateNotice,
2000,
)
} else {
this.settings.currentSaveStateNotice = { error: true, errorData: error }
}
},
setNotificationPermission(permission) {
this.notificationPermission = permission
},
closeSettingsModal() {
this.settingsModalState = 'hidden'
},
openSettingsModal(value) {
this.settingsModalMode = value
this.settingsModalState = 'visible'
if (value === 'user') {
if (!this.settingsModalLoadedUser) {
this.settingsModalLoadedUser = true
}
} else if (value === 'admin') {
if (!this.settingsModalLoadedAdmin) {
this.settingsModalLoadedAdmin = true
}
}
},
setSettingsModalState(newState) {
const oldState = this.settingsModalState
const legal = (() => {
switch (oldState) {
case 'minimized':
return true
case 'visible':
return true
case 'hidden':
return newState === 'visible'
}
})()
if (legal) {
this.settingsModalState = newState
}
},
toggleMinimizeSettingsModal() {
switch (this.settingsModalState) {
case 'minimized':
this.settingsModalState = 'visible'
return
case 'visible':
this.settingsModalState = 'minimized'
return
case 'hidden':
return
default:
throw new Error(
`Illegal minimization state of settings modal: ${this.settingsModalState}`,
)
}
},
clearSettingsModalTargetTab() {
this.settingsModalTargetTab = null
},
openSettingsModalTab(value, mode = 'user') {
this.settingsModalTargetTab = value
this.openSettingsModal(mode)
},
removeGlobalNotice(notice) {
this.globalNotices = this.globalNotices.filter((n) => n !== notice)
},
setGlobalError({ error, instance, info }) {
switch (info) {
case 'https://vuejs.org/error-reference/#runtime-13': {
this.globalError = {
title: 'general.refresh_required',
content: 'general.refresh_required_content',
// `true` disables cache on Firefox (non-standard)
recover: () => window.location.reload(true),
recoverText: 'general.refresh_required_refresh',
error,
}
break
}
default: {
this.globalError = { error }
break
}
}
},
clearGlobalError() {
this.globalError = null
},
pushGlobalNotice({
messageKey,
messageArgs = {},
level = 'error',
timeout = 5000,
}) {
const notice = {
messageKey,
messageArgs,
level,
}
this.globalNotices.push(notice)
// Adding a new element to array wraps it in a Proxy, which breaks the comparison
// TODO: Generate UUID or something instead or relying on !== operator?
const newNotice = this.globalNotices[this.globalNotices.length - 1]
if (timeout > 0) {
setTimeout(() => this.removeGlobalNotice(newNotice), timeout)
}
return newNotice
},
setLayoutHeight(value) {
this.layoutHeight = value
},
setLayoutWidth(value) {
let width = value
if (value !== undefined) {
this.layoutWidth = value
} else {
width = this.layoutWidth
}
const mobileLayout = width <= 800
const normalOrMobile = mobileLayout ? 'mobile' : 'normal'
const { thirdColumnMode } = useMergedConfigStore().mergedConfig
if (thirdColumnMode === 'none' || !window.vuex.state.users.currentUser) {
this.layoutType = normalOrMobile
} else {
const wideLayout = width >= 1300
this.layoutType = wideLayout ? 'wide' : normalOrMobile
}
},
setFontsList(value) {
this.localFonts = [...new Set(value.map(({ family }) => family)).values()]
},
queryLocalFonts() {
if (this.localFonts !== null) return
this.setFontsList([])
if (!this.browserSupport.localFonts) {
return
}
window
.queryLocalFonts()
.then((fonts) => {
this.setFontsList(fonts)
})
.catch((e) => {
this.pushGlobalNotice({
messageKey: 'settings.style.themes3.font.font_list_unavailable',
messageArgs: {
error: e,
},
level: 'error',
})
})
},
setLastTimeline(value) {
this.lastTimeline = value
},
async fetchPalettesIndex() {
try {
const value = await getResourcesIndex('/static/palettes/index.json')
useInstanceStore().set({
path: 'palettesIndex',
value,
})
return value
} catch (e) {
console.error('Could not fetch palettes index', e)
useInstanceStore().set({
path: 'palettesIndex',
value: { _error: e },
})
- return Promise.resolve({})
+ return {}
}
},
setPalette(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.palette', value })
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setPaletteCustom(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({
path: 'simple.paletteCustomData',
value,
})
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
async fetchStylesIndex() {
try {
const value = await getResourcesIndex(
'/static/styles/index.json',
deserialize,
)
useInstanceStore().set({ path: 'stylesIndex', value })
return value
} catch (e) {
console.error('Could not fetch styles index', e)
useInstanceStore().set({
path: 'simple.stylesIndex',
value: { _error: e },
})
- return Promise.resolve({})
+ return {}
}
},
setStyle(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
useSyncConfigStore().setPreference({ path: 'simple.style', value })
useSyncConfigStore().pushSyncConfig()
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
setStyleCustom(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
useSyncConfigStore().setPreference({
path: 'simple.styleCustomData',
value,
})
useSyncConfigStore().pushSyncConfig()
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
async fetchThemesIndex() {
try {
const value = await getResourcesIndex('/static/styles.json')
useInstanceStore().set({ path: 'themesIndex', value })
return value
} catch (e) {
console.error('Could not fetch themes index', e)
useInstanceStore().set({
path: 'themesIndex',
value: { _error: e },
})
- return Promise.resolve({})
+ return {}
}
},
setTheme(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.theme', value })
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setThemeCustom(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.customTheme', value })
useSyncConfigStore().setPreference({
path: 'simple.customThemeSource',
value,
})
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
resetThemeV3() {
useSyncConfigStore().setPreference({ path: 'simple.style', value: null })
useSyncConfigStore().setPreference({
path: 'simple.styleCustomData',
value: null,
})
},
resetThemeV3Palette() {
useSyncConfigStore().setPreference({
path: 'simple.palette',
value: null,
})
useSyncConfigStore().setPreference({
path: 'simple.paletteCustomData',
value: null,
})
},
resetThemeV2() {
useSyncConfigStore().setPreference({ path: 'simple.theme', value: null })
useSyncConfigStore().setPreference({
path: 'simple.customTheme',
value: null,
})
useSyncConfigStore().setPreference({
path: 'simple.customThemeSource',
value: null,
})
},
async getThemeData() {
const getData = async (resource, index, customData, name) => {
const capitalizedResource =
resource[0].toUpperCase() + resource.slice(1)
const result = {}
if (customData) {
result.nameUsed = 'custom' // custom data overrides name
result.dataUsed = customData
} else {
result.nameUsed = name
if (result.nameUsed == null) {
result.dataUsed = null
return result
}
let fetchFunc = index[result.nameUsed]
// Fallbacks
if (!fetchFunc) {
if (resource === 'style' || resource === 'palette') {
return result
}
const newName = Object.keys(index)[0]
fetchFunc = index[newName]
console.warn(
`${capitalizedResource} with id '${this.styleNameUsed}' not found, trying back to '${newName}'`,
)
if (!fetchFunc) {
console.warn(
`${capitalizedResource} doesn't have a fallback, defaulting to stock.`,
)
fetchFunc = () => Promise.resolve(null)
}
}
result.dataUsed = await fetchFunc()
}
return result
}
let {
theme: instanceThemeName,
style: instanceStyleName,
palette: instancePaletteName,
} = useInstanceStore().instanceIdentity
let { themesIndex, stylesIndex, palettesIndex } = useInstanceStore()
const {
style: userStyleName,
styleCustomData: userStyleCustomData,
palette: userPaletteName,
paletteCustomData: userPaletteCustomData,
} = useMergedConfigStore().mergedConfig
let {
theme: userThemeV2Name,
customTheme: userThemeV2Snapshot,
customThemeSource: userThemeV2Source,
} = useMergedConfigStore().mergedConfig
let majorVersionUsed
console.debug(
`User V3 palette: ${userPaletteName}, style: ${userStyleName} , custom: ${!!userStyleCustomData}`,
)
console.debug(
`User V2 name: ${userThemeV2Name}, source: ${!!userThemeV2Source}, snapshot: ${!!userThemeV2Snapshot}`,
)
console.debug(
`Instance V3 palette: ${instancePaletteName}, style: ${instanceStyleName}`,
)
console.debug('Instance V2 theme: ' + instanceThemeName)
if (
userPaletteName ||
userPaletteCustomData ||
userStyleName ||
userStyleCustomData ||
// User V2 overrides instance V3
((instancePaletteName || instanceStyleName) &&
instanceThemeName == null &&
userThemeV2Name == null)
) {
// Palette and/or style overrides V2 themes
instanceThemeName = null
userThemeV2Name = null
userThemeV2Source = null
userThemeV2Snapshot = null
majorVersionUsed = 'v3'
} else if (
userThemeV2Name ||
userThemeV2Snapshot ||
userThemeV2Source ||
instanceThemeName
) {
majorVersionUsed = 'v2'
} else {
// if all fails fallback to v3
majorVersionUsed = 'v3'
}
if (majorVersionUsed === 'v3') {
const result = await Promise.all([
this.fetchPalettesIndex(),
this.fetchStylesIndex(),
])
palettesIndex = result[0]
stylesIndex = result[1]
} else {
// Promise.all just to be uniform with v3
const result = await Promise.all([this.fetchThemesIndex()])
themesIndex = result[0]
}
this.themeVersion = majorVersionUsed
console.debug('Version used', majorVersionUsed)
if (majorVersionUsed === 'v3') {
this.themeDataUsed = null
this.themeNameUsed = null
const style = await getData(
'style',
stylesIndex,
userStyleCustomData,
userStyleName || instanceStyleName,
)
this.styleNameUsed = style.nameUsed
this.styleDataUsed = style.dataUsed
let firstStylePaletteName = null
style.dataUsed
?.filter((x) => x.component === '@palette')
.map((x) => {
const cleanDirectives = Object.fromEntries(
Object.entries(x.directives).filter(([k]) => k),
)
return { name: x.variant, ...cleanDirectives }
})
.forEach((palette) => {
const key =
'style.' + palette.name.toLowerCase().replaceAll(' ', '_')
if (!firstStylePaletteName) firstStylePaletteName = key
palettesIndex[key] = () => Promise.resolve(palette)
})
const palette = await getData(
'palette',
palettesIndex,
userPaletteCustomData,
this.useStylePalette
? firstStylePaletteName
: userPaletteName || instancePaletteName,
)
if (this.useStylePalette) {
useSyncConfigStore().setPreference({
path: 'simple.palette',
value: firstStylePaletteName,
})
useSyncConfigStore().pushSyncConfig()
}
this.paletteNameUsed = palette.nameUsed
this.paletteDataUsed = palette.dataUsed
if (this.paletteDataUsed) {
this.paletteDataUsed.link =
this.paletteDataUsed.link || this.paletteDataUsed.accent
this.paletteDataUsed.accent =
this.paletteDataUsed.accent || this.paletteDataUsed.link
}
if (Array.isArray(this.paletteDataUsed)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = palette.dataUsed
this.paletteDataUsed = {
name,
bg,
fg,
text,
link,
accent: link,
cRed,
cBlue,
cGreen,
cOrange,
}
}
console.debug('Palette data used', palette.dataUsed)
} else {
this.styleNameUsed = null
this.styleDataUsed = null
this.paletteNameUsed = null
this.paletteDataUsed = null
const theme = await getData(
'theme',
themesIndex,
userThemeV2Source || userThemeV2Snapshot,
userThemeV2Name || instanceThemeName,
)
this.themeNameUsed = theme.nameUsed
this.themeDataUsed = theme.dataUsed
}
},
async setThemeApplied() {
this.themeApplied = true
},
async applyTheme({ recompile = false } = {}) {
const { mergedConfig } = useMergedConfigStore()
const { forceThemeRecompilation, themeDebug } = mergedConfig
this.themeChangeInProgress = true
// If we're not forced to recompile try using
// cache (tryLoadCache return true if load successful)
const forceRecompile = forceThemeRecompilation || recompile
await this.getThemeData()
if (!forceRecompile && !themeDebug && (await tryLoadCache())) {
this.themeChangeInProgress = false
return this.setThemeApplied()
}
window.splashUpdate('splash.theme')
try {
const paletteIss = (() => {
if (!this.paletteDataUsed) return null
const result = {
component: 'Root',
directives: {},
}
Object.entries(this.paletteDataUsed)
.filter(([k]) => k !== 'name')
.forEach(([k, v]) => {
let issRootDirectiveName
switch (k) {
case 'background':
issRootDirectiveName = 'bg'
break
case 'foreground':
issRootDirectiveName = 'fg'
break
default:
issRootDirectiveName = k
}
result.directives['--' + issRootDirectiveName] = 'color | ' + v
})
return result
})()
const theme2ruleset =
this.themeDataUsed &&
convertTheme2To3(normalizeThemeData(this.themeDataUsed))
const hacks = []
const fontMap = {
Interface: 'Root',
Input: 'Input',
Posts: 'Post',
Monospace: 'Root',
}
Object.entries(fontMap).forEach(([font, component]) => {
const family = mergedConfig[`font${font}`]
const variable = font === 'Monospace' ? '--monoFont' : '--font'
if (typeof family === 'string') {
const familyString = GENERIC_FONT_NAMES.has(family)
? family
: `"${family}"`
hacks.push({
component,
directives: {
[variable]: `generic | ${familyString}`,
},
})
}
})
if (mergedConfig.underlay !== 'none') {
const newRule = {
component: 'Underlay',
directives: {},
}
if (mergedConfig.underlay === 'opaque') {
newRule.directives.opacity = 1
newRule.directives.background = '--wallpaper'
}
if (mergedConfig.underlay === 'transparent') {
newRule.directives.opacity = 0
}
hacks.push(newRule)
}
const rulesetArray = [
theme2ruleset,
this.styleDataUsed,
paletteIss,
hacks,
].filter(Boolean)
return applyTheme(
rulesetArray.flat(),
() => this.setThemeApplied(),
() => {
this.themeChangeInProgress = false
},
themeDebug,
)
} catch (e) {
console.error(e)
window.splashError(e)
}
},
},
})
export const normalizeThemeData = (input) => {
let themeData, themeSource
if (input.themeFileVerison === 1) {
// this might not be even used at all, some leftover of unimplemented code in V2 editor
return generatePreset(input).theme
} else if (
Object.hasOwn(input, '_pleroma_theme_version') ||
Object.hasOwn(input, 'source') ||
Object.hasOwn(input, 'theme')
) {
// We got passed a full theme file
themeData = input.theme
themeSource = input.source
} else if (
Object.hasOwn(input, 'themeEngineVersion') ||
Object.hasOwn(input, 'colors')
) {
// We got passed a source/snapshot
themeData = input
themeSource = input
}
// New theme presets don't have 'theme' property, they use 'source'
let out // shout, shout let it all out
if (themeSource?.themeEngineVersion === CURRENT_VERSION) {
// There are some themes in wild that have completely broken source
out = { ...(themeData || {}), ...themeSource }
} else {
out = themeData
}
// generatePreset here basically creates/updates "snapshot",
// while also fixing the 2.2 -> 2.3 colors/shadows/etc
return generatePreset(out).theme
}
diff --git a/src/stores/sync_config.js b/src/stores/sync_config.js
index 3b71478c18..9464ffb686 100644
--- a/src/stores/sync_config.js
+++ b/src/stores/sync_config.js
@@ -1,844 +1,844 @@
import sum from 'hash-sum'
import {
merge as _merge,
clamp,
cloneDeep,
findLastIndex,
get,
groupBy,
isEqual,
last,
set,
take,
uniqWith,
unset,
} from 'lodash'
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { toRaw } from 'vue'
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileJSON } from 'src/api/user.js'
import { storage } from 'src/lib/storage.js'
import {
makeUndefined,
ROOT_CONFIG,
ROOT_CONFIG_DEFINITIONS,
validateSetting,
} from 'src/modules/default_config_state.js'
import { oldDefaultConfigSync } from 'src/modules/old_default_config_state.js'
export const VERSION = 2
export const NEW_USER_DATE = new Date('2026-03-16') // date of writing this, basically
export const COMMAND_TRIM_FLAGS = 1000
export const COMMAND_TRIM_FLAGS_AND_RESET = 1001
export const COMMAND_WIPE_JOURNAL = 1010
export const COMMAND_WIPE_JOURNAL_AND_STORAGE = 1011
export const defaultState = {
// do we need to update data on server?
dirty: false,
// storage of flags - stuff that can only be set and incremented
flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen
reset: 0, // special flag that can be used to force-reset all data, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0
},
prefsStorage: {
_journal: [],
simple: {
muteFilters: {},
...makeUndefined({ ...ROOT_CONFIG }),
},
collections: {
pinnedStatusActions: ['reply', 'retweet', 'favorite', 'emoji'],
pinnedNavItems: ['home', 'dms', 'chats'],
},
},
// raw data
raw: null,
// local cache
cache: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
updateCounter: CURRENT_UPDATE_COUNTER, // new users don't need to see update notification
}
export const _moveItemInArray = (array, value, movement) => {
const oldIndex = array.indexOf(value)
const newIndex = oldIndex + movement
const newArray = [...array]
// remove old
newArray.splice(oldIndex, 1)
// add new
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
return newArray
}
const _wrapData = (data, userName) => {
return {
...data,
_user: userName,
_timestamp: Date.now(),
_version: VERSION,
}
}
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
const _verifyPrefs = (state) => {
state.prefsStorage = state.prefsStorage || {
simple: {},
collections: {},
}
// Simple
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
- if (typeof v === 'undefined') return
+ if (v === undefined) return
if (typeof v === 'number' || typeof v === 'boolean') return
if (typeof v === 'object') return
console.warn(
`Preference simple.${k} as invalid type ${typeof v}, reinitializing`,
)
set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
})
// Collections
Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
if (Array.isArray(v)) return
console.warn(
`Preference collections.${k} as invalid type ${typeof v}, reinitializing`,
)
set(
state.prefsStorage.collections,
k,
defaultState.prefsStorage.collections[k],
)
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug('Both sources have valid data, figuring things out...')
if (
live._timestamp === cache._timestamp &&
live._version === cache._version
) {
console.debug(
'Same version/timestamp on both sources, source of truth irrelevant',
)
result.recent = cache
result.stale = live
} else {
console.debug(
'Different timestamp or version, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('Both sources are invalid, start from scratch')
result.needUpload = true
}
const merge = (a, b) => {
return {
_user: a._user ?? b._user,
_version: a._version ?? b._version,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
prefsStorage: _merge(cloneDeep(a.prefsStorage), b.prefsStorage),
flagStorage: _merge(a.flagStorage, b.flagStorage),
}
}
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
export const _getAllFlags = (recent, stale) => {
const recentStorage = toRaw(recent?.flagStorage)
const staleStorage = toRaw(stale?.flagStorage)
return Array.from(
new Set([
...Object.keys(recentStorage || {}),
...Object.keys(staleStorage || {}),
]),
)
}
export const _mergeFlags = (recent, stale, allFlagKeys) => {
if (!stale.flagStorage) return recent.flagStorage
if (!recent.flagStorage) return stale.flagStorage
return Object.fromEntries(
allFlagKeys.map((flag) => {
const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag]
// use flag that is of higher value
return [
flag,
Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0),
]
}),
)
}
export const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = journals
.map((j) => (Array.isArray(j) ? j : []))
.flat()
.filter(
(entry) =>
Object.hasOwn(entry, 'path') &&
Object.hasOwn(entry, 'operation') &&
Object.hasOwn(entry, 'args') &&
Object.hasOwn(entry, 'timestamp'),
)
const grouped = groupBy(allJournals, 'path')
const trimmedGrouped = Object.entries(grouped).map(([path, rawJournal]) => {
const journal = rawJournal
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
if (a.timestamp === b.timestamp) {
return ai - bi
} else {
return a.timestamp > b.timestamp ? 1 : -1
}
})
.map((x) => x.data)
if (path.startsWith('collections')) {
const lastRemoveIndex = findLastIndex(
journal,
({ operation }) => operation === 'removeFromCollection',
)
// everything before last remove is unimportant
let remainder
if (lastRemoveIndex > 0) {
remainder = journal.slice(lastRemoveIndex)
} else {
// everything else doesn't need trimming
remainder = journal
}
return uniqWith(remainder, (a, b) => {
if (a.path !== b.path) {
return false
}
if (a.operation !== b.operation) {
return false
}
if (a.operation === 'addToCollection') {
return a.args[0] === b.args[0]
}
return false
})
} else if (path.startsWith('simple')) {
// Only the last record is important
return [last(journal)]
} else {
return journal
}
})
const flat = trimmedGrouped
.flat()
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
if (a.timestamp === b.timestamp) {
return ai - bi
} else {
return a.timestamp > b.timestamp ? 1 : -1
}
})
.map((x) => x.data)
return take(flat, 500)
}
export const _mergePrefs = (recent, stale) => {
if (!stale) return recent
if (!recent) return stale
const { _journal: recentJournal, ...recentData } = recent
const { _journal: staleJournal } = stale
/** Journal entry format:
* path: path to entry in prefsStorage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
* shouldn't be used with collections!
*/
const resultOutput = { ...recentData }
const totalJournal = _mergeJournal(staleJournal, recentJournal)
totalJournal
.filter(({ path, operation, args }) => {
const entry = path.split('.')[1]
if (operation === 'unset') return ROOT_CONFIG[entry] !== undefined
if (operation !== 'set') return true
const definition = path.startsWith('simple.muteFilters')
? { default: {} }
: ROOT_CONFIG_DEFINITIONS[entry]
const finalValue = validateSetting({
path,
value: args[0],
definition,
throwError: false,
defaultState: ROOT_CONFIG,
})
return finalValue !== undefined
})
.forEach(({ path, operation, args }) => {
if (path.startsWith('_')) {
throw new Error(
`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`,
)
}
switch (operation) {
case 'set': {
if (path.startsWith('collections')) {
return console.error('Illegal operation "set" on a collection')
}
if (path.split(/\./g).length <= 1) {
return console.error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
set(resultOutput, path, args[0])
break
}
case 'unset':
if (path.startsWith('collections')) {
return console.error('Illegal operation "unset" on a collection')
}
if (path.split(/\./g).length <= 2) {
return console.error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
unset(resultOutput, path)
break
case 'addToCollection':
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "addToCollection" on a non-collection',
)
}
set(
resultOutput,
path,
Array.from(new Set(get(resultOutput, path)).add(args[0])),
)
break
case 'removeFromCollection': {
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "removeFromCollection" on a non-collection',
)
}
const newSet = new Set(get(resultOutput, path))
newSet.delete(args[0])
set(resultOutput, path, Array.from(newSet))
break
}
case 'reorderCollection': {
const [value, movement] = args
set(
resultOutput,
path,
_moveItemInArray(get(resultOutput, path), value, movement),
)
break
}
default:
return console.error(
`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`,
)
}
})
return { ...resultOutput, _journal: totalJournal }
}
export const _resetFlags = (
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
let result = { ...totalFlags }
const allFlagKeys = Object.keys(totalFlags)
// flag reset functionality
if (
totalFlags.reset >= COMMAND_TRIM_FLAGS &&
totalFlags.reset <= COMMAND_TRIM_FLAGS_AND_RESET
) {
console.debug('Received command to trim the flags')
const knownKeysSet = new Set(Object.keys(knownKeys))
// Trim
result = {}
allFlagKeys.forEach((flag) => {
if (knownKeysSet.has(flag)) {
result[flag] = totalFlags[flag]
}
})
// Reset
if (totalFlags.reset === COMMAND_TRIM_FLAGS_AND_RESET) {
// 1001 - and reset everything to 0
console.debug('Received command to reset the flags')
Object.keys(knownKeys).forEach((flag) => {
result[flag] = 0
})
}
}
result.reset = 0
return result
}
const _resetPrefs = (
totalPrefs,
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
// prefs reset functionality
if (
totalFlags.reset >= COMMAND_WIPE_JOURNAL &&
totalFlags.reset <= COMMAND_WIPE_JOURNAL_AND_STORAGE
) {
console.debug('Received command to reset journals')
this.flagStorage.reset = COMMAND_WIPE_JOURNAL
this.prefsStorage._journal = []
this.cache.prefsStorage._journal = []
this.raw.prefsStorage._journal = []
this.pushSyncConfig()
if (totalFlags.reset === COMMAND_WIPE_JOURNAL_AND_STORAGE) {
console.debug('Received command to reset storage')
return cloneDeep(defaultState)
}
}
return totalPrefs
}
const _doMigrations = async (data, setPreference) => {
if (data._version < VERSION) {
console.debug(
'Data has older version, seeing if there any migrations that can be applied',
)
}
if (data._version > VERSION) {
console.debug(
'Data has newer version, seeing if there any reverse migrations that can be applied',
)
// no reverse migrations right now but we leave a possibility of loading a hotpatch if need be
if (window._PLEROMA_HOTPATCH) {
if (window._PLEROMA_HOTPATCH.reverseMigrations) {
console.debug('Found hotpatch migration, applying')
return window._PLEROMA_HOTPATCH.reverseMigrations.call(
{},
'syncConfigStore',
{ from: data._version, to: VERSION },
data,
)
}
}
}
return data
}
export const useSyncConfigStore = defineStore('sync_config', {
state() {
return cloneDeep(defaultState)
},
actions: {
setFlag({ flag, value }) {
this.flagStorage[flag] = value
this.dirty = true
},
setSimplePrefAndSave({ path, value }) {
this.setPreference({ path: `simple.${path}`, value })
this.pushSyncConfig()
},
unsetSimplePrefAndSave({ path }) {
this.unsetPreference({ path: `simple.${path}` })
this.pushSyncConfig()
},
setPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'set' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 1) {
throw new Error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling set on depth > 3 (path: ${path}) is not allowed`,
)
}
if (path.startsWith('collections.')) return value
const definition = path.startsWith('simple.muteFilters')
? { default: {} }
: ROOT_CONFIG_DEFINITIONS[path.split('.')[1]]
const finalValue = validateSetting({
path,
value,
definition,
throwError: false,
defaultState: ROOT_CONFIG,
})
if (finalValue !== undefined) set(this.prefsStorage, path, finalValue)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'set', path, args: [value], timestamp: Date.now() },
]
this.dirty = true
},
unsetPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'unset' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 2) {
throw new Error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling unset on depth > 3 (path: ${path}) is not allowed`,
)
}
unset(this.prefsStorage, path)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'unset', path, args: [], timestamp: Date.now() },
]
this.dirty = true
},
addCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}'`,
)
}
if (path.startsWith('collections')) {
const collection = new Set(get(this.prefsStorage, path))
collection.add(value)
set(this.prefsStorage, path, [...collection])
}
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'addToCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
removeCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const { _key } = value
if (path.startsWith('collection')) {
const collection = new Set(get(this.prefsStorage, path))
collection.delete(value)
set(this.prefsStorage, path, [...collection])
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'removeFromCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
}
},
reorderCollectionPreference({ path, value, movement }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const collection = get(this.prefsStorage, path)
const newCollection = _moveItemInArray(collection, value, movement)
set(this.prefsStorage, path, newCollection)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'arrangeCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
updateCache({ username }) {
this.prefsStorage._journal = _mergeJournal(this.prefsStorage._journal)
this.cache = _wrapData(
{
flagStorage: toRaw(this.flagStorage),
prefsStorage: toRaw(this.prefsStorage),
},
username,
)
},
clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
this.flagStorage.reset = COMMAND_WIPE_JOURNAL_AND_STORAGE
},
async initSyncConfig(userData) {
const live = userData.storage
this.raw = live
let cache = this.cache
if (cache?._user !== userData.fqn) {
console.warn(
'Cache belongs to another user! reinitializing local cache!',
)
cache = null
}
let { recent, stale, needUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage
let dirty = false
if (recent === null) {
console.debug(
`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
)
recent = _wrapData({
flagStorage: { ...flagsTemplate },
prefsStorage: { ...defaultState.prefsStorage },
})
}
recent = recent && (await _doMigrations(recent, this.setPreference))
stale = stale && (await _doMigrations(stale, this.setPreference))
// Various migrations
console.debug('Migrating from old config')
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
const config = vuexState.config ?? {}
const migratedEntries = new Set(config._syncMigration ?? [])
console.debug(
`Already migrated Values: ${[...migratedEntries].join() || '[none]'}`,
)
Object.entries(oldDefaultConfigSync).forEach(([key, value]) => {
const oldValue = config[key]
const defaultValue = value
const present = oldValue !== undefined
const migrated = migratedEntries.has(key)
const different = !isEqual(oldValue, defaultValue)
if (present && !migrated && different) {
console.debug(`Migrating config ${key}: ${oldValue}`)
if (key === 'theme3hacks') {
useLocalConfigStore().set({
path: 'fontInterface',
value: oldValue.fonts.interface,
})
useLocalConfigStore().set({
path: 'fontInput',
value: oldValue.fonts.input,
})
useLocalConfigStore().set({
path: 'fontPost',
value: oldValue.fonts.post,
})
useLocalConfigStore().set({
path: 'fontMonospace',
value: oldValue.fonts.monospace,
})
useSyncConfigStore().setSimplePrefAndSave({
path: 'underlay',
value: oldValue.underlay,
})
} else if (key == 'muteWords') {
oldValue.forEach((word, order) => {
const uniqueId = uuidv4()
useSyncConfigStore().setPreference({
path: 'simple.muteFilters.' + uniqueId,
value: {
type: 'word',
value: word,
name: word,
enabled: true,
expires: null,
hide: false,
order,
},
})
})
} else {
this.setPreference({ path: `simple.${key}`, value: oldValue })
}
migratedEntries.add(key)
needUpload = true
}
})
config._syncMigration = [...migratedEntries]
vuexState.config = config
storage.setItem('vuex-lz', vuexState)
if (!needUpload && recent && stale) {
console.debug('Checking if data needs merging...')
// discarding timestamps and versions
const { _timestamp: _0, _version: _1, ...recentData } = recent
const { _timestamp: _2, _version: _3, ...staleData } = stale
dirty = sum(recentData) !== sum(staleData)
console.debug(`Data ${dirty ? 'needs' : "doesn't need"} merging`)
}
const allFlagKeys = _getAllFlags(recent, stale)
let totalFlags
let totalPrefs
if (dirty) {
// Merge the flags
console.debug('Merging the data...')
totalFlags = _mergeFlags(recent, stale, allFlagKeys)
_verifyPrefs(recent)
_verifyPrefs(stale)
totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
} else {
totalFlags = recent.flagStorage
totalPrefs = recent.prefsStorage
}
totalPrefs = _resetPrefs(totalPrefs, totalFlags)
totalFlags = _resetFlags(totalFlags)
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
this.dirty = dirty || needUpload
this.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.flagStorage = this.cache.flagStorage
this.prefsStorage = this.cache.prefsStorage
this.pushSyncConfig()
},
pushSyncConfig({ force = false } = {}) {
const needPush = this.dirty || force
if (!needPush) return
this.updateCache({ username: window.vuex.state.users.currentUser.fqn })
const params = { pleroma_settings_store: { 'pleroma-fe': this.cache } }
updateProfileJSON({
params,
credentials: useOAuthStore().token,
})
},
},
persist: {
afterLoad(state) {
console.debug('Validating persisted state of SyncConfig')
const newState = { ...state }
newState.prefsStorage = newState.prefsStorage || {}
const newEntries = Object.entries(ROOT_CONFIG).map(([path, value]) => {
const definition = ROOT_CONFIG_DEFINITIONS[path]
const finalValue = validateSetting({
path,
value: newState.prefsStorage.simple?.[path],
definition,
throwError: false,
validateObjects: false,
defaultState: ROOT_CONFIG,
})
return finalValue === undefined
? [path, definition.default]
: [path, finalValue]
})
newState.prefsStorage.simple = Object.fromEntries(
- newEntries.filter((_) => _),
+ newEntries.filter(Boolean),
)
return newState
},
},
})
diff --git a/src/stores/user_highlight.js b/src/stores/user_highlight.js
index 0db37703d0..3cd3bfe7dd 100644
--- a/src/stores/user_highlight.js
+++ b/src/stores/user_highlight.js
@@ -1,349 +1,349 @@
import {
merge as _merge,
clone,
cloneDeep,
groupBy,
isEqual,
last,
} from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
import { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileJSON } from 'src/api/user.js'
import { storage } from 'src/lib/storage.js'
export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically
export const defaultState = {
// do we need to update data on server?
dirty: false,
highlight: {
_journal: [],
},
// raw data
raw: null,
// local cache
cache: null,
}
const _wrapData = (data, userName) => {
return {
...data,
_user: userName,
_timestamp: Date.now(),
}
}
const _checkValidity = (data) => data._timestamp > 0
const _verifyHighlights = (state) => {
state.highlight = state.highlight || {}
// Simple
Object.entries(defaultState.highlight).forEach(([k, v]) => {
- if (typeof v === 'undefined') return
+ if (v === undefined) return
if (typeof v === 'object') return
console.warn(`User highlight ${k} is invalid type ${typeof v}, unsetting`)
delete state.highlight[k]
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'[HIGHLIGHT] Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'[HIGHLIGHT] Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug(
'[HIGHLIGHT] Both sources have valid data, figuring things out...',
)
if (live._timestamp === cache._timestamp) {
console.debug(
'[HIGHLIGHT] Same timestamp on both sources, source of truth irrelevant',
)
result.recent = cache
result.stale = live
} else {
console.debug(
'[HIGHLIGHT] Different timestamp, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('[HIGHLIGHT] Both sources are invalid, start from scratch')
result.needUpload = true
}
const merge = (a, b) => {
return {
_user: a._user ?? b._user,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
highlight: _merge(cloneDeep(a.highlight), b.highlight),
}
}
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = journals
.map((j) => (Array.isArray(j) ? j : []))
.flat()
.filter(
(entry) =>
Object.hasOwn(entry, 'user') &&
Object.hasOwn(entry, 'operation') &&
Object.hasOwn(entry, 'args') &&
Object.hasOwn(entry, 'timestamp'),
)
const grouped = groupBy(allJournals, 'user')
const trimmedGrouped = Object.entries(grouped).map(([user, journal]) => {
// side effect
journal.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1))
return [last(journal)]
})
return trimmedGrouped
.flat()
.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1))
}
export const _mergeHighlights = (recent, stale) => {
if (!stale) return recent
if (!recent) return stale
const { _journal: recentJournal, ...recentHighlight } = recent
const { _journal: staleJournal } = stale
/** Journal entry format:
* user: user to entry in highlight storage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
*/
const resultHighlight = { ...recentHighlight }
const Journal = _mergeJournal(staleJournal, recentJournal)
Journal.forEach(({ user, operation, args }) => {
if (user.startsWith('_')) {
throw new Error(
`journal contains entry to edit internal (starts with _) field '${user}', something is incorrect here, ignoring.`,
)
}
switch (operation) {
case 'set':
resultHighlight[user] = args[0]
break
case 'unset':
delete resultHighlight[user]
break
default:
return console.error(`Unknown journal operation: '${operation}'`)
}
})
return { ...resultHighlight, _journal: Journal }
}
export const useUserHighlightStore = defineStore('user_highlight', {
state() {
return cloneDeep(defaultState)
},
actions: {
setAndSave({ user, value }) {
this.set({ user, value })
this.pushHighlight()
},
unsetAndSave({ user }) {
this.unset({ user })
this.pushHighlight()
},
get(user) {
const present = this.highlight[user] || {}
return {
user,
type: 'disabled',
color: '#FFFFFF',
...present,
}
},
set({ user, value }) {
if (user.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${user}', ignoring.`,
)
}
const oldValue = this.highlight[user]
const newValue = {
user,
...oldValue,
...value,
}
this.highlight[user] = newValue
this.highlight._journal = [
...this.highlight._journal,
{ operation: 'set', user, args: [newValue], timestamp: Date.now() },
]
this.dirty = true
},
unset({ user, value }) {
if (user.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${user}', ignoring.`,
)
}
delete this.highlight[user]
this.highlight._journal = [
...this.highlight._journal,
{ operation: 'unset', user, args: [], timestamp: Date.now() },
]
this.dirty = true
},
updateCache({ username }) {
this.highlight._journal = _mergeJournal(this.highlight._journal)
this.cache = _wrapData(
{
highlight: toRaw(this.highlight),
},
username,
)
},
clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
},
clearJournals() {
this.highlight._journal = []
this.cache.highlight._journal = []
this.raw.highlight._journal = []
this.pushSyncConfig()
},
async initUserHighlight(userData) {
const live = userData.user_highlight
this.raw = live
let cache = this.cache
if (cache?._user !== userData.fqn) {
console.warn(
'Cache belongs to another user! reinitializing local cache!',
)
cache = null
}
let { recent, stale, needUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
let dirty = false
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
const config = vuexState.config ?? {}
const highlight = config.highlight ?? {}
Object.entries(highlight).forEach(([user, value]) => {
if ((highlight[user]._migrated || 0) < 1) {
dirty = true
needUpload = true
this.set({ user, value: clone(value) })
highlight[user]._migrated = 1
console.debug(
`[HIGHLIGHT] Migrating user ${user}: ${JSON.stringify(value)}`,
)
}
})
storage.setItem('vuex-lz', {
...vuexState,
config: { ...config, highlight },
})
if (recent === null) {
console.debug(
`[HIGHLIGHT] Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
)
recent = _wrapData({
highlight: { ...defaultState.highlight },
})
}
if (!needUpload && recent && stale) {
console.debug('[HIGHLIGHT] Checking if data needs merging...')
// discarding timestamps
const { _timestamp: _0, ...recentData } = recent
const { _timestamp: _2, ...staleData } = stale
dirty = !isEqual(recentData, staleData)
console.debug(
`[HIGHLIGHT] Data ${dirty ? 'needs' : "doesn't need"} merging`,
)
}
let highlights
if (dirty) {
console.debug('[HIGHLIGHT] Merging the data...')
_verifyHighlights(recent)
_verifyHighlights(stale)
highlights = _mergeHighlights(recent.highlight, stale.highlight)
} else {
highlights = recent.highlight
}
recent.highlight = { ...defaultState.highlight, ...highlights }
this.dirty = dirty || needUpload
this.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.highlight = this.cache.highlight
},
pushHighlight({ force = false } = {}) {
const needPush = this.dirty || force
if (!needPush) return
this.updateCache({ username: window.vuex.state.users.currentUser.fqn })
const params = {
pleroma_settings_store: { user_highlight: this.cache },
}
updateProfileJSON({
params,
credentials: useOAuthStore().token,
}).then(({ data: user }) => {
this.initUserHighlight(user)
this.dirty = false
})
},
},
persist: {
afterLoad(state) {
return state
},
},
})
diff --git a/test/e2e/runner.js b/test/e2e/runner.js
index 33fcc322cd..06cae7813a 100644
--- a/test/e2e/runner.js
+++ b/test/e2e/runner.js
@@ -1,33 +1,33 @@
// 1. start the dev server using production config
process.env.NODE_ENV = 'testing'
const server = require('../../build/dev-server.js')
// 2. run the nightwatch test suite against it
// to run in additional browsers:
// 1. add an entry in test/e2e/nightwatch.conf.json under "test_settings"
// 2. add it to the --env flag below
// or override the environment flag, for example: `npm run e2e -- --env chrome,firefox`
// For more information on Nightwatch's config file, see
// http://nightwatchjs.org/guide#settings-file
let opts = process.argv.slice(2)
-if (opts.indexOf('--config') === -1) {
+if (!opts.includes('--config')) {
opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js'])
}
-if (opts.indexOf('--env') === -1) {
+if (!opts.includes('--env')) {
opts = opts.concat(['--env', 'chrome'])
}
const spawn = require('cross-spawn')
const runner = spawn('./node_modules/.bin/nightwatch', opts, {
stdio: 'inherit',
})
runner.on('exit', function (code) {
server.close()
process.exit(code)
})
runner.on('error', function (err) {
server.close()
throw err
})
diff --git a/test/unit/specs/components/chat_view.spec.js b/test/unit/specs/components/chat_view.spec.js
index c308e4fbc3..d03809e387 100644
--- a/test/unit/specs/components/chat_view.spec.js
+++ b/test/unit/specs/components/chat_view.spec.js
@@ -1,137 +1,137 @@
import { createTestingPinia } from '@pinia/testing'
import { shallowMount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import ChatView from 'src/components/chat_view/chat_view.vue'
const message1 = {
id: '1',
chat_id: 2,
idempotency_key: '1',
created_at: new Date('2020-06-22T18:45:53.000Z'),
}
const message2 = {
id: '2',
chat_id: 2,
idempotency_key: '2',
account_id: '9vmRb29zLQReckr5ay',
created_at: new Date('2020-06-22T18:45:56.000Z'),
}
const message3 = {
id: '3',
chat_id: 2,
idempotency_key: '3',
account_id: '9vmRb29zLQReckr5ay',
created_at: new Date('2020-07-22T18:45:59.000Z'),
}
const global = {
mocks: {
$store: {
state: {
api: {},
users: {},
statuses: {
allStatusesObject: {},
},
},
},
$route: {
params: {
recipient_id: 2,
},
},
$router: {
push: () => {
/* noop */
},
},
},
stubs: {
FAIcon: true,
},
}
describe('ChatView methods', () => {
let component
beforeEach(() => {
setActivePinia(createTestingPinia())
component = shallowMount(ChatView, { global, props: { testMode: true } })
component.vm.chat = { id: 2 }
})
describe('addMessages', () => {
it("Doesn't add duplicates", () => {
component.vm.addMessages({ messages: [message1] })
component.vm.addMessages({ messages: [message1] })
- expect(component.vm.messages.length).to.eql(1)
+ expect(component.vm.messages).to.have.length(1)
component.vm.addMessages({ messages: [message2] })
- expect(component.vm.messages.length).to.eql(2)
+ expect(component.vm.messages).to.have.length(2)
})
it('Updates minId and lastMessage and newMessageCount', async () => {
component.vm.addMessages({ messages: [message1] })
expect(component.vm.maxId).to.eql(message1.id)
expect(component.vm.minId).to.eql(message1.id)
expect(component.vm.newMessageCount).to.eql(1)
component.vm.addMessages({ messages: [message2] })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message1.id)
expect(component.vm.newMessageCount).to.eql(2)
await component.vm.readChat()
expect(component.vm.newMessageCount).to.eql(0)
expect(component.vm.lastReadMessageId).to.eql(message2.id)
// Add message with higher id
component.vm.addMessages({ messages: [message3] })
expect(component.vm.newMessageCount).to.eql(1)
})
})
describe('deleteChatMessage', () => {
it('Updates minId and lastMessage', () => {
component.vm.addMessages({ messages: [message1] })
component.vm.addMessages({ messages: [message2] })
component.vm.addMessages({ messages: [message3] })
expect(component.vm.maxId).to.eql(message3.id)
expect(component.vm.minId).to.eql(message1.id)
component.vm.deleteChatMessage({ messageId: message3.id })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message1.id)
component.vm.deleteChatMessage({ messageId: message1.id })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message2.id)
})
})
describe('cullOlder', () => {
it('keeps 50 newest messages and messagesIndex matches', () => {
for (let i = 100; i > 0; i--) {
// Use decimal values with toFixed to hack together constant length predictable strings
component.vm.addMessages({
messages: [
{
...message1,
id: 'a' + (i / 1000).toFixed(3),
idempotency_key: i,
},
],
})
}
component.vm.cullOlder()
- expect(component.vm.messages.length).to.eql(50)
+ expect(component.vm.messages).to.have.length(50)
expect(component.vm.messages[0].id).to.eql('a0.051')
expect(component.vm.minId).to.eql('a0.051')
expect(component.vm.messages[49].id).to.eql('a0.100')
- expect(Object.keys(component.vm.messagesIndex).length).to.eql(50)
+ expect(Object.keys(component.vm.messagesIndex)).to.have.length(50)
})
})
})
diff --git a/test/unit/specs/components/post_status_form.spec.js b/test/unit/specs/components/post_status_form.spec.js
index 03c0ec8b91..f89102504d 100644
--- a/test/unit/specs/components/post_status_form.spec.js
+++ b/test/unit/specs/components/post_status_form.spec.js
@@ -1,322 +1,322 @@
import { mount } from '@vue/test-utils'
import { vi } from 'vitest'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { mountOpts } from '../../../fixtures/setup_test'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const currentUser = {
id: 'current-user',
default_scope: 'public',
locked: false,
}
const repliedUser = {
id: 'replied-user',
screen_name: 'replied',
}
const repliedStatus = {
id: 'status-1',
visibility: 'public',
user: repliedUser,
}
const repliedStatus2 = {
id: 'status-2',
visibility: 'private',
summary: 'subject',
user: repliedUser,
}
const replyMountOpts = (props) =>
mountOpts({
props,
afterStore(store) {
store.state.users.currentUser = currentUser
store.state.statuses.allStatusesObject = {
[repliedStatus.id]: repliedStatus,
}
},
})
describe('PostStatusForm', () => {
beforeEach(() => {
vi.useFakeTimers()
})
it('Clean empty initial state', () => {
const wrapper = mount(PostStatusForm, replyMountOpts())
expect(wrapper.vm.statusType).to.equal('new')
expect(wrapper.vm.newStatus.spoilerText).to.eql('')
expect(wrapper.vm.newStatus.mentions).to.eql('')
expect(wrapper.vm.newStatus.status).to.eql('')
})
it('Reset cleans form to pristine state equal to state form was when created', () => {
const wrapper = mount(PostStatusForm, replyMountOpts())
const initial = { ...wrapper.vm.newStatus }
wrapper.vm.clearStatus()
expect(wrapper.vm.newStatus).to.eql(initial)
})
it('Initializes a reply form', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus,
}),
)
useInstanceCapabilitiesStore().quotingAvailable = true
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.refId).to.equal('status-1')
expect(wrapper.vm.quotable).to.equal(true)
expect(wrapper.vm.inReplyToStatusId).to.equal('status-1')
- expect(wrapper.vm.newStatus.quote).to.eql(null)
- expect(wrapper.vm.newStatus.poll).to.eql(null)
+ expect(wrapper.vm.newStatus.quote).to.be.null
+ expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('public')
})
it('Copies scope and subject line, disables quoting for locked posts', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus2,
}),
)
useInstanceCapabilitiesStore().quotingAvailable = true
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.quotable).to.equal(false)
- expect(wrapper.vm.newStatus.quote).to.eql(null)
- expect(wrapper.vm.newStatus.poll).to.eql(null)
+ expect(wrapper.vm.newStatus.quote).to.be.null
+ expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('re: subject')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('private')
expect(wrapper.vm.postingOptions.status).to.eql('@replied ')
expect(wrapper.vm.postingOptions.spoilerText).to.eql('re: subject')
expect(wrapper.vm.postingOptions.visibility).to.eql('private')
expect(wrapper.vm.postingOptions.sensitive).to.eql(false)
expect(wrapper.vm.postingOptions.media).to.eql([])
expect(wrapper.vm.postingOptions.inReplyToStatusId).to.eql('status-2')
- expect(wrapper.vm.postingOptions.quoteId).to.eql(null)
+ expect(wrapper.vm.postingOptions.quoteId).to.be.null
expect(wrapper.vm.postingOptions.contentType).to.eql('text/plain')
- expect(wrapper.vm.postingOptions.poll).to.eql(null)
+ expect(wrapper.vm.postingOptions.poll).to.be.null
})
it('Forces direct mode when replying to a DM, mastodon style subject handling', () => {
// We need to initialize pinia first which is happening here...
const options = replyMountOpts({
repliedStatus: { ...repliedStatus2, visibility: 'direct' },
})
// ...set our settings...
useMergedConfigStore().mergedConfig = {
...useMergedConfigStore().mergedConfig,
subjectLineBehavior: 'masto',
}
// ...and only then mount our component
const wrapper = mount(PostStatusForm, options)
// Otherwise we get multiple instances of pinia that don't talk to each other
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.quotable).to.equal(false)
- expect(wrapper.vm.newStatus.quote).to.eql(null)
- expect(wrapper.vm.newStatus.poll).to.eql(null)
+ expect(wrapper.vm.newStatus.quote).to.be.null
+ expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('subject')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('direct')
})
it('Sets status to statusText without mentions if mentions line is enabled', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus2,
statusText: 'testing',
mentionsLine: true,
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('testing')
})
it('Sets mention when asked for it', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
profileMention: repliedUser,
}),
)
expect(wrapper.vm.statusType).to.equal('mention')
expect(wrapper.vm.isReply).to.equal(false)
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
})
it('Initializes quote when reply/quote toggled to quote', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus2,
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.quoteThreadToggled = true
expect(wrapper.vm.newStatus.quote).to.eql({ thread: true, id: 'status-2' })
})
it('Resets quote when reply/quote toggled to reply', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus2,
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.quoteThreadToggled = true
wrapper.vm.quoteThreadToggled = false
- expect(wrapper.vm.newStatus.quote).to.eql(null)
+ expect(wrapper.vm.newStatus.quote).to.be.null
})
it('Initializes and reset quote when toggling quote attachment', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus2,
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.toggleQuoteForm()
expect(wrapper.vm.newStatus.quote).to.eql({
thread: false,
id: null,
url: '',
})
wrapper.vm.toggleQuoteForm()
- expect(wrapper.vm.newStatus.quote).to.eql(null)
+ expect(wrapper.vm.newStatus.quote).to.be.null
})
it('Status editing', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
statusId: 'edited',
statusText: 'text',
statusSubject: 'heading',
statusIsSensitive: true,
statusPoll: {},
statusQuote: {},
statusFiles: [],
statusMediaDescriptions: {},
statusVisibility: 'unlisted',
statusContentType: 'text/markdown',
}),
)
expect(wrapper.vm.statusType).to.equal('edit')
expect(wrapper.vm.isReply).to.equal(false) // edits don't support changing reply-to so it's pretty much ignored
expect(wrapper.vm.isEdit).to.equal(true)
expect(wrapper.vm.newStatus.quote).to.eql({})
expect(wrapper.vm.newStatus.poll).to.eql({})
expect(wrapper.vm.newStatus.spoilerText).to.eql('heading')
expect(wrapper.vm.newStatus.mentions).to.eql('')
expect(wrapper.vm.newStatus.status).to.eql('text')
expect(wrapper.vm.newStatus.visibility).to.eql('unlisted')
expect(wrapper.vm.newStatus.contentType).to.eql('text/markdown')
expect(wrapper.vm.newStatus.nsfw).to.equal(true)
expect(wrapper.vm.newStatus.files).to.eql([])
})
it('Posting should reset idempotency key', async () => {
vi.setSystemTime(new Date(2027, 1, 1, 13))
const wrapper = mount(PostStatusForm, replyMountOpts())
const oldIdempotency = wrapper.vm.idempotencyKey
vi.setSystemTime(new Date(2028, 1, 1, 13))
wrapper.vm.newStatus.status = 'Testing'
await wrapper.vm.postStatus()
expect(wrapper.vm.idempotencyKey).to.not.eql(oldIdempotency)
})
// TODO Probably better to separate attachment upload/manipulation into its own component?
// we need to upload-on-submit for compression setting anyway
it('Attachments manipulations (moving, adding, removing)', () => {
const wrapper = mount(PostStatusForm, replyMountOpts())
const i1 = { id: '1', url: 'a' }
const i2 = { id: '2', url: 'b' }
const i3 = { id: '3', url: 'c' }
const i4 = { id: '4', url: 'd' }
const iX = { id: 'x', url: 'x' }
wrapper.vm.newStatus.files = [i3, i1, iX, i2]
wrapper.vm.removeMediaFile(iX)
expect(wrapper.vm.newStatus.files).to.eql([i3, i1, i2])
wrapper.vm.shiftUpMediaFile(i1)
expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
wrapper.vm.shiftUpMediaFile(i1) // should ignore
expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
wrapper.vm.shiftDnMediaFile(i3)
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
wrapper.vm.shiftDnMediaFile(i3) // should ignore
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
wrapper.vm.addMediaFile(i4)
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3, i4])
})
it('Attachment descriptions', () => {
const wrapper = mount(PostStatusForm, replyMountOpts())
const i1 = { id: '1', url: 'a' }
wrapper.vm.addMediaFile(i1)
expect(wrapper.vm.newStatus.files).to.eql([i1])
wrapper.vm.editAttachment(i1, 'description')
expect(wrapper.vm.newStatus.mediaDescriptions['1']).to.eql('description')
})
// TODO: Drafts (needs vuex to pinia migration)
})
diff --git a/test/unit/specs/lib/persisted_state.spec.js b/test/unit/specs/lib/persisted_state.spec.js
index de7d92bd89..c37d0f99f0 100644
--- a/test/unit/specs/lib/persisted_state.spec.js
+++ b/test/unit/specs/lib/persisted_state.spec.js
@@ -1,326 +1,326 @@
import { flushPromises } from '@vue/test-utils'
import { createPinia, defineStore, setActivePinia } from 'pinia'
import { createApp } from 'vue'
import { piniaPersistPlugin } from 'src/lib/persisted_state.js'
const app = createApp({})
const getMockStorage = () => {
let state = {}
return {
getItem: vi.fn(async (key) => {
console.info('get:', key, state[key])
return state[key]
}),
setItem: vi.fn(async (key, value) => {
console.info('set:', key, value)
state[key] = value
}),
_clear: () => {
state = {}
},
}
}
let mockStorage
beforeEach(() => {
mockStorage = getMockStorage()
const pinia = createPinia().use(piniaPersistPlugin({ storage: mockStorage }))
app.use(pinia)
setActivePinia(pinia)
})
describe('piniaPersistPlugin', () => {
describe('initial state', () => {
it('does not load anything if it is not enabled', async () => {
await mockStorage.setItem('pinia-local-test', { a: 3 })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(1)
expect(test.b).to.eql(2)
})
test('$persistLoaded rejects if getItem() throws', async () => {
const error = new Error('unable to get storage')
mockStorage.getItem = vi.fn(async () => {
throw error
})
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {},
})
const test = useTestStore()
await expect(test.$persistLoaded).rejects.toThrowError(error)
})
it('loads from pinia storage', async () => {
await mockStorage.setItem('pinia-local-test', { a: 3, c: { d: 0 } })
await mockStorage.setItem('vuex-lz', { test: { a: 4 } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {},
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(3)
expect(test.b).to.eql(2)
expect(test.c.d).to.eql(0)
expect(test.c.e).to.eql(5)
})
it('loads from vuex storage as fallback', async () => {
await mockStorage.setItem('vuex-lz', { test: { a: 4, c: { d: 0 } } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {},
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(4)
expect(test.b).to.eql(2)
expect(test.c.d).to.eql(0)
expect(test.c.e).to.eql(5)
})
it('loads from vuex storage and writes it into pinia storage', async () => {
await mockStorage.setItem('vuex-lz', { test: { a: 4, c: { d: 0 } } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {
afterLoad(state) {
return {
...state,
a: 5,
}
},
},
})
const test = useTestStore()
await test.$persistLoaded
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 4,
c: { d: 0 },
})
expect(test.a).to.eql(5)
expect(test.b).to.eql(2)
expect(test.c.d).to.eql(0)
expect(test.c.e).to.eql(5)
})
it('does not modify state if there is nothing to load', async () => {
await mockStorage.setItem('vuex-lz', { test2: { a: 4 } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {},
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(1)
expect(test.b).to.eql(2)
})
})
describe('paths', () => {
it('saves everything if paths is unspecified', async () => {
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {},
})
const test = useTestStore()
await test.$persistLoaded
test.$patch({ a: 3 })
await flushPromises()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 3,
b: 2,
})
})
it('saves only specified paths', async () => {
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {
paths: ['a', 'c.d'],
},
})
const test = useTestStore()
await test.$persistLoaded
test.$patch({ a: 3 })
await flushPromises()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 3,
c: { d: 4 },
})
})
})
it('only saves after load', async () => {
const onSaveError = vi.fn()
const onSaveSuccess = vi.fn()
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
onSaveSuccess,
onSaveError,
},
})
const test = useTestStore()
test.$patch({ a: 3 })
- expect(await mockStorage.getItem('pinia-local-test')).to.eql(undefined)
+ expect(await mockStorage.getItem('pinia-local-test')).to.be.undefined
// NOTE: it should not even have tried to save, because the subscribe function
// is called only after loading the initial state.
expect(mockStorage.setItem).not.toHaveBeenCalled()
// this asserts that it has not called setState() in persistCurrentState()
expect(onSaveError).not.toHaveBeenCalled()
expect(onSaveSuccess).not.toHaveBeenCalled()
await test.$persistLoaded
test.$patch({ a: 4 })
expect(await mockStorage.getItem('pinia-local-test')).to.eql({ a: 4, b: 2 })
})
describe('saveImmediatelyActions', () => {
it('should only persist state after specified actions', async () => {
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
actions: {
increaseA() {
++this.a
},
increaseB() {
++this.b
},
},
persist: {
saveImmediatelyActions: ['increaseA'],
},
})
const test = useTestStore()
await test.$persistLoaded
await test.increaseA()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 2,
b: 2,
})
await test.increaseB()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 2,
b: 2,
})
await test.increaseA()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 3,
b: 3,
})
})
})
describe('onSaveSuccess / onSaveError', () => {
test('onSaveSuccess is called after setState', async () => {
const onSaveSuccess = vi.fn()
const onSaveError = vi.fn()
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
onSaveSuccess,
onSaveError,
},
})
const test = useTestStore()
await test.$persistLoaded
test.$patch({ a: 3 })
await flushPromises()
expect(onSaveSuccess).toHaveBeenCalledTimes(1)
expect(onSaveError).toHaveBeenCalledTimes(0)
test.$patch({ a: 4 })
await flushPromises()
expect(onSaveSuccess).toHaveBeenCalledTimes(2)
expect(onSaveError).toHaveBeenCalledTimes(0)
})
test('onSaveError is called after setState fails', async () => {
mockStorage.setItem = vi.fn(async () => {
throw new Error('cannot save')
})
const onSaveSuccess = vi.fn()
const onSaveError = vi.fn()
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
onSaveSuccess,
onSaveError,
},
})
const test = useTestStore()
await test.$persistLoaded
await test.$patch({ a: 3 })
expect(onSaveSuccess).toHaveBeenCalledTimes(0)
expect(onSaveError).toHaveBeenCalledTimes(1)
await test.$patch({ a: 4 })
expect(onSaveSuccess).toHaveBeenCalledTimes(0)
expect(onSaveError).toHaveBeenCalledTimes(2)
})
})
describe('afterLoad', () => {
it('is called with the saved state object', async () => {
await mockStorage.setItem('pinia-local-test', { a: 2 })
const afterLoad = vi.fn(async (orig) => {
return { a: orig.a + 1 }
})
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
afterLoad,
},
})
const test = useTestStore()
await test.$persistLoaded
expect(afterLoad).toHaveBeenCalledTimes(1)
expect(afterLoad).toHaveBeenCalledWith({ a: 2 })
expect(test.a).to.eql(3)
})
it('is called with empty object if there is no saved state', async () => {
const afterLoad = vi.fn(async () => {
return { a: 3 }
})
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
afterLoad,
},
})
const test = useTestStore()
await test.$persistLoaded
expect(afterLoad).toHaveBeenCalledTimes(1)
expect(afterLoad).toHaveBeenCalledWith({})
expect(test.a).to.eql(3)
})
})
})
diff --git a/test/unit/specs/modules/statuses.spec.js b/test/unit/specs/modules/statuses.spec.js
index cd43496a92..04bd07ffec 100644
--- a/test/unit/specs/modules/statuses.spec.js
+++ b/test/unit/specs/modules/statuses.spec.js
@@ -1,447 +1,447 @@
import { createTestingPinia } from '@pinia/testing'
import {
defaultState,
mutations,
prepareStatus,
} from '../../../../src/modules/statuses.js'
createTestingPinia()
const makeMockStatus = ({ id, text, type = 'status' }) => {
return {
id,
user: { id: '0' },
name: 'status',
text: text || `Text number ${id}`,
fave_num: 0,
uri: '',
type,
attentions: [],
}
}
describe('Statuses module', () => {
describe('prepareStatus', () => {
it('sets deleted flag to false', () => {
const aStatus = makeMockStatus({ id: '1', text: 'Hello oniichan' })
expect(prepareStatus(aStatus).deleted).to.eq(false)
})
})
describe('addNewStatuses', () => {
it('adds the status to allStatuses and to the given timeline', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
mutations.addNewStatuses(state, {
statuses: [status],
timeline: 'public',
})
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
})
it('counts the status as new if it has not been seen on this timeline', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
mutations.addNewStatuses(state, {
statuses: [status],
timeline: 'public',
})
mutations.addNewStatuses(state, {
statuses: [status],
timeline: 'friends',
})
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
expect(state.allStatuses).to.eql([status])
expect(state.timelines.friends.statuses).to.eql([status])
expect(state.timelines.friends.visibleStatuses).to.eql([])
expect(state.timelines.friends.newStatusCount).to.equal(1)
})
it('add the statuses to allStatuses if no timeline is given', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
mutations.addNewStatuses(state, { statuses: [status] })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('adds the status to allStatuses and to the given timeline, directly visible', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([status])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('does not update the maxId when the noIdUpdate flag is set', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
const secondStatus = makeMockStatus({ id: '2' })
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.maxId).to.equal('1')
mutations.addNewStatuses(state, {
statuses: [secondStatus],
showImmediately: true,
timeline: 'public',
noIdUpdate: true,
})
expect(state.timelines.public.statuses).to.eql([secondStatus, status])
expect(state.timelines.public.visibleStatuses).to.eql([
secondStatus,
status,
])
expect(state.timelines.public.maxId).to.equal('1')
})
it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => {
const state = defaultState()
const nonVisibleStatus = makeMockStatus({ id: '1' })
const status = makeMockStatus({ id: '3' })
const statusTwo = makeMockStatus({ id: '2' })
const statusThree = makeMockStatus({ id: '4' })
mutations.addNewStatuses(state, {
statuses: [nonVisibleStatus],
showImmediately: false,
timeline: 'public',
})
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.addNewStatuses(state, {
statuses: [statusTwo],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.minVisibleId).to.equal('2')
mutations.addNewStatuses(state, {
statuses: [statusThree],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.statuses).to.eql([
statusThree,
status,
statusTwo,
nonVisibleStatus,
])
expect(state.timelines.public.visibleStatuses).to.eql([
statusThree,
status,
statusTwo,
])
})
it('splits retweets from their status and links them', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
const retweet = makeMockStatus({ id: '2', type: 'retweet' })
const modStatus = makeMockStatus({ id: '1', text: 'something else' })
retweet.retweeted_status = status
// It adds both statuses, but only the retweet to visible.
mutations.addNewStatuses(state, {
statuses: [retweet],
timeline: 'public',
showImmediately: true,
})
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[1].id).to.equal('2')
// It refers to the modified status.
mutations.addNewStatuses(state, {
statuses: [modStatus],
timeline: 'public',
})
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[0].text).to.equal(modStatus.text)
expect(state.allStatuses[1].id).to.equal('2')
expect(retweet.retweeted_status.text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
const modStatus = makeMockStatus({ id: '1', text: 'something else' })
// Add original status
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, {
statuses: [modStatus],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id, coming from a retweet', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
const modStatus = makeMockStatus({ id: '1', text: 'something else' })
const retweet = makeMockStatus({ id: '2', type: 'retweet' })
retweet.retweeted_status = modStatus
// Add original status
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, {
statuses: [retweet],
showImmediately: false,
timeline: 'public',
})
expect(state.timelines.public.visibleStatuses).to.have.length(1)
// Don't add the retweet itself if the tweet is visible
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('handles favorite actions', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
const favorite = {
id: '2',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user: { id: '99' },
}
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.addNewStatuses(state, {
statuses: [favorite],
showImmediately: true,
timeline: 'public',
})
- expect(state.timelines.public.visibleStatuses.length).to.eql(1)
+ expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// Adding it again does nothing
mutations.addNewStatuses(state, {
statuses: [favorite],
showImmediately: true,
timeline: 'public',
})
- expect(state.timelines.public.visibleStatuses.length).to.eql(1)
+ expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request.
const user = {
id: '1',
}
const ownFavorite = {
id: '3',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user,
}
mutations.addNewStatuses(state, {
statuses: [ownFavorite],
showImmediately: true,
timeline: 'public',
user,
})
- expect(state.timelines.public.visibleStatuses.length).to.eql(1)
+ expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true)
})
})
describe('emojiReactions', () => {
it('increments count in existing reaction', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
status.emoji_reactions = [{ name: '😂', count: 1, accounts: [] }]
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.addOwnReaction(state, {
id: '1',
emoji: '😂',
currentUser: { id: 'me' },
})
expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(2)
expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true)
expect(
state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id,
).to.equal('me')
})
it('adds a new reaction', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
status.emoji_reactions = []
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.addOwnReaction(state, {
id: '1',
emoji: '😂',
currentUser: { id: 'me' },
})
expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1)
expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true)
expect(
state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id,
).to.equal('me')
})
it('decreases count in existing reaction', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
status.emoji_reactions = [
{ name: '😂', count: 2, accounts: [{ id: 'me' }] },
]
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.removeOwnReaction(state, {
id: '1',
emoji: '😂',
currentUser: { id: 'me' },
})
expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1)
expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(false)
expect(state.allStatusesObject['1'].emoji_reactions[0].accounts).to.eql(
[],
)
})
it('removes a reaction', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
status.emoji_reactions = [
{ name: '😂', count: 1, accounts: [{ id: 'me' }] },
]
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.removeOwnReaction(state, {
id: '1',
emoji: '😂',
currentUser: { id: 'me' },
})
- expect(state.allStatusesObject['1'].emoji_reactions.length).to.eql(0)
+ expect(state.allStatusesObject['1'].emoji_reactions).to.have.length(0)
})
})
describe('showNewStatuses', () => {
it('resets the minId to the min of the visible statuses when adding new to visible statuses', () => {
const state = defaultState()
const status = makeMockStatus({ id: '10' })
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
const newStatus = makeMockStatus({ id: '20' })
mutations.addNewStatuses(state, {
statuses: [newStatus],
showImmediately: false,
timeline: 'public',
})
state.timelines.public.minId = '5'
mutations.showNewStatuses(state, { timeline: 'public' })
- expect(state.timelines.public.visibleStatuses.length).to.eql(2)
+ expect(state.timelines.public.visibleStatuses).to.have.length(2)
expect(state.timelines.public.minVisibleId).to.equal('10')
expect(state.timelines.public.minId).to.equal('10')
})
})
describe('clearTimeline', () => {
it('keeps userId when clearing user timeline when excludeUserId param is true', () => {
const state = defaultState()
state.timelines.user.userId = 123
mutations.clearTimeline(state, { timeline: 'user', excludeUserId: true })
expect(state.timelines.user.userId).to.eql(123)
})
})
})
diff --git a/test/unit/specs/modules/users.spec.js b/test/unit/specs/modules/users.spec.js
index c07f817d82..1b33f8c4ea 100644
--- a/test/unit/specs/modules/users.spec.js
+++ b/test/unit/specs/modules/users.spec.js
@@ -1,120 +1,120 @@
import { cloneDeep } from 'lodash'
import {
defaultState,
getters,
mutations,
} from '../../../../src/modules/users.js'
describe('The users module', () => {
describe('mutations', () => {
it('adds new users to the set, merging in new information for old users', () => {
const state = cloneDeep(defaultState)
const user = { id: '1', name: 'Guy' }
const modUser = { id: '1', name: 'Dude' }
mutations.addNewUsers(state, [user])
expect(state.users).to.have.length(1)
expect(state.users).to.eql([user])
mutations.addNewUsers(state, [modUser])
expect(state.users).to.have.length(1)
expect(state.users).to.eql([user])
expect(state.users[0].name).to.eql('Dude')
})
it('merging array field in new information for old users', () => {
const state = cloneDeep(defaultState)
const user = {
id: '1',
fields: [{ name: 'Label 1', value: 'Content 1' }],
}
const firstModUser = {
id: '1',
fields: [
{ name: 'Label 2', value: 'Content 2' },
{ name: 'Label 3', value: 'Content 3' },
],
}
const secondModUser = {
id: '1',
fields: [{ name: 'Label 4', value: 'Content 4' }],
}
mutations.addNewUsers(state, [user])
expect(state.users[0].fields).to.have.length(1)
expect(state.users[0].fields[0].name).to.eql('Label 1')
mutations.addNewUsers(state, [firstModUser])
expect(state.users[0].fields).to.have.length(2)
expect(state.users[0].fields[0].name).to.eql('Label 2')
expect(state.users[0].fields[1].name).to.eql('Label 3')
mutations.addNewUsers(state, [secondModUser])
expect(state.users[0].fields).to.have.length(1)
expect(state.users[0].fields[0].name).to.eql('Label 4')
})
})
describe('findUser', () => {
it('does not return user with matching screen_name', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
},
usersByNameObject: {
guy: user,
},
}
const name = 'Guy'
- expect(getters.findUser(state)(name)).to.eql(undefined)
+ expect(getters.findUser(state)(name)).to.be.undefined
})
it('returns user with matching id', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
},
usersByNameObject: {
guy: user,
},
}
const id = '1'
const expected = { screen_name: 'Guy', id: '1' }
expect(getters.findUser(state)(id)).to.eql(expected)
})
})
describe('findUserByName', () => {
it('returns user with matching screen_name', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
},
usersByNameObject: {
guy: user,
},
}
const name = 'Guy'
const expected = { screen_name: 'Guy', id: '1' }
expect(getters.findUserByName(state)(name)).to.eql(expected)
})
it('does not return user with matching id', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
},
usersByNameObject: {
guy: user,
},
}
const id = '1'
- expect(getters.findUserByName(state)(id)).to.eql(undefined)
+ expect(getters.findUserByName(state)(id)).to.be.undefined
})
})
})
diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
index da3f88636f..c68c832a0f 100644
--- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
+++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
@@ -1,205 +1,205 @@
import mastoapidata from '../../../../fixtures/mastoapi.json'
import {
parseLinkHeaderPagination,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const makeMockUserMasto = (overrides = {}) => {
return {
acct: 'hj',
avatar:
'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
avatar_static:
'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
bot: false,
created_at: '2017-12-17T21:54:14.000Z',
display_name: 'whatever whatever whatever witch',
emojis: [],
fields: [],
followers_count: 705,
following_count: 326,
header:
'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
header_static:
'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
id: '1',
locked: false,
note: 'Volatile Internet Weirdo. Name pronounced as Hee Jay. JS and Java dark arts mage, Elixir trainee. I love sampo and lain. Matrix is <span><a data-user="1" href="https://shigusegubu.club/users/hj">@<span>hj</span></a></span>:matrix.heldscal.la Pronouns are whatever. Do not DM me unless it\'s truly private matter and you\'re instance\'s admin or you risk your DM to be reposted publicly.Wish i was Finnish girl.',
pleroma: { confirmation_pending: false, tags: null },
source: { note: '', privacy: 'public', sensitive: false },
statuses_count: 41775,
url: 'https://shigusegubu.club/users/hj',
username: 'hj',
...overrides,
}
}
const makeMockStatusMasto = (overrides = {}) => {
return {
account: makeMockUserMasto(),
application: { name: 'Web', website: null },
content:
'<span><a data-user="14660" href="https://pleroma.soykaf.com/users/sampo">@<span>sampo</span></a></span> god i wish i was there',
created_at: '2019-01-17T16:29:23.000Z',
emojis: [],
favourited: false,
favourites_count: 1,
id: '10423476',
in_reply_to_account_id: '14660',
in_reply_to_id: '10423197',
language: null,
media_attachments: [],
mentions: [
{
acct: 'sampo@pleroma.soykaf.com',
id: '14660',
url: 'https://pleroma.soykaf.com/users/sampo',
username: 'sampo',
},
],
muted: false,
reblog: null,
reblogged: false,
reblogs_count: 0,
replies_count: 0,
sensitive: false,
spoiler_text: '',
tags: [],
uri: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
url: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
visibility: 'public',
pleroma: {
local: true,
},
...overrides,
}
}
const makeMockEmojiMasto = (overrides = [{}]) => {
return [
{
shortcode: 'image',
static_url: 'https://example.com/image.png',
url: 'https://example.com/image.png',
visible_in_picker: false,
...overrides[0],
},
{
shortcode: 'thinking',
static_url: 'https://example.com/think.png',
url: 'https://example.com/think.png',
visible_in_picker: false,
...overrides[1],
},
]
}
describe('API Entities normalizer', () => {
describe('parseStatus', () => {
describe('Mastoapi preprocessing and converting', () => {
it("doesn't blow up", () => {
const parsed = mastoapidata.map(parseStatus)
- expect(parsed.length).to.eq(mastoapidata.length)
+ expect(parsed).to.have.length(mastoapidata.length)
})
it('processes repeats correctly', () => {
const post = makeMockStatusMasto({ reblog: null, id: 'deadbeef' })
const repeat = makeMockStatusMasto({ reblog: post, id: 'foobar' })
const parsedPost = parseStatus(post)
const parsedRepeat = parseStatus(repeat)
expect(parsedPost).to.have.property('type', 'status')
expect(parsedRepeat).to.have.property('type', 'retweet')
expect(parsedRepeat).to.have.property('retweeted_status')
expect(parsedRepeat).to.have.nested.property(
'retweeted_status.id',
'deadbeef',
)
})
})
})
// Statuses generally already contain some info regarding users and there's nearly 1:1 mapping, so very little to test
describe('parseUsers (MastoAPI)', () => {
it('sets correct is_local for users depending on their screen_name', () => {
const local = makeMockUserMasto({ acct: 'foo' })
const remote = makeMockUserMasto({ acct: 'foo@bar.baz' })
expect(parseUser(local)).to.have.property('is_local', true)
expect(parseUser(remote)).to.have.property('is_local', false)
})
it('removes html tags from user profile fields', () => {
const user = makeMockUserMasto({
emojis: makeMockEmojiMasto(),
fields: [
{
name: 'user',
value: '<a rel="me" href="https://example.com/@user">@user</a>',
},
],
})
const parsedUser = parseUser(user)
expect(parsedUser).to.have.property('fields_text').to.be.an('array')
const field = parsedUser.fields_text[0]
expect(field).to.have.property('name').that.equal('user')
expect(field).to.have.property('value').that.equal('@user')
})
it('adds hide_follows and hide_followers user settings', () => {
const user = makeMockUserMasto({
pleroma: {
hide_followers: true,
hide_follows: false,
hide_followers_count: false,
hide_follows_count: true,
},
})
expect(parseUser(user)).to.have.property('hide_followers', true)
expect(parseUser(user)).to.have.property('hide_follows', false)
expect(parseUser(user)).to.have.property('hide_followers_count', false)
expect(parseUser(user)).to.have.property('hide_follows_count', true)
})
it('converts IDN to unicode and marks it as internatonal', () => {
const user = makeMockUserMasto({ acct: 'lain@xn--lin-6cd.com' })
expect(parseUser(user))
.to.have.property('screen_name_ui')
.that.equal('lain@lаin.com')
expect(parseUser(user))
.to.have.property('screen_name_ui_contains_non_ascii')
.that.equal(true)
})
})
describe('Link header pagination', () => {
it('Parses min and max ids as integers', () => {
const linkHeader =
'<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader)
expect(result).to.eql({
maxId: 861676,
minId: 861741,
})
})
it('Parses min and max ids as flakes', () => {
const linkHeader =
'<http://example.com/api/v1/timelines/home?max_id=9waQx5IIS48qVue2Ai>; rel="next", <http://example.com/api/v1/timelines/home?min_id=9wi61nIPnfn674xgie>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader, { flakeId: true })
expect(result).to.eql({
maxId: '9waQx5IIS48qVue2Ai',
minId: '9wi61nIPnfn674xgie',
})
})
})
})
diff --git a/test/unit/specs/stores/sync_config.spec.js b/test/unit/specs/stores/sync_config.spec.js
index a045aa18ae..d2e6d42d31 100644
--- a/test/unit/specs/stores/sync_config.spec.js
+++ b/test/unit/specs/stores/sync_config.spec.js
@@ -1,674 +1,674 @@
import { cloneDeep } from 'lodash'
import { createPinia, setActivePinia } from 'pinia'
import {
_getAllFlags,
_getRecentData,
_mergeFlags,
_mergePrefs,
_moveItemInArray,
_resetFlags,
COMMAND_TRIM_FLAGS,
COMMAND_TRIM_FLAGS_AND_RESET,
defaultState,
newUserFlags,
useSyncConfigStore,
VERSION,
} from 'src/stores/sync_config.js'
describe('The SyncConfig store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('mutations', () => {
describe('initSyncConfig', () => {
const user = {
created_at: new Date('1999-02-09'),
storage: {},
}
it('should initialize storage if none present', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({ ...user })
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(defaultState.flagStorage)
expect(store.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should initialize storage with proper flags for new users if none present', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({ ...user, created_at: new Date() })
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(newUserFlags)
expect(store.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should merge flags even if remote timestamp is older', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.cache = {
_timestamp: Date.now(),
_version: VERSION,
...cloneDeep(defaultState),
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 1,
},
prefsStorage: {
...defaultState.prefsStorage,
},
},
})
expect(store.flagStorage).to.eql({
...defaultState.flagStorage,
updateCounter: 1,
})
})
it('should trim journal to 500 entries', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.cache = {
_timestamp: Date.now(),
_version: VERSION,
...cloneDeep(defaultState),
}
const largeJournal = []
for (let value = 0; value < 1000; value++) {
largeJournal.push({
path: 'simple.palette' + value,
operation: 'set',
args: [value],
// should have A timestamp, we don't really care what it is
timestamp: 123456,
})
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 1,
},
prefsStorage: {
...defaultState.prefsStorage,
_journal: largeJournal,
},
},
})
- expect(store.prefsStorage._journal.length).to.eql(500)
+ expect(store.prefsStorage._journal).to.have.length(500)
})
it('should reset local timestamp to remote if contents are the same', async () => {
const store = useSyncConfigStore()
store.cache = null
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 999,
},
},
})
expect(store.cache._timestamp).to.eql(123)
expect(store.flagStorage.updateCounter).to.eql(999)
expect(store.cache.flagStorage.updateCounter).to.eql(999)
})
it('should use remote version if local missing', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig(store, user)
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(defaultState.flagStorage)
})
})
describe('setPreference', () => {
it('should set preference and update journal log accordingly', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.palette', value: '1' })
expect(store.prefsStorage.simple.palette).to.eql('1')
- expect(store.prefsStorage._journal.length).to.eql(1)
+ expect(store.prefsStorage._journal).to.have.length(1)
expect(store.prefsStorage._journal[0]).to.eql({
path: 'simple.palette',
operation: 'set',
args: ['1'],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[0].timestamp,
})
})
it('should keep journal to a minimum', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.palette', value: 1 })
store.setPreference({ path: 'simple.palette', value: 2 })
store.addCollectionPreference({ path: 'collections.palette', value: 2 })
store.removeCollectionPreference({
path: 'collections.palette',
value: 2,
})
store.updateCache({ username: 'test' })
expect(store.prefsStorage.simple.palette).to.eql(2)
expect(store.prefsStorage.collections.palette).to.eql([])
- expect(store.prefsStorage._journal.length).to.eql(2)
+ expect(store.prefsStorage._journal).to.have.length(2)
expect(store.prefsStorage._journal[0]).to.eql({
path: 'simple.palette',
operation: 'set',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[0].timestamp,
})
expect(store.prefsStorage._journal[1]).to.eql({
path: 'collections.palette',
operation: 'removeFromCollection',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[1].timestamp,
})
})
it('should remove duplicate entries from journal', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.palette', value: 1 })
store.setPreference({ path: 'simple.palette', value: 1 })
store.addCollectionPreference({ path: 'collections.palette', value: 2 })
store.addCollectionPreference({ path: 'collections.palette', value: 2 })
store.updateCache({ username: 'test' })
expect(store.prefsStorage.simple.palette).to.eql(1)
expect(store.prefsStorage.collections.palette).to.eql([2])
- expect(store.prefsStorage._journal.length).to.eql(2)
+ expect(store.prefsStorage._journal).to.have.length(2)
})
// TODO We need a proper test for object-based stores
it.skip('should remove depth = 3 set/unset entries from journal', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.fontInput', value: 'test' })
store.unsetPreference({ path: 'simple.fontInput' })
store.updateCache(store, { username: 'test' })
expect(store.prefsStorage.simple.fontInput).to.not.have.property(
'family',
)
- expect(store.prefsStorage._journal.length).to.eql(1)
+ expect(store.prefsStorage._journal).to.have.length(1)
})
it('should not allow unsetting depth <= 2', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.object.foo', value: 1 })
expect(() => store.unsetPreference({ path: 'simple' })).to.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object' }),
).to.throw()
})
it('should not allow (un)setting depth > 3', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.object', value: {} })
expect(() =>
store.setPreference({ path: 'simple.object.lv3', value: 1 }),
).to.not.throw()
expect(() =>
store.setPreference({ path: 'simple.object.lv3.lv4', value: 1 }),
).to.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object.lv3', value: 1 }),
).to.not.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object.lv3.lv4', value: 1 }),
).to.throw()
})
})
})
describe('helper functions', () => {
describe('_moveItemInArray', () => {
it('should move item according to movement value', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -1)).to.eql([1, 2, 4, 3])
expect(_moveItemInArray([1, 2, 3, 4], 1, 2)).to.eql([2, 3, 1, 4])
})
it('should clamp movement to within array', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -10)).to.eql([4, 1, 2, 3])
expect(_moveItemInArray([1, 2, 3, 4], 3, 99)).to.eql([1, 2, 4, 3])
})
})
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it("doesn't choke on invalid data", () => {
expect(_getRecentData({ a: 1 }, { b: 2 }, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it('should prefer the valid non-null correctly, needUpload works properly', () => {
const nonNull = { _version: VERSION, _timestamp: 1 }
expect(_getRecentData(nonNull, null, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: true,
})
expect(_getRecentData(null, nonNull, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: false,
})
})
it('should prefer the one with higher timestamp', () => {
const a = { _version: VERSION, _timestamp: 1 }
const b = { _version: VERSION, _timestamp: 2 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
it('case where both are same', () => {
const a = { _version: VERSION, _timestamp: 3 }
const b = { _version: VERSION, _timestamp: 3 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
})
describe('_getAllFlags', () => {
it('should handle nulls properly', () => {
expect(_getAllFlags(null, null)).to.eql([])
})
it('should output list of keys if passed single object', () => {
expect(
_getAllFlags({ flagStorage: { a: 1, b: 1, c: 1 } }, null),
).to.eql(['a', 'b', 'c'])
})
it('should union keys of both objects', () => {
expect(
_getAllFlags(
{ flagStorage: { a: 1, b: 1, c: 1 } },
{ flagStorage: { c: 1, d: 1 } },
),
).to.eql(['a', 'b', 'c', 'd'])
})
})
describe('_mergeFlags', () => {
it('should handle merge two flag sets correctly picking higher numbers', () => {
expect(
_mergeFlags(
{ flagStorage: { a: 0, b: 3 } },
{ flagStorage: { b: 1, c: 4, d: 9 } },
['a', 'b', 'c', 'd'],
),
).to.eql({ a: 0, b: 3, c: 4, d: 9 })
})
})
describe('_mergePrefs', () => {
it('should prefer recent and apply journal to it', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { theme: '1', style: '0', hideISP: true },
_journal: [
{
path: 'simple.style',
operation: 'set',
args: ['0'],
timestamp: 2,
},
{
path: 'simple.hideISP',
operation: 'set',
args: [true],
timestamp: 4,
},
],
},
// STALE
{
simple: { theme: '1', style: '1', hideISP: false },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['1'],
timestamp: 1,
},
{
path: 'simple.style',
operation: 'set',
args: ['1'],
timestamp: 3,
},
],
},
),
).to.eql({
simple: { theme: '1', style: '1', hideISP: true },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['1'],
timestamp: 1,
},
{
path: 'simple.style',
operation: 'set',
args: ['1'],
timestamp: 3,
},
{
path: 'simple.hideISP',
operation: 'set',
args: [true],
timestamp: 4,
},
],
})
})
it('should allow setting falsy values', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { theme: '1', style: '0', hideISP: false },
_journal: [
{
path: 'simple.style',
operation: 'set',
args: ['0'],
timestamp: 2,
},
{
path: 'simple.hideISP',
operation: 'set',
args: [false],
timestamp: 4,
},
],
},
// STALE
{
simple: { theme: '0', style: '0', hideISP: true },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['0'],
timestamp: 1,
},
{
path: 'simple.style',
operation: 'set',
args: ['0'],
timestamp: 3,
},
],
},
),
).to.eql({
simple: { theme: '0', style: '0', hideISP: false },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['0'],
timestamp: 1,
},
{
path: 'simple.style',
operation: 'set',
args: ['0'],
timestamp: 3,
},
{
path: 'simple.hideISP',
operation: 'set',
args: [false],
timestamp: 4,
},
],
})
})
it('should work with strings', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { theme: 'foo' },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
simple: { theme: 'bar' },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
},
),
).to.eql({
simple: { theme: 'bar' },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
})
})
it('should work with objects', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { fontInput: { lv3: 'foo' } },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
simple: { fontInput: { lv3: 'bar' } },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
},
),
).to.eql({
simple: { fontInput: { lv3: 'bar' } },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
})
})
it('should work with unset', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { fontInput: { lv3: 'foo' } },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
simple: { fontInput: {} },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'unset',
args: [],
timestamp: 4,
},
],
},
),
).to.eql({
simple: { fontInput: {} },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'unset',
args: [],
timestamp: 4,
},
],
})
})
})
describe('_resetFlags', () => {
it('should trim all flags to known when reset is set to 1000', () => {
const totalFlags = { a: 0, b: 3, c: 33, reset: COMMAND_TRIM_FLAGS }
expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({
a: 0,
b: 3,
reset: 0,
})
})
it('should trim all flags to known and reset when reset is set to 1001', () => {
const totalFlags = {
a: 0,
b: 3,
c: 33,
reset: COMMAND_TRIM_FLAGS_AND_RESET,
}
expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({
a: 0,
b: 0,
reset: 0,
})
})
})
})
})
diff --git a/test/unit/specs/stores/user_highlight.spec.js b/test/unit/specs/stores/user_highlight.spec.js
index f80143b6e0..e97f8f3826 100644
--- a/test/unit/specs/stores/user_highlight.spec.js
+++ b/test/unit/specs/stores/user_highlight.spec.js
@@ -1,314 +1,314 @@
import { createPinia, setActivePinia } from 'pinia'
import {
_getRecentData,
_mergeHighlights,
useUserHighlightStore,
} from 'src/stores/user_highlight.js'
describe('The UserHighlight store', () => {
beforeEach(() => {
setActivePinia(createPinia())
window.vuex = {
state: {
users: {
currentUser: {
fqn: 'foo@bar.tld',
},
},
},
}
})
describe('mutations', () => {
describe('initUserHighlight', () => {
const user = {
created_at: new Date('1999-02-09'),
storage: {},
}
it('should initialize storage if none present', async () => {
const store = useUserHighlightStore()
await store.initUserHighlight({ ...user })
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.highlight).to.eql({ _journal: [] })
})
it('should initialize storage for new users if none present', async () => {
const store = useUserHighlightStore()
await store.initUserHighlight({ ...user, created_at: new Date() })
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.highlight).to.eql({ _journal: [] })
})
it('should use remote version if local missing', async () => {
const store = useUserHighlightStore()
await store.initUserHighlight(store, user)
expect(store.cache._timestamp).to.be.a('number')
})
})
describe('set', () => {
it('should set preference and update journal log accordingly', () => {
const store = useUserHighlightStore()
store.set({ user: 'highlight@testing', value: { type: 'test' } })
expect(store.highlight['highlight@testing']).to.eql({
user: 'highlight@testing',
type: 'test',
})
- expect(store.highlight._journal.length).to.eql(1)
+ expect(store.highlight._journal).to.have.length(1)
expect(store.highlight._journal[0]).to.eql({
user: 'highlight@testing',
operation: 'set',
args: [{ user: 'highlight@testing', type: 'test' }],
// should have A timestamp, we don't really care what it is
timestamp: store.highlight._journal[0].timestamp,
})
})
it('should keep journal to a minimum', () => {
const store = useUserHighlightStore()
store.set({ user: 'highlight@testing.xyz', value: { type: 'test' } })
store.set({ user: 'highlight@testing.xyz', value: { type: 'test' } })
store.updateCache({ username: 'test' })
expect(store.highlight['highlight@testing.xyz']).to.eql({
user: 'highlight@testing.xyz',
type: 'test',
})
- expect(store.highlight._journal.length).to.eql(1)
+ expect(store.highlight._journal).to.have.length(1)
expect(store.highlight._journal[0]).to.eql({
user: 'highlight@testing.xyz',
operation: 'set',
args: [
{
user: 'highlight@testing.xyz',
type: 'test',
},
],
// should have A timestamp, we don't really care what it is
timestamp: store.highlight._journal[0].timestamp,
})
})
it('should remove duplicate entries from journal', () => {
const store = useUserHighlightStore()
store.set({ user: 'a@test.xyz', value: { type: 'foo' } })
store.set({ user: 'a@test.xyz', value: { type: 'foo' } })
store.updateCache({ username: 'test' })
expect(store.highlight['a@test.xyz']).to.eql({
user: 'a@test.xyz',
type: 'foo',
})
- expect(store.highlight._journal.length).to.eql(1)
+ expect(store.highlight._journal).to.have.length(1)
})
})
})
describe('helper functions', () => {
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it("doesn't choke on invalid data", () => {
expect(_getRecentData({ a: 1 }, { b: 2 }, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it('should prefer the valid non-null correctly, needUpload works properly', () => {
const nonNull = { _timestamp: 1 }
expect(_getRecentData(nonNull, null, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: true,
})
expect(_getRecentData(null, nonNull, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: false,
})
})
it('should prefer the one with higher timestamp', () => {
const a = { _timestamp: 1 }
const b = { _timestamp: 2 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
it('case where both are same', () => {
const a = { _timestamp: 3 }
const b = { _timestamp: 3 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
})
describe('_mergeHighlights', () => {
it('should prefer recent and apply journal to it', () => {
expect(
_mergeHighlights(
{
// RECENT
'a@test.xyz': 1,
'b@test.xyz': 0,
'c@test.xyz': true,
_journal: [
{
user: 'b@test.xyz',
operation: 'set',
args: [0],
timestamp: 2,
},
{
user: 'c@test.xyz',
operation: 'set',
args: [true],
timestamp: 4,
},
],
},
{
// STALE
'a@test.xyz': 1,
'b@test.xyz': 1,
'c@test.xyz': false,
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [1],
timestamp: 1,
},
{
user: 'b@test.xyz',
operation: 'set',
args: [1],
timestamp: 3,
},
],
},
),
).to.eql({
'a@test.xyz': 1,
'b@test.xyz': 1,
'c@test.xyz': true,
_journal: [
{ user: 'a@test.xyz', operation: 'set', args: [1], timestamp: 1 },
{ user: 'b@test.xyz', operation: 'set', args: [1], timestamp: 3 },
{
user: 'c@test.xyz',
operation: 'set',
args: [true],
timestamp: 4,
},
],
})
})
it('should work with objects', () => {
expect(
_mergeHighlights(
// RECENT
{
'a@test.xyz': { type: 'foo' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'foo' }],
timestamp: 2,
},
],
},
// STALE
{
'a@test.xyz': { type: 'bar' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'bar' }],
timestamp: 4,
},
],
},
),
).to.eql({
'a@test.xyz': { type: 'bar' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'bar' }],
timestamp: 4,
},
],
})
})
it('should work with unset', () => {
expect(
_mergeHighlights(
// RECENT
{
'a@test.xyz': { type: 'foo' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'foo' }],
timestamp: 2,
},
],
},
// STALE
{
_journal: [
{
user: 'a@test.xyz',
operation: 'unset',
args: [],
timestamp: 4,
},
],
},
),
).to.eql({
_journal: [
{
user: 'a@test.xyz',
operation: 'unset',
args: [],
timestamp: 4,
},
],
})
})
})
})
})

File Metadata

Mime Type
text/x-diff
Expires
Sat, Aug 8, 10:33 PM (1 d, 20 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1724018
Default Alt Text
(600 KB)

Event Timeline